from typing import Any, List, Optional from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from pydantic import BaseModel, Field from sqlalchemy import and_, 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 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.core.utils import generate_uuid from app.services.db_data_scope import apply_db_scope, assert_db_row_in_scope from app.models.db_models import DbInboundOutbound, DbPurchasePlan, DbWeighingSlip from app.schemas.db.inventory import InboundOutboundRead from app.schemas.db.plans import PurchasePlanRead from app.schemas.db.weighing import WeighingSlipCreate, WeighingSlipRead, WeighingSlipUpdate from app.services.db.mappers import ( inventory_date_to_api, inventory_to_read, load_user_display_name_map, parse_datetime_str, plan_to_read, weighing_to_read, ) from app.services.db.list_sort import ( order_weighing, parse_sort_params, weighing_needs_inventory_join, ) from app.services.db.weighing_list_filters import build_weighing_column_filters from app.services.db.weighing_logic import process_weighing_business_logic from app.services.db.product_helpers import ( apply_product_snapshot_to_weighing, resolve_crm_product, ) from app.utils.excel_export import ( EXPORT_MAX_ROWS, ExportColumn, ExportValidationError, export_rows_to_xlsx_response, filter_export_columns, ) router = APIRouter(prefix="/weighing", tags=["weighing"]) _WEIGHING_REMARK_FIELD = "remarks" def _base_query(db: Session, tenant_no: str): return db.query(DbWeighingSlip).filter( DbWeighingSlip.tenant_no == tenant_no, DbWeighingSlip.del_flag == 0, DbWeighingSlip.is_voided == 0, ) 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_weighing_column_filters( plan_no=_param_get(params, "planNo", "plan_no"), batch_no=_param_get(params, "batchNo", "batch_no"), vehicle_no=_param_get(params, "vehicleNo", "vehicle_no"), cargo_name=_param_get(params, "cargoName", "cargo_name"), supplier=_param_get(params, "supplier"), consignee=_param_get(params, "consignee"), business_type=_param_get(params, "businessType", "business_type"), business_type_in=_param_get(params, "businessTypeIn", "business_type_in"), date_from=_param_get(params, "dateFrom", "date_from"), date_to=_param_get(params, "dateTo", "date_to"), net_weight_op=_param_get(params, "netWeightOp", "net_weight_op"), net_weight=_param_get(params, "netWeight", "net_weight"), net_weight_min=_param_get(params, "netWeightMin", "net_weight_min"), net_weight_max=_param_get(params, "netWeightMax", "net_weight_max"), gross_weight_op=_param_get(params, "grossWeightOp", "gross_weight_op"), gross_weight=_param_get(params, "grossWeight", "gross_weight"), gross_weight_min=_param_get(params, "grossWeightMin", "gross_weight_min"), gross_weight_max=_param_get(params, "grossWeightMax", "gross_weight_max"), ) def _body_to_params(body: "WeighingExportBody") -> 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 _fetch_weighing( db: Session, user: DbUserContext, params: dict ) -> list[WeighingSlipRead]: 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(DbWeighingSlip.vehicle_no).contains(keyword), func.lower(DbWeighingSlip.plan_no).contains(keyword), func.lower(DbWeighingSlip.batch_no).contains(keyword), func.lower(DbWeighingSlip.cargo_name).contains(keyword), func.lower(DbWeighingSlip.supplier).contains(keyword), func.lower(DbWeighingSlip.consignee).contains(keyword), ) ) column_filters = _filters_from_params(params) if column_filters: query = query.filter(*column_filters) query = apply_db_scope(query, DbWeighingSlip, user, db, tenant_no) sf, so = parse_sort_params(params) if weighing_needs_inventory_join(sf, so): query = query.outerjoin( DbInboundOutbound, and_( DbWeighingSlip.inbound_outbound_id == DbInboundOutbound.id, DbInboundOutbound.del_flag == 0, ), ) query = query.order_by( order_weighing(sf, so, default=DbWeighingSlip.gross_weight_time.desc()), DbWeighingSlip.id.desc(), ) weighings = query.all() inventory_ids = [w.inbound_outbound_id for w in weighings if w.inbound_outbound_id] inventory_date_map: dict[str, str] = {} if inventory_ids: inventories = ( db.query(DbInboundOutbound) .filter( DbInboundOutbound.id.in_(inventory_ids), DbInboundOutbound.del_flag == 0, ) .all() ) for inventory in inventories: inventory_date_map[inventory.id] = inventory_date_to_api(inventory.date) user_name_map = load_user_display_name_map(db, [w.create_by for w in weighings]) return [ weighing_to_read( weighing, inventory_date=inventory_date_map.get(weighing.inbound_outbound_id or ""), user_name_map=user_name_map, ) for weighing in weighings ] def _get_weighing( db: Session, tenant_no: str, weighing_id: str, user: DbUserContext ) -> DbWeighingSlip: row = ( db.query(DbWeighingSlip) .filter( DbWeighingSlip.id == weighing_id, DbWeighingSlip.tenant_no == tenant_no, DbWeighingSlip.del_flag == 0, ) .one_or_none() ) if row is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Weighing {weighing_id} not found", ) assert_db_row_in_scope(db, row, DbWeighingSlip, user, tenant_no) return row def _apply_weighing_product_update( db: Session, tenant_no: str, row: DbWeighingSlip, payload_dict: dict ) -> None: if "cargo_name" 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("cargo_name", 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_weighing(row, snapshot) def _apply_weighing_update(row: DbWeighingSlip, payload_dict: dict) -> None: for key, value in payload_dict.items(): if key == _WEIGHING_REMARK_FIELD: if isinstance(value, str): value = value.strip() row.remark = value continue if key in {"gross_weight_time", "tare_weight_time"}: if value is None or (isinstance(value, str) and not str(value).strip()): if key == "tare_weight_time": row.tare_weight_time = None else: parsed = parse_datetime_str(str(value)) if parsed: setattr(row, key, parsed) continue if isinstance(value, str): value = value.strip() setattr(row, key, value) @router.get("/", response_model=list[WeighingSlipRead]) def list_weighing( request: Request, q: Optional[str] = Query(default=None, description="模糊搜索关键词"), date_from: Optional[str] = Query(default=None, alias="dateFrom", description="开始日期"), date_to: Optional[str] = Query(default=None, alias="dateTo", description="结束日期"), plan_no: Optional[str] = Query(default=None, alias="planNo", description="计划号"), batch_no: Optional[str] = Query(default=None, alias="batchNo", description="出入库单号"), vehicle_no: Optional[str] = Query(default=None, alias="vehicleNo", description="车牌号"), cargo_name: Optional[str] = Query(default=None, alias="cargoName", description="货物名称"), supplier: Optional[str] = Query(default=None, description="供货单位"), consignee: Optional[str] = Query(default=None, description="收货单位"), business_type: Optional[str] = Query(default=None, alias="businessType", description="业务类型"), db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ) -> list[WeighingSlipRead]: params = _merge_query( request, q=q, dateFrom=date_from, dateTo=date_to, planNo=plan_no, batchNo=batch_no, vehicleNo=vehicle_no, cargoName=cargo_name, supplier=supplier, consignee=consignee, businessType=business_type, ) return _fetch_weighing(db, user, params) WEIGHING_EXPORT_COLUMNS = [ ExportColumn(title="日期", field="inventoryDate"), ExportColumn(title="计划单号", field="planNo"), ExportColumn(title="出入库单号", field="batchNo"), ExportColumn(title="车牌号", field="vehicleNo"), ExportColumn(title="货物名称", field="cargoName"), ExportColumn(title="规格", field="specification"), ExportColumn(title="业务类型", field="businessType"), ExportColumn(title="单据状态", field="docStatus"), ExportColumn(title="供货单位", field="supplier"), ExportColumn(title="收货单位", field="consignee"), ExportColumn(title="本次量(吨)", field="currentQuantity"), ExportColumn(title="净重(吨)", field="netWeight"), ExportColumn(title="毛重(kg)", field="grossWeight"), ExportColumn(title="毛重时间", field="grossWeightTime"), ExportColumn(title="皮重(kg)", field="tareWeight"), ExportColumn(title="皮重时间", field="tareWeightTime"), ExportColumn(title="扣杂(kg)", field="deduction"), ExportColumn(title="创建人", field="createByName"), ] class WeighingExportBody(BaseModel): q: Optional[str] = None date_from: Optional[str] = Field(default=None, alias="dateFrom") date_to: Optional[str] = Field(default=None, alias="dateTo") plan_no: Optional[str] = Field(default=None, alias="planNo") batch_no: Optional[str] = Field(default=None, alias="batchNo") vehicle_no: Optional[str] = Field(default=None, alias="vehicleNo") cargo_name: Optional[str] = Field(default=None, alias="cargoName") supplier: Optional[str] = None consignee: 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"} def _kg_to_ton(value) -> float: try: return round(float(value or 0) / 1000, 2) except (TypeError, ValueError): return 0.0 @router.post("/export") def export_weighing( body: WeighingExportBody, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ): """导出地磅单列表(当前筛选全量,或 ids 已选)。""" try: items = _fetch_weighing(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) data["docStatus"] = "已作废" if data.get("isVoided") == 1 else "正常" data["currentQuantity"] = _kg_to_ton(data.get("currentQuantity")) data["netWeight"] = _kg_to_ton(data.get("netWeight")) if not data.get("inventoryDate"): gwt = data.get("grossWeightTime") or "" data["inventoryDate"] = str(gwt)[:10] if gwt else "" if not data.get("createByName"): data["createByName"] = data.get("createBy") or "" rows.append(data) cols = filter_export_columns(WEIGHING_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=WeighingSlipRead, status_code=status.HTTP_201_CREATED) def create_weighing( payload: WeighingSlipCreate, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ) -> WeighingSlipRead: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) inbound_outbound_id = payload.inbound_outbound_id if inbound_outbound_id: existing = ( db.query(DbWeighingSlip) .filter( DbWeighingSlip.inbound_outbound_id == inbound_outbound_id, DbWeighingSlip.is_voided == 0, DbWeighingSlip.del_flag == 0, ) .first() ) if existing: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="该出入库单已经存在关联的地磅单,无法重复创建", ) planned_quantity = payload.planned_quantity current_quantity = payload.current_quantity purchase_plan_id = payload.purchase_plan_id batch_no = payload.batch_no.strip() if payload.batch_no else None if inbound_outbound_id: inventory = ( db.query(DbInboundOutbound) .filter( DbInboundOutbound.id == inbound_outbound_id, DbInboundOutbound.tenant_no == tenant_no, DbInboundOutbound.del_flag == 0, ) .one_or_none() ) if inventory: if current_quantity is None: current_quantity = inventory.quantity * 1000 if purchase_plan_id is None and inventory.purchase_plan_id: purchase_plan_id = inventory.purchase_plan_id if purchase_plan_id: plan = ( db.query(DbPurchasePlan) .filter(DbPurchasePlan.id == purchase_plan_id) .one_or_none() ) if plan and planned_quantity is None: planned_quantity = plan.planned_quantity * 1000 if not batch_no and inventory.batch_no: batch_no = inventory.batch_no gross_time = parse_datetime_str(payload.gross_weight_time) if payload.gross_weight_time else None tare_time = ( parse_datetime_str(payload.tare_weight_time) if payload.tare_weight_time and payload.tare_weight_time.strip() else None ) snapshot = resolve_crm_product(db, tenant_no, payload.product_id) row = DbWeighingSlip( id=generate_uuid(), tenant_no=tenant_no, vehicle_no=payload.vehicle_no.strip(), plan_no=payload.plan_no.strip() if payload.plan_no else None, business_type=payload.business_type, batch_no=batch_no, supplier=payload.supplier.strip(), consignee=payload.consignee.strip(), gross_weight=payload.gross_weight or 0, gross_weight_time=gross_time or datetime.now(), tare_weight=payload.tare_weight or 0, tare_weight_time=tare_time, deduction=payload.deduction or 0, net_weight=payload.net_weight or 0, planned_quantity=planned_quantity, executed_quantity=payload.executed_quantity, purchase_plan_id=purchase_plan_id, inbound_outbound_id=inbound_outbound_id, weighing_point=payload.weighing_point or "山峡化工", remark=payload.remarks, current_quantity=current_quantity, is_voided=0, create_by=user.id, create_dept_id=user.dept_id, ) apply_product_snapshot_to_weighing(row, snapshot) db.add(row) db.commit() db.refresh(row) process_weighing_business_logic(db, row) db.refresh(row) return weighing_to_read(row, db=db) @router.get("/{id}", response_model=WeighingSlipRead) def get_weighing( id: str, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ) -> WeighingSlipRead: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) row = _get_weighing(db, tenant_no, id, user) return weighing_to_read(row, db=db) @router.patch("/{id}", response_model=WeighingSlipRead) def update_weighing( id: str, payload: WeighingSlipUpdate, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ) -> WeighingSlipRead: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) row = _get_weighing(db, tenant_no, id, user) if row.is_voided == 1: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot update voided weighing record", ) payload_dict = payload.model_dump(exclude_unset=True) _apply_weighing_product_update(db, tenant_no, row, payload_dict) _apply_weighing_update(row, payload_dict) row.update_by = user.id db.commit() db.refresh(row) process_weighing_business_logic(db, row) db.refresh(row) return weighing_to_read(row, db=db) @router.post("/{id}/void", status_code=status.HTTP_204_NO_CONTENT) def void_weighing( 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_weighing(db, tenant_no, id, user) if row.is_voided == 1: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Weighing record is already voided", ) row.is_voided = 1 row.update_by = user.id db.commit() process_weighing_business_logic(db, row) @router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT) def delete_weighing( 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_weighing(db, tenant_no, id, user) assert_row_deletable_by_creator(row, user_info_from_context(user)) row.is_voided = 1 row.update_by = user.id db.commit() process_weighing_business_logic(db, row) class WeighingRelatedData(BaseModel): plan: Optional[PurchasePlanRead] inventory: Optional[InboundOutboundRead] @router.get("/{id}/related", response_model=WeighingRelatedData) def get_weighing_related( id: str, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ) -> WeighingRelatedData: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) row = _get_weighing(db, tenant_no, id, user) 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() ) elif row.plan_no: plan = ( db.query(DbPurchasePlan) .filter( DbPurchasePlan.plan_no == row.plan_no, DbPurchasePlan.tenant_no == tenant_no, DbPurchasePlan.del_flag == 0, ) .limit(1) .first() ) inventory = None if row.inbound_outbound_id: inventory = ( db.query(DbInboundOutbound) .filter( DbInboundOutbound.id == row.inbound_outbound_id, DbInboundOutbound.del_flag == 0, ) .one_or_none() ) return WeighingRelatedData( plan=plan_to_read(plan, db=db) if plan else None, inventory=inventory_to_read(inventory, db=db) if inventory else None, )