| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849 |
- from typing import Any, List, Optional, Tuple
- import re
- from datetime import datetime
- from types import SimpleNamespace
- from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
- from fastapi.responses import JSONResponse
- from pydantic import BaseModel, ConfigDict, Field
- from sqlalchemy import func, or_
- from sqlalchemy.orm import Session
- from app.core.database import get_db
- from app.core.db_auth import DbUserContext, get_db_current_user, get_db_current_user_optional
- from app.core.delete_guard import assert_row_deletable_by_creator, user_info_from_context
- from app.core.tenant_context import get_tenant_no
- from app.services.db_data_scope import apply_db_scope, assert_db_row_in_scope
- from app.services.db.inventory_id import next_inbound_outbound_id
- from app.services.db.inventory_list_filters import build_inventory_column_filters
- from app.services.db.list_sort import order_inventory, parse_sort_params
- from app.models.db_models import DbInboundOutbound, DbPurchasePlan, DbWeighingSlip
- from app.schemas.db.inventory import (
- CreateLinkForPlanPayload,
- InboundOutboundCreate,
- InboundOutboundListResponse,
- InboundOutboundRead,
- InboundOutboundUpdate,
- )
- from app.schemas.db.public_inventory import InventorySiteUrlRead
- from app.schemas.db.plans import PurchasePlanRead
- from app.schemas.db.weighing import WeighingSlipRead
- from app.services.db.mappers import (
- EMPTY_INVENTORY_DATE,
- inventory_to_read,
- is_empty_inventory_date,
- load_user_display_name_map,
- parse_date_str,
- plan_to_read,
- weighing_to_read,
- )
- from app.services.db.product_helpers import (
- apply_product_snapshot_to_inventory,
- resolve_crm_product,
- )
- from app.services.db_inventory_site_token import build_site_inventory_url, ensure_site_access_token
- from app.utils.excel_export import (
- EXPORT_MAX_ROWS,
- ExportColumn,
- ExportValidationError,
- export_rows_to_xlsx_response,
- filter_export_columns,
- )
- router = APIRouter(prefix="/inventory", tags=["inventory"])
- STATUS_THIRD_PARTY_FILL = "待三方填写"
- _INVENTORY_REMARK_FIELD = "remarks"
- def _base_query(db: Session, tenant_no: str):
- return db.query(DbInboundOutbound).filter(
- DbInboundOutbound.tenant_no == tenant_no,
- DbInboundOutbound.del_flag == 0,
- )
- def _generate_batch_no(db: Session, tenant_no: str) -> str:
- rows = (
- db.query(DbInboundOutbound.batch_no)
- .filter(
- DbInboundOutbound.tenant_no == tenant_no,
- DbInboundOutbound.del_flag == 0,
- DbInboundOutbound.batch_no.like("CRKDH%"),
- )
- .all()
- )
- max_seq = 0
- pattern = re.compile(r"^CRKDH(\d+)$")
- for (batch_no,) in rows:
- if batch_no:
- match = pattern.match(batch_no)
- if match:
- max_seq = max(max_seq, int(match.group(1)))
- return f"CRKDH{max_seq + 1:07d}"
- def _resolve_tenant_no(
- db: Session,
- user: Optional[DbUserContext],
- purchase_plan_id: Optional[str] = None,
- ) -> str:
- if user:
- return get_tenant_no(db, user.to_tenant_user_dict())
- if purchase_plan_id:
- plan = (
- db.query(DbPurchasePlan)
- .filter(DbPurchasePlan.id == purchase_plan_id, DbPurchasePlan.del_flag == 0)
- .one_or_none()
- )
- if plan:
- return plan.tenant_no
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="无法确定租户")
- def _get_inventory_by_id(db: Session, inventory_id: str) -> DbInboundOutbound:
- row = (
- db.query(DbInboundOutbound)
- .filter(DbInboundOutbound.id == inventory_id, DbInboundOutbound.del_flag == 0)
- .one_or_none()
- )
- if row is None:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail=f"Inventory {inventory_id} not found",
- )
- return row
- def _get_inventory(
- db: Session, tenant_no: str, inventory_id: str, user: DbUserContext
- ) -> DbInboundOutbound:
- row = (
- _base_query(db, tenant_no)
- .filter(DbInboundOutbound.id == inventory_id)
- .one_or_none()
- )
- if row is None:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail=f"Inventory {inventory_id} not found",
- )
- assert_db_row_in_scope(db, row, DbInboundOutbound, user, tenant_no)
- return row
- def _apply_inventory_product_update(
- db: Session, tenant_no: str, row: DbInboundOutbound, payload_dict: dict
- ) -> None:
- if "product" in payload_dict and "product_id" not in payload_dict:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="请从 CRM 重新选择产品",
- )
- product_id = payload_dict.pop("product_id", None)
- payload_dict.pop("product", None)
- payload_dict.pop("specification", None)
- if product_id is not None:
- snapshot = resolve_crm_product(db, tenant_no, product_id)
- apply_product_snapshot_to_inventory(row, snapshot)
- def _normalize_plan_no(value: Any) -> Optional[str]:
- if value is None:
- return None
- text = str(value).strip()
- return text or None
- def _pick_plan_for_inventory(plans: list, preferred_id: Optional[str]):
- """同一计划号命中多条时,优先已关联的计划,其次唯一产品。"""
- if preferred_id:
- for plan in plans:
- if plan.id == preferred_id:
- return plan
- if len(plans) == 1:
- return plans[0]
- product_ids = {plan.product_id for plan in plans if plan.product_id}
- if len(product_ids) == 1 and all(plan.product_id for plan in plans):
- return plans[0]
- return None
- def _text(value: Any) -> str:
- if value is None:
- return ""
- return str(value).strip()
- def _enforce_inventory_plan_on_edit(db: Session, row: DbInboundOutbound, payload_dict: dict) -> None:
- """新增和编辑:有计划号则必须存在,且产品、往来单位、业务类型与计划一致;没有计划号不限制。"""
- watched = ("plan_no", "product_id", "partner_company", "business_type")
- if not any(key in payload_dict for key in watched):
- return
- if "plan_no" in payload_dict:
- plan_no = _normalize_plan_no(payload_dict.get("plan_no"))
- payload_dict["plan_no"] = plan_no
- else:
- plan_no = _normalize_plan_no(row.plan_no)
- if not plan_no:
- if "plan_no" in payload_dict:
- payload_dict["purchase_plan_id"] = None
- return
- plans = (
- db.query(DbPurchasePlan)
- .filter(
- DbPurchasePlan.tenant_no == row.tenant_no,
- DbPurchasePlan.del_flag == 0,
- DbPurchasePlan.plan_no == plan_no,
- )
- .all()
- )
- if not plans:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="计划号不存在")
- preferred_id = payload_dict.get("purchase_plan_id") or row.purchase_plan_id
- plan = _pick_plan_for_inventory(plans, preferred_id)
- if plan is None:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="计划号对应多条计划,无法确定产品",
- )
- if not plan.product_id:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="该计划未关联产品,请先编辑计划并选择产品",
- )
- submitted_product_id = (
- payload_dict.get("product_id") if "product_id" in payload_dict else row.product_id
- )
- if submitted_product_id != plan.product_id:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="有计划号时,产品只能选择该计划的产品",
- )
- submitted_partner = (
- _text(payload_dict.get("partner_company"))
- if "partner_company" in payload_dict
- else _text(row.partner_company)
- )
- if submitted_partner != _text(plan.customer_name):
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="有计划号时,往来单位必须与该计划一致",
- )
- submitted_business_type = (
- _text(payload_dict.get("business_type"))
- if "business_type" in payload_dict
- else _text(row.business_type)
- )
- if submitted_business_type != _text(plan.business_type):
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="有计划号时,业务类型必须与该计划一致",
- )
- payload_dict["plan_no"] = (plan.plan_no or "").strip() or plan_no
- payload_dict["product_id"] = plan.product_id
- payload_dict["partner_company"] = _text(plan.customer_name)
- payload_dict["business_type"] = _text(plan.business_type)
- payload_dict["purchase_plan_id"] = plan.id
- def _apply_inventory_update(row: DbInboundOutbound, payload_dict: dict) -> None:
- for key, value in payload_dict.items():
- if key == _INVENTORY_REMARK_FIELD:
- if isinstance(value, str):
- value = value.strip()
- row.remark = value
- continue
- if key == "date":
- if value is None or (isinstance(value, str) and not value.strip()):
- row.date = EMPTY_INVENTORY_DATE
- else:
- row.date = parse_date_str(str(value)) or EMPTY_INVENTORY_DATE
- continue
- if isinstance(value, str):
- value = value.strip()
- setattr(row, key, value)
- def _maybe_promote_third_party_status(row: DbInboundOutbound) -> None:
- if row.status != STATUS_THIRD_PARTY_FILL:
- return
- has_date = not is_empty_inventory_date(row.date)
- has_qty = row.quantity is not None and row.quantity != 0
- has_vehicle = bool(row.vehicle_no and row.vehicle_no.strip())
- if has_date and has_qty and has_vehicle:
- row.status = "待入库" if row.business_type == "入库" else "待出库"
- def _merge_query(request: Request, **known) -> dict:
- out = {k: v for k, v in known.items() if v is not None and v != ""}
- for k, v in request.query_params.multi_items():
- if k in ("page", "pageSize"):
- continue
- if k not in out and v not in (None, ""):
- out[k] = v
- return out
- def _param_get(params: dict, *keys: str) -> Any:
- for key in keys:
- if key in params and params[key] not in (None, ""):
- return params[key]
- return None
- def _filters_from_params(params: dict) -> list:
- return build_inventory_column_filters(
- plan_no=_param_get(params, "planNo", "plan_no"),
- plan_no_in=_param_get(params, "planNoIn", "plan_no_in"),
- batch_no=_param_get(params, "batchNo", "batch_no"),
- batch_no_in=_param_get(params, "batchNoIn", "batch_no_in"),
- partner_company=_param_get(params, "partnerCompany", "partner_company"),
- partner_company_in=_param_get(params, "partnerCompanyIn", "partner_company_in"),
- product=_param_get(params, "product"),
- product_in=_param_get(params, "productIn", "product_in"),
- specification=_param_get(params, "specification"),
- specification_in=_param_get(params, "specificationIn", "specification_in"),
- vehicle_no=_param_get(params, "vehicleNo", "vehicle_no"),
- vehicle_no_in=_param_get(params, "vehicleNoIn", "vehicle_no_in"),
- business_type=_param_get(params, "businessType", "business_type"),
- business_type_in=_param_get(params, "businessTypeIn", "business_type_in"),
- status=_param_get(params, "status"),
- status_in=_param_get(params, "statusIn", "status_in"),
- date_from=_param_get(params, "dateFrom", "date_from"),
- date_to=_param_get(params, "dateTo", "date_to"),
- quantity_op=_param_get(params, "quantityOp", "quantity_op"),
- quantity=_param_get(params, "quantity"),
- quantity_min=_param_get(params, "quantityMin", "quantity_min"),
- quantity_max=_param_get(params, "quantityMax", "quantity_max"),
- )
- def _body_to_params(body: "InventoryExportBody") -> dict:
- data = body.model_dump(by_alias=True, exclude_none=True)
- data.pop("ids", None)
- data.pop("columns", None)
- extra = getattr(body, "model_extra", None) or {}
- for key, value in extra.items():
- if key not in data and value not in (None, ""):
- data[key] = value
- return data
- def _inventory_query(db: Session, user: DbUserContext, params: dict):
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- query = _base_query(db, tenant_no)
- q = _param_get(params, "q")
- if q:
- keyword = str(q).lower()
- query = query.filter(
- or_(
- func.lower(DbInboundOutbound.partner_company).contains(keyword),
- func.lower(DbInboundOutbound.product).contains(keyword),
- func.lower(DbInboundOutbound.plan_no).contains(keyword),
- func.lower(DbInboundOutbound.batch_no).contains(keyword),
- func.lower(DbInboundOutbound.vehicle_no).contains(keyword),
- )
- )
- column_filters = _filters_from_params(params)
- if column_filters:
- query = query.filter(*column_filters)
- query = apply_db_scope(query, DbInboundOutbound, user, db, tenant_no)
- sf, so = parse_sort_params(params)
- query = query.order_by(
- order_inventory(sf, so, default=DbInboundOutbound.date.desc()),
- DbInboundOutbound.id.desc(),
- )
- return query, tenant_no
- def _map_inventories(
- db: Session, tenant_no: str, inventories: list
- ) -> list[InboundOutboundRead]:
- inventory_ids = [inv.id for inv in inventories]
- weighing_map: dict[str, DbWeighingSlip] = {}
- if inventory_ids:
- weighings = (
- db.query(DbWeighingSlip)
- .filter(
- DbWeighingSlip.tenant_no == tenant_no,
- DbWeighingSlip.del_flag == 0,
- DbWeighingSlip.inbound_outbound_id.in_(inventory_ids),
- DbWeighingSlip.is_voided == 0,
- )
- .order_by(DbWeighingSlip.id.asc())
- .all()
- )
- for weighing in weighings:
- if (
- weighing.inbound_outbound_id
- and weighing.inbound_outbound_id not in weighing_map
- ):
- weighing_map[weighing.inbound_outbound_id] = weighing
- result_list: list[InboundOutboundRead] = []
- user_name_map = load_user_display_name_map(db, [inv.create_by for inv in inventories])
- for inventory in inventories:
- try:
- result_list.append(
- inventory_to_read(
- inventory,
- weighing=weighing_map.get(inventory.id),
- user_name_map=user_name_map,
- )
- )
- except Exception:
- continue
- return result_list
- def _fetch_inventory(
- db: Session,
- user: DbUserContext,
- params: dict,
- *,
- page: Optional[int] = None,
- page_size: Optional[int] = None,
- ) -> Tuple[list[InboundOutboundRead], int]:
- query, tenant_no = _inventory_query(db, user, params)
- if page is not None and page_size is not None:
- total = query.order_by(None).count()
- inventories = query.offset((page - 1) * page_size).limit(page_size).all()
- return _map_inventories(db, tenant_no, inventories), total
- inventories = query.all()
- items = _map_inventories(db, tenant_no, inventories)
- return items, len(items)
- @router.get("/")
- def list_inventory(
- request: Request,
- q: Optional[str] = Query(default=None, description="模糊搜索关键词"),
- plan_no: Optional[str] = Query(default=None, alias="planNo", description="计划号"),
- batch_no: Optional[str] = Query(default=None, alias="batchNo", description="出入库单号"),
- partner_company: Optional[str] = Query(
- default=None, alias="partnerCompany", description="往来单位"
- ),
- product: Optional[str] = Query(default=None, description="产品"),
- vehicle_no: Optional[str] = Query(default=None, alias="vehicleNo", description="车牌号"),
- date_from: Optional[str] = Query(default=None, alias="dateFrom", description="开始日期"),
- date_to: Optional[str] = Query(default=None, alias="dateTo", description="结束日期"),
- status_filter: Optional[str] = Query(default=None, alias="status", description="状态筛选"),
- business_type: Optional[str] = Query(default=None, alias="businessType", description="业务类型"),
- page: Optional[int] = Query(default=None, ge=1, description="页码;传入则返回分页结构"),
- page_size: Optional[int] = Query(
- default=None, ge=1, le=500, alias="pageSize", description="每页条数"
- ),
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ):
- params = _merge_query(
- request,
- q=q,
- planNo=plan_no,
- batchNo=batch_no,
- partnerCompany=partner_company,
- product=product,
- vehicleNo=vehicle_no,
- dateFrom=date_from,
- dateTo=date_to,
- status=status_filter,
- businessType=business_type,
- )
- paged = page is not None
- size = page_size if page_size is not None else 20
- page_num = page if page is not None else 1
- items, total = _fetch_inventory(
- db,
- user,
- params,
- page=page_num if paged else None,
- page_size=size if paged else None,
- )
- if paged:
- body = InboundOutboundListResponse(
- items=items, total=total, page=page_num, pageSize=size
- )
- return JSONResponse(content=body.model_dump(by_alias=True))
- return JSONResponse(content=[item.model_dump(by_alias=True) for item in items])
- class InventoryColumnFacetBody(BaseModel):
- """出入库单列头可选值(去重;可附带列表上下文)"""
- model_config = ConfigDict(populate_by_name=True, extra="allow")
- field: str = Field(..., description="planNo | batchNo | partnerCompany | product | specification | vehicleNo")
- keyword: Optional[str] = Field(default=None, description="可选值关键字")
- limit: Optional[int] = Field(default=500, ge=1, le=500, description="返回条数上限")
- _INVENTORY_FACET_COLUMNS = {
- "planNo": DbInboundOutbound.plan_no,
- "batchNo": DbInboundOutbound.batch_no,
- "partnerCompany": DbInboundOutbound.partner_company,
- "product": DbInboundOutbound.product,
- "specification": DbInboundOutbound.specification,
- "vehicleNo": DbInboundOutbound.vehicle_no,
- }
- @router.post("/column-facet-values")
- def inventory_column_facet_values(
- body: InventoryColumnFacetBody,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ):
- """出入库单列头可选值:在列表上下文(排除本列)下去重。"""
- from app.services.column_facet import list_column_facet_values
- from app.services.pl.facet_context import strip_simple_facet_self_params
- column = _INVENTORY_FACET_COLUMNS.get(body.field)
- if column is None:
- raise HTTPException(status_code=400, detail="不支持的列")
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- params = strip_simple_facet_self_params(_body_to_params(body), body.field)
- extra_filters = list(_filters_from_params(params))
- qv = _param_get(params, "q")
- if qv:
- keyword = str(qv).lower()
- extra_filters.append(
- or_(
- func.lower(DbInboundOutbound.partner_company).contains(keyword),
- func.lower(DbInboundOutbound.product).contains(keyword),
- func.lower(DbInboundOutbound.plan_no).contains(keyword),
- func.lower(DbInboundOutbound.batch_no).contains(keyword),
- func.lower(DbInboundOutbound.vehicle_no).contains(keyword),
- )
- )
- rows = list_column_facet_values(
- db,
- DbInboundOutbound,
- column,
- tenant_no=tenant_no,
- keyword=body.keyword,
- limit=body.limit if body.limit is not None else 500,
- apply_scope=lambda q: apply_db_scope(q, DbInboundOutbound, user, db, tenant_no),
- extra_filters=extra_filters or None,
- )
- return {"list": rows}
- INVENTORY_EXPORT_COLUMNS = [
- ExportColumn(title="ID", field="id"),
- ExportColumn(title="计划号", field="planNo"),
- ExportColumn(title="出入库单号", field="batchNo"),
- ExportColumn(title="日期", field="date"),
- ExportColumn(title="往来单位", field="partnerCompany"),
- ExportColumn(title="业务类型", field="businessType"),
- ExportColumn(title="状态", field="status"),
- ExportColumn(title="产品", field="product"),
- ExportColumn(title="规格", field="specification"),
- ExportColumn(title="车牌号", field="vehicleNo"),
- ExportColumn(title="数量(吨)", field="quantity"),
- ExportColumn(title="净重(吨)", field="netWeight"),
- ExportColumn(title="毛重(吨)", field="grossWeight"),
- ExportColumn(title="毛重时间", field="grossWeightTime"),
- ExportColumn(title="皮重(吨)", field="tareWeight"),
- ExportColumn(title="皮重时间", field="tareWeightTime"),
- ExportColumn(title="扣杂(吨)", field="deduction"),
- ExportColumn(title="备注", field="remarks"),
- ExportColumn(title="创建人", field="createByName"),
- ]
- class InventoryExportBody(BaseModel):
- q: Optional[str] = None
- plan_no: Optional[str] = Field(default=None, alias="planNo")
- batch_no: Optional[str] = Field(default=None, alias="batchNo")
- partner_company: Optional[str] = Field(default=None, alias="partnerCompany")
- product: Optional[str] = None
- vehicle_no: Optional[str] = Field(default=None, alias="vehicleNo")
- date_from: Optional[str] = Field(default=None, alias="dateFrom")
- date_to: Optional[str] = Field(default=None, alias="dateTo")
- status: Optional[str] = None
- business_type: Optional[str] = Field(default=None, alias="businessType")
- sort_field: Optional[str] = Field(default=None, alias="sortField")
- sort_order: Optional[str] = Field(default=None, alias="sortOrder")
- ids: Optional[List[str]] = None
- columns: Optional[List[str]] = None
- model_config = {"populate_by_name": True, "extra": "allow"}
- @router.post("/export")
- def export_inventory(
- body: InventoryExportBody,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ):
- """导出出入库列表(当前筛选全量,或 ids 已选)。"""
- try:
- items, _total = _fetch_inventory(db, user, _body_to_params(body))
- if body.ids:
- id_set = set(body.ids)
- items = [x for x in items if x.id in id_set]
- if len(items) > EXPORT_MAX_ROWS:
- raise ExportValidationError(f"导出超过 {EXPORT_MAX_ROWS} 条,请缩小筛选范围")
- rows = []
- for item in items:
- data = item.model_dump(by_alias=True)
- if not data.get("createByName"):
- data["createByName"] = data.get("createBy") or ""
- rows.append(data)
- cols = filter_export_columns(INVENTORY_EXPORT_COLUMNS, body.columns)
- date_str = datetime.now().strftime("%Y%m%d")
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- return export_rows_to_xlsx_response(
- db,
- tenant_no=tenant_no,
- sheet_title="出入库列表",
- columns=cols,
- rows=rows,
- filename_stem=f"出入库列表_{date_str}",
- )
- except ExportValidationError as e:
- raise HTTPException(status_code=400, detail=str(e)) from e
- @router.post("/", response_model=InboundOutboundRead, status_code=status.HTTP_201_CREATED)
- def create_inventory(
- payload: InboundOutboundCreate,
- db: Session = Depends(get_db),
- user: Optional[DbUserContext] = Depends(get_db_current_user_optional),
- ) -> InboundOutboundRead:
- tenant_no = _resolve_tenant_no(db, user, payload.purchase_plan_id)
- plan_fields = {
- "plan_no": payload.plan_no,
- "product_id": payload.product_id,
- "partner_company": payload.partner_company,
- "business_type": payload.business_type,
- "purchase_plan_id": payload.purchase_plan_id,
- }
- _enforce_inventory_plan_on_edit(
- db,
- SimpleNamespace(
- tenant_no=tenant_no,
- plan_no=None,
- product_id=None,
- partner_company=None,
- business_type=None,
- purchase_plan_id=None,
- ),
- plan_fields,
- )
- batch_no = payload.batch_no.strip() if payload.batch_no else None
- if not batch_no:
- batch_no = _generate_batch_no(db, tenant_no)
- status_value = (
- payload.status.strip()
- if payload.status and str(payload.status).strip()
- else ("待入库" if payload.business_type == "入库" else "待出库")
- )
- snapshot = resolve_crm_product(db, tenant_no, plan_fields["product_id"])
- row = DbInboundOutbound(
- id=next_inbound_outbound_id(db, tenant_no),
- tenant_no=tenant_no,
- date=parse_date_str(payload.date) or datetime.now().date(),
- partner_company=_text(plan_fields.get("partner_company")),
- business_type=_text(plan_fields.get("business_type")) or payload.business_type,
- quantity=payload.quantity,
- plan_no=plan_fields.get("plan_no"),
- batch_no=batch_no,
- vehicle_no=payload.vehicle_no.strip(),
- status=status_value,
- remark=payload.remarks,
- purchase_plan_id=plan_fields.get("purchase_plan_id"),
- create_by=user.id if user else None,
- create_dept_id=user.dept_id if user else "",
- )
- apply_product_snapshot_to_inventory(row, snapshot)
- ensure_site_access_token(row)
- db.add(row)
- db.commit()
- db.refresh(row)
- return inventory_to_read(row, db=db)
- @router.post(
- "/create-link-for-plan",
- response_model=InboundOutboundRead,
- status_code=status.HTTP_201_CREATED,
- )
- def create_link_for_plan(
- payload: CreateLinkForPlanPayload,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> InboundOutboundRead:
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- plan = (
- db.query(DbPurchasePlan)
- .filter(
- DbPurchasePlan.id == payload.purchase_plan_id,
- DbPurchasePlan.tenant_no == tenant_no,
- DbPurchasePlan.del_flag == 0,
- )
- .one_or_none()
- )
- if not plan:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Plan not found")
- if not plan.product_id:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="计划未关联 CRM 产品,请先编辑计划并选择产品",
- )
- row = DbInboundOutbound(
- id=next_inbound_outbound_id(db, tenant_no),
- tenant_no=tenant_no,
- date=EMPTY_INVENTORY_DATE,
- partner_company=(plan.customer_name or "").strip() or "待填写",
- business_type=plan.business_type,
- product_id=plan.product_id,
- product=(plan.product or "").strip(),
- specification=plan.specification,
- quantity=0.0,
- plan_no=(plan.plan_no or "").strip() or None,
- batch_no=_generate_batch_no(db, tenant_no),
- vehicle_no="",
- status=STATUS_THIRD_PARTY_FILL,
- remark=payload.remarks,
- purchase_plan_id=plan.id,
- create_by=user.id,
- create_dept_id=user.dept_id,
- )
- ensure_site_access_token(row)
- db.add(row)
- db.commit()
- db.refresh(row)
- return inventory_to_read(row, db=db)
- @router.get("/{id}/site-url", response_model=InventorySiteUrlRead)
- def get_inventory_site_url(
- id: str,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> InventorySiteUrlRead:
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- row = _get_inventory(db, tenant_no, id, user)
- token = ensure_site_access_token(row)
- db.commit()
- return InventorySiteUrlRead(site_url=build_site_inventory_url(token))
- @router.get("/{id}", response_model=InboundOutboundRead)
- def get_inventory(
- id: str,
- db: Session = Depends(get_db),
- ) -> InboundOutboundRead:
- """单条查询(三方 H5 填写页无需门户登录)。"""
- row = _get_inventory_by_id(db, id)
- weighing = (
- db.query(DbWeighingSlip)
- .filter(
- DbWeighingSlip.inbound_outbound_id == row.id,
- DbWeighingSlip.is_voided == 0,
- DbWeighingSlip.del_flag == 0,
- )
- .order_by(DbWeighingSlip.id.asc())
- .first()
- )
- return inventory_to_read(row, weighing=weighing, db=db)
- @router.patch("/{id}", response_model=InboundOutboundRead)
- def update_inventory(
- id: str,
- payload: InboundOutboundUpdate,
- db: Session = Depends(get_db),
- ) -> InboundOutboundRead:
- """更新(三方 H5 填写页无需门户登录)。"""
- row = _get_inventory_by_id(db, id)
- payload_dict = payload.model_dump(exclude_unset=True)
- _enforce_inventory_plan_on_edit(db, row, payload_dict)
- _apply_inventory_product_update(db, row.tenant_no, row, payload_dict)
- _apply_inventory_update(row, payload_dict)
- _maybe_promote_third_party_status(row)
- db.commit()
- db.refresh(row)
- return inventory_to_read(row, db=db)
- @router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
- def delete_inventory(
- id: str,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> None:
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- row = _get_inventory(db, tenant_no, id, user)
- if row.status == "已完成":
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="已完成的记录不能删除",
- )
- assert_row_deletable_by_creator(row, user_info_from_context(user))
- row.del_flag = 1
- row.update_by = user.id
- db.commit()
- class InventoryRelatedData(BaseModel):
- plan: Optional[PurchasePlanRead]
- weighing: Optional[WeighingSlipRead]
- @router.get("/{id}/related", response_model=InventoryRelatedData)
- def get_inventory_related(
- id: str,
- db: Session = Depends(get_db),
- ) -> InventoryRelatedData:
- row = _get_inventory_by_id(db, id)
- plan = None
- if row.purchase_plan_id:
- plan = (
- db.query(DbPurchasePlan)
- .filter(
- DbPurchasePlan.id == row.purchase_plan_id,
- DbPurchasePlan.del_flag == 0,
- )
- .one_or_none()
- )
- weighing = (
- db.query(DbWeighingSlip)
- .filter(
- DbWeighingSlip.inbound_outbound_id == row.id,
- DbWeighingSlip.is_voided == 0,
- DbWeighingSlip.del_flag == 0,
- )
- .limit(1)
- .first()
- )
- return InventoryRelatedData(
- plan=plan_to_read(plan, db=db) if plan else None,
- weighing=weighing_to_read(weighing, db=db) if weighing else None,
- )
|