from typing import List, Optional from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, 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 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 ( datetime_end_of_day, inventory_date_to_api, inventory_to_read, load_user_display_name_map, parse_date_str, parse_datetime_str, plan_to_read, weighing_to_read, ) 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 _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( 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]: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) query = _base_query(db, tenant_no).order_by(DbWeighingSlip.gross_weight_time.desc()) if q: keyword = 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), ) ) if date_from: start_date = parse_date_str(date_from) if start_date: query = query.filter( DbWeighingSlip.gross_weight_time >= datetime.combine(start_date, datetime.min.time()) ) if date_to: end_date = parse_date_str(date_to) if end_date: query = query.filter(DbWeighingSlip.gross_weight_time <= datetime_end_of_day(end_date)) if plan_no: query = query.filter(DbWeighingSlip.plan_no.contains(plan_no)) if batch_no: query = query.filter(DbWeighingSlip.batch_no.contains(batch_no)) if vehicle_no: query = query.filter(DbWeighingSlip.vehicle_no.contains(vehicle_no)) if cargo_name: query = query.filter(DbWeighingSlip.cargo_name.contains(cargo_name)) if supplier: query = query.filter(func.lower(DbWeighingSlip.supplier).contains(supplier.lower())) if consignee: query = query.filter(func.lower(DbWeighingSlip.consignee).contains(consignee.lower())) if business_type: query = query.filter(DbWeighingSlip.business_type == business_type) query = apply_db_scope(query, DbWeighingSlip, user, db, tenant_no) 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 ] 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") ids: Optional[List[str]] = None columns: Optional[List[str]] = None model_config = {"populate_by_name": True} 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 = list_weighing( q=body.q, date_from=body.date_from, date_to=body.date_to, plan_no=body.plan_no, batch_no=body.batch_no, vehicle_no=body.vehicle_no, cargo_name=body.cargo_name, supplier=body.supplier, consignee=body.consignee, business_type=body.business_type, db=db, user=user, ) 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, )