from datetime import date, datetime from decimal import Decimal from io import BytesIO from pathlib import Path from urllib.parse import quote from uuid import uuid4 from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile from fastapi.responses import FileResponse, Response from openpyxl import Workbook, load_workbook from sqlalchemy import and_, case, func, or_, select, update from sqlalchemy.orm import Session from app.db.session import get_db from app.models.document_archive import DocumentArchive from app.models.master_data import Bom, BomItem, Item, StockBalance, Warehouse from app.models.operations import ( InventoryTxn, OperationReport, ProcessRoute, ProcessRouteOperation, ProductionBatchLedger, ProductionBatchLedgerTxn, PurchaseOrder, PurchaseOrderItem, PurchaseReceipt, PurchaseReceiptItem, PurchaseReturn, PurchaseReturnItem, SpecialWarehouseAdjustmentLine, StockLot, Stocktake, StocktakeLine, StocktakeWarehouse, WarehouseLocation, WorkOrder, ) from app.schemas.operations import ( CustomerSuppliedInboundCreate, InventorySettlementRead, OpeningInventoryImportResult, InventoryTxnLedgerResponse, InventoryTxnRead, PurchaseReturnRead, RawMaterialReturnOutboundCreate, SpecialWarehouseAdjustmentCreate, SpecialWarehouseAdjustmentRead, StockBalanceRead, StockLotPurchaseLinkRead, StockLotOptionRead, StockLotRead, StocktakeConfirmCreate, StocktakeImportPreviewRead, StocktakeLineRead, StocktakeRead, StocktakeStartCreate, WarehouseInboundCreate, WarehouseOutboundCreate, ) from app.services.auth import AuthContext, require_authenticated_user from app.services.document_archives import ( ARCHIVE_STATUS_MISSING, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, DOCUMENT_TYPE_WAREHOUSE_OPERATION, FILE_FORMAT_PDF, generate_document_archive, production_document_batch_no_from_text, ) from app.services.logistics import normalize_logistics_fields from app.services.operations import ( build_inventory_lot_no, create_inventory_txn, WAREHOUSE_LEDGER_TYPES, get_location_or_default, get_inventory_settlements, get_inventory_txn_ledger_query, get_inventory_txn_query, get_stock_balances_query, get_stock_lot_purchase_links, get_stock_lot_options_query, get_stock_lots_query, to_decimal, summarize_inventory_txn_ledger, upsert_stock_balance, ) from app.services.production_work_order_ledger import ( INBOUND_TOLERANCE_MULTIPLIER, get_work_order_limit_snapshot, validate_inbound_with_tolerance, validate_work_order_source_lot, ) from app.services.production_batch_ledger import ( lock_production_batch_ledger, post_finished_inbound_to_ledger, post_scrap_inbound_to_ledger, post_surplus_inbound_to_ledger, ) from app.services.special_inventory_adjustment import create_special_inventory_adjustment from app.services.stocktake import ( _stocktake_summary, build_stocktake_workbook, cancel_stocktake, confirm_stocktake, ensure_warehouses_unlocked, import_stocktake_workbook, start_stocktake, stocktake_lines_for_read, stocktake_warehouses_for_read, ) router = APIRouter(dependencies=[Depends(require_authenticated_user)]) def _stock_lot_read_with_archive( row: dict, *, archive_status: str | None = None, archive_business_id: int | None = None, archive_document_type: str | None = None, archive_error_message: str | None = None, ) -> StockLotRead: response = StockLotRead.model_validate(dict(row)) response.archive_status = archive_status response.archive_business_id = archive_business_id response.archive_document_type = archive_document_type response.archive_error_message = archive_error_message return response def _generate_inventory_archive_fields( db: Session, *, document_type: str, business_id: int | None, created_by: int | None, ) -> dict[str, object]: if not business_id: return { "archive_status": None, "archive_business_id": None, "archive_document_type": None, "archive_error_message": None, } try: archive_result = generate_document_archive(db, document_type, int(business_id), created_by=created_by) return { "archive_status": archive_result.archive_status, "archive_business_id": int(business_id), "archive_document_type": document_type, "archive_error_message": archive_result.archive_error_message, } except Exception as exc: _ = exc return { "archive_status": "归档失败", "archive_business_id": int(business_id), "archive_document_type": document_type, "archive_error_message": "归档失败:PDF归档服务异常,请稍后重新生成", } def _generate_inventory_archives_for_txns( db: Session, *, document_type: str, business_ids: list[int], created_by: int | None, ) -> dict[str, object]: first_archive_fields: dict[str, object] | None = None for business_id in business_ids: archive_fields = _generate_inventory_archive_fields( db, document_type=document_type, business_id=business_id, created_by=created_by, ) if first_archive_fields is None: first_archive_fields = archive_fields return first_archive_fields or _generate_inventory_archive_fields( db, document_type=document_type, business_id=None, created_by=created_by, ) LOGISTICS_UPLOAD_DIR = Path(__file__).resolve().parents[3] / "uploads" / "logistics" LOGISTICS_ALLOWED_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif"} LOGISTICS_MAX_BYTES = 10 * 1024 * 1024 OPENING_INVENTORY_HEADERS = [ "仓库类型", "仓库名称", "库位", "物料编码", "物料名称", "批次号", "数量", "重量(kg)", "单价", "来源说明", "备注", ] WEIGHT_ONLY_WAREHOUSE_TYPES = {"RAW", "SCRAP"} WAREHOUSE_TYPE_LABELS = { "RAW": "原材料库", "SEMI": "半成品库", "FINISHED": "成品库", "AUX": "辅料库", "SCRAP": "废料库", "RETURN": "退货库", } WAREHOUSE_LABEL_TO_TYPE = {label: key for key, label in WAREHOUSE_TYPE_LABELS.items()} OPENING_INVENTORY_SAMPLE_ROWS = { "RAW": [ ["原材料库", "原材料仓", "原料主库位", "示例原材料001", "304不锈钢板", "示例-RAW-0001", 0, 500, 4.2, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"], ["原材料库", "原材料仓", "原料主库位", "示例原材料002", "冷轧钢板", "示例-RAW-0002", 0, 1200, 3.8, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"], ], "SEMI": [ ["半成品库", "半成品仓", "半成品主库位", "示例产品001", "五金支架半成品", "示例-SEMI-0001", 100, 250, 8.5, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"], ["半成品库", "半成品仓", "半成品主库位", "示例产品002", "冲压件半成品", "示例-SEMI-0002", 60, 180, 7.2, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"], ], "FINISHED": [ ["成品库", "成品仓", "成品主库位", "示例产品001", "五金支架", "示例-FG-0001", 100, 250, 12.5, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"], ["成品库", "成品仓", "成品主库位", "示例产品002", "苹果电脑支架", "示例-FG-0002", 80, 200, 15, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"], ], "AUX": [ ["辅料库", "辅料仓", "辅料主库位", "示例辅料001", "包装薄膜", "示例-AUX-0001", 80, 0, 6.5, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"], ["辅料库", "辅料仓", "辅料主库位", "示例辅料002", "劳保手套", "示例-AUX-0002", 25, 0, 12, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"], ], "SCRAP": [ ["废料库", "废料库", "废料暂存位", "示例原材料001", "304不锈钢边角料", "示例-SCRAP-0001", 0, 120, 1.6, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"], ["废料库", "废料库", "废料暂存位", "示例原材料002", "冲压报废件", "示例-SCRAP-0002", 0, 80, 1.2, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"], ], "RETURN": [ ["退货库", "退货库", "退货暂存位", "示例产品001", "返工待处理五金支架", "示例-RETURN-0001", 20, 50, 12.5, "客户退回待返工初始化", "示例行,导入时会自动跳过,请替换为真实数据"], ["退货库", "退货库", "退货暂存位", "示例产品002", "返工待处理苹果电脑支架", "示例-RETURN-0002", 12, 30, 15, "客户退回待返工初始化", "示例行,导入时会自动跳过,请替换为真实数据"], ], } def _is_weight_only_warehouse_type(warehouse_type: str | None) -> bool: return str(warehouse_type or "").upper() in WEIGHT_ONLY_WAREHOUSE_TYPES def _opening_inventory_headers_for_type(warehouse_type: str | None) -> list[str]: if _is_weight_only_warehouse_type(warehouse_type): return [header for header in OPENING_INVENTORY_HEADERS if header != "数量"] return list(OPENING_INVENTORY_HEADERS) def _opening_inventory_row_for_headers(row: list[object], headers: list[str]) -> list[object]: legacy_payload = {header: row[index] if index < len(row) else "" for index, header in enumerate(OPENING_INVENTORY_HEADERS)} return [legacy_payload.get(header, "") for header in headers] def _excel_response(workbook, filename: str) -> Response: output = BytesIO() workbook.save(output) encoded_filename = quote(filename) return Response( content=output.getvalue(), media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": f"attachment; filename=\"stocktake.xlsx\"; filename*=UTF-8''{encoded_filename}"}, ) def _stocktake_read(db: Session, stocktake_id: int, warehouse_id: int | None = None) -> StocktakeRead: stocktake = db.get(Stocktake, stocktake_id) if not stocktake: raise HTTPException(status_code=404, detail="盘库单不存在") warehouses = stocktake_warehouses_for_read(db, stocktake_id) lines = stocktake_lines_for_read(db, stocktake_id) if warehouse_id: lines = [line for line in lines if int(line.warehouse_id) == int(warehouse_id)] return StocktakeRead.model_validate( { "id": stocktake.id, "stocktake_no": stocktake.stocktake_no, "status": stocktake.status, "started_by_user_id": stocktake.started_by_user_id, "imported_by_user_id": stocktake.imported_by_user_id, "confirmed_by_user_id": stocktake.confirmed_by_user_id, "canceled_by_user_id": stocktake.canceled_by_user_id, "started_at": stocktake.started_at, "exported_at": stocktake.exported_at, "imported_at": stocktake.imported_at, "confirmed_at": stocktake.confirmed_at, "canceled_at": stocktake.canceled_at, "remark": stocktake.remark, "warehouses": [ { "id": row.id, "warehouse_id": row.warehouse_id, "warehouse_name": row.warehouse_name, "warehouse_type": row.warehouse_type, "status": row.status, } for row in warehouses ], "summary": _stocktake_summary(lines), } ) def _stocktake_line_read(line: StocktakeLine) -> dict[str, object]: return { "id": line.id, "stocktake_id": line.stocktake_id, "line_no": line.line_no, "warehouse_id": line.warehouse_id, "warehouse_name": line.warehouse_name, "warehouse_type": line.warehouse_type, "location_id": line.location_id, "location_name": line.location_name, "item_id": line.item_id, "item_code": line.item_code, "item_name": line.item_name, "item_type": line.item_type, "lot_id": line.lot_id, "lot_no": line.lot_no, "source_material_sub_batch_no": line.source_material_sub_batch_no, "snapshot_qty": line.snapshot_qty, "snapshot_weight_kg": line.snapshot_weight_kg, "snapshot_unit_cost": line.snapshot_unit_cost, "counted_qty": line.counted_qty, "counted_weight_kg": line.counted_weight_kg, "diff_qty": line.diff_qty, "diff_weight_kg": line.diff_weight_kg, "diff_amount": line.diff_amount, "diff_type": line.diff_type, "row_status": line.row_status, "error_message": line.error_message, "remark": line.remark, } def _stocktake_preview_read(db: Session, result: dict[str, object]) -> StocktakeImportPreviewRead: stocktake = result["stocktake"] lines = result["lines"] return StocktakeImportPreviewRead.model_validate( { "stocktake": _stocktake_read(db, stocktake.id), "lines": [_stocktake_line_read(line) for line in lines], "summary": result["summary"], } ) def _code_token(value: str | None, fallback: str, max_length: int = 14) -> str: token = "".join(ch for ch in str(value or "").upper() if ch.isalnum()) return (token or fallback)[-max_length:] def _build_customer_supplied_lot_no(db: Session, material: Item) -> str: return build_inventory_lot_no(db, material.id) def _build_purchase_return_no(db: Session) -> str: year = datetime.now().strftime("%Y") prefix = f"退货{year}-" rows = db.scalars(select(PurchaseReturn.return_no).where(PurchaseReturn.return_no.like(f"{prefix}%"))).all() max_no = 0 for value in rows: tail = str(value or "").replace(prefix, "", 1) if tail.isdigit(): max_no = max(max_no, int(tail)) return f"{prefix}{max_no + 1:05d}" def _lock_purchase_order_items_for_return(db: Session, purchase_order_item_ids: set[int]) -> None: if not purchase_order_item_ids: return db.scalars( select(PurchaseOrderItem) .where(PurchaseOrderItem.id.in_(sorted(purchase_order_item_ids))) .with_for_update() ).all() def _current_returned_weight_for_purchase_line_lot(db: Session, purchase_order_item_id: int, lot_id: int) -> Decimal: rows = db.scalars( select(PurchaseReturnItem.return_weight_kg) .join(PurchaseReturn, PurchaseReturn.id == PurchaseReturnItem.purchase_return_id) .where( PurchaseReturnItem.purchase_order_item_id == purchase_order_item_id, PurchaseReturnItem.lot_id == lot_id, PurchaseReturn.status != "CANCELED", PurchaseReturnItem.status != "CANCELED", ) .with_for_update() ).all() return sum((to_decimal(row) for row in rows), Decimal("0")) def _recheck_raw_material_return_purchase_line_availability(db: Session, prepared_lines: list[dict[str, object]]) -> None: for prepared in prepared_lines: lot_id = int(prepared["lot_id"]) purchase_order_item_id = int(prepared["purchase_order_item_id"]) return_weight = to_decimal(prepared["return_weight"]) matched_link = next( ( link for link in get_stock_lot_purchase_links(db, lot_id) if int(link["purchase_order_item_id"]) == purchase_order_item_id ), None, ) if not matched_link: db.rollback() raise HTTPException(status_code=400, detail="退货重量不能超过该采购明细可退重量") current_returned_weight = _current_returned_weight_for_purchase_line_lot(db, purchase_order_item_id, lot_id) current_line_returnable_weight = to_decimal(matched_link["accepted_weight_kg"]) - current_returned_weight if current_line_returnable_weight < 0: current_line_returnable_weight = Decimal("0") if return_weight > current_line_returnable_weight or return_weight > to_decimal(matched_link["returnable_weight_kg"]): db.rollback() raise HTTPException(status_code=400, detail="退货重量不能超过该采购明细可退重量") prepared["unit_cost"] = to_decimal(matched_link["unit_cost"], "0.0001") def _purchase_return_read(db: Session, purchase_return_id: int) -> PurchaseReturnRead: header = db.execute( select( PurchaseReturn.id.label("purchase_return_id"), PurchaseReturn.return_no.label("return_no"), PurchaseReturn.purchase_order_id.label("purchase_order_id"), PurchaseOrder.po_no.label("po_no"), PurchaseReturn.warehouse_id.label("warehouse_id"), Warehouse.warehouse_name.label("warehouse_name"), PurchaseReturn.return_date.label("return_date"), PurchaseReturn.logistics_waybill_no.label("logistics_waybill_no"), PurchaseReturn.logistics_freight_amount.label("logistics_freight_amount"), PurchaseReturn.logistics_photo_url.label("logistics_photo_url"), PurchaseReturn.reason.label("reason"), PurchaseReturn.status.label("status"), PurchaseReturn.remark.label("remark"), ) .join(PurchaseOrder, PurchaseOrder.id == PurchaseReturn.purchase_order_id) .join(Warehouse, Warehouse.id == PurchaseReturn.warehouse_id) .where(PurchaseReturn.id == purchase_return_id) ).mappings().first() if not header: raise HTTPException(status_code=404, detail="采购退货单不存在") item_rows = db.execute( select( PurchaseReturnItem.id.label("purchase_return_item_id"), PurchaseReturnItem.purchase_return_id.label("purchase_return_id"), PurchaseReturnItem.line_no.label("line_no"), PurchaseReturnItem.purchase_order_item_id.label("purchase_order_item_id"), PurchaseReturnItem.material_item_id.label("material_item_id"), Item.item_code.label("material_code"), Item.item_name.label("material_name"), PurchaseReturnItem.lot_id.label("lot_id"), StockLot.lot_no.label("lot_no"), PurchaseReturnItem.return_weight_kg.label("return_weight_kg"), PurchaseReturnItem.unit_cost.label("unit_cost"), PurchaseReturnItem.amount.label("amount"), PurchaseReturnItem.reason.label("reason"), PurchaseReturnItem.status.label("status"), PurchaseReturnItem.remark.label("remark"), ) .join(Item, Item.id == PurchaseReturnItem.material_item_id) .join(StockLot, StockLot.id == PurchaseReturnItem.lot_id) .where(PurchaseReturnItem.purchase_return_id == purchase_return_id) .order_by(PurchaseReturnItem.line_no.asc(), PurchaseReturnItem.id.asc()) ).mappings().all() total_return_weight = sum((to_decimal(row["return_weight_kg"]) for row in item_rows), Decimal("0")) total_amount = sum((to_decimal(row["amount"], "0.01") for row in item_rows), Decimal("0")) return PurchaseReturnRead.model_validate( { **dict(header), "total_return_weight_kg": total_return_weight, "total_amount": total_amount, "items": [dict(row) for row in item_rows], } ) @router.post("/stocktakes", response_model=StocktakeRead) def start_inventory_stocktake( payload: StocktakeStartCreate, context: AuthContext = Depends(require_authenticated_user), db: Session = Depends(get_db), ) -> StocktakeRead: stocktake = start_stocktake(db, warehouse_ids=payload.warehouse_ids, user_id=context.user.id, remark=payload.remark) return _stocktake_read(db, stocktake.id) @router.get("/stocktakes/{stocktake_id}/export") def export_inventory_stocktake(stocktake_id: int, db: Session = Depends(get_db)) -> Response: workbook = build_stocktake_workbook(db, stocktake_id) stocktake = db.get(Stocktake, stocktake_id) if not stocktake: raise HTTPException(status_code=404, detail="盘库单不存在") return _excel_response(workbook, f"{stocktake.stocktake_no}_盘库清单.xlsx") @router.get("/stocktakes", response_model=list[StocktakeRead]) def list_inventory_stocktakes( limit: int = Query(default=100, ge=1, le=500), warehouse_id: int | None = Query(default=None), db: Session = Depends(get_db), ) -> list[StocktakeRead]: stmt = select(Stocktake).order_by(Stocktake.started_at.desc(), Stocktake.id.desc()).limit(limit) if warehouse_id: stmt = ( select(Stocktake) .join(StocktakeWarehouse, StocktakeWarehouse.stocktake_id == Stocktake.id) .where(StocktakeWarehouse.warehouse_id == warehouse_id) .order_by(Stocktake.started_at.desc(), Stocktake.id.desc()) .limit(limit) ) rows = db.scalars(stmt).all() return [_stocktake_read(db, row.id, warehouse_id=warehouse_id) for row in rows] @router.get("/stocktakes/{stocktake_id}", response_model=StocktakeRead) def get_inventory_stocktake(stocktake_id: int, db: Session = Depends(get_db)) -> StocktakeRead: return _stocktake_read(db, stocktake_id) @router.get("/stocktakes/{stocktake_id}/lines", response_model=list[StocktakeLineRead]) def list_inventory_stocktake_lines( stocktake_id: int, warehouse_id: int | None = Query(default=None), db: Session = Depends(get_db), ) -> list[StocktakeLineRead]: lines = stocktake_lines_for_read(db, stocktake_id) if warehouse_id: lines = [line for line in lines if int(line.warehouse_id) == int(warehouse_id)] return [StocktakeLineRead.model_validate(_stocktake_line_read(line)) for line in lines] @router.post("/stocktakes/{stocktake_id}/import", response_model=StocktakeImportPreviewRead) async def import_inventory_stocktake( stocktake_id: int, file: UploadFile = File(...), context: AuthContext = Depends(require_authenticated_user), db: Session = Depends(get_db), ) -> StocktakeImportPreviewRead: content = await file.read() try: result = import_stocktake_workbook(db, stocktake_id, content, user_id=context.user.id) except ValueError as exc: db.rollback() raise HTTPException(status_code=400, detail=str(exc)) from exc return _stocktake_preview_read(db, result) @router.post("/stocktakes/{stocktake_id}/confirm", response_model=StocktakeRead) def confirm_inventory_stocktake( stocktake_id: int, payload: StocktakeConfirmCreate, context: AuthContext = Depends(require_authenticated_user), db: Session = Depends(get_db), ) -> StocktakeRead: stocktake = confirm_stocktake( db, stocktake_id, user_id=context.user.id, confirm_text=payload.confirm_text, remark=payload.remark, ) return _stocktake_read(db, stocktake.id) @router.post("/stocktakes/{stocktake_id}/cancel", response_model=StocktakeRead) def cancel_inventory_stocktake( stocktake_id: int, payload: StocktakeConfirmCreate, context: AuthContext = Depends(require_authenticated_user), db: Session = Depends(get_db), ) -> StocktakeRead: stocktake = cancel_stocktake(db, stocktake_id, user_id=context.user.id, remark=payload.remark) return _stocktake_read(db, stocktake.id) def _normalize_warehouse_type(value: str | None) -> str: text = str(value or "").strip().upper() if text in WAREHOUSE_TYPE_LABELS: return text return WAREHOUSE_LABEL_TO_TYPE.get(str(value or "").strip(), "") def _opening_cell_text(value: object) -> str: return str(value or "").strip() def _opening_decimal(value: object, default: Decimal = Decimal("0"), places: str = "0.000001") -> Decimal: if value is None or _opening_cell_text(value) == "": return default try: return Decimal(str(value)).quantize(Decimal(places)) except Exception as exc: raise ValueError(f"数值格式错误:{value}") from exc def _opening_row_is_empty(row: dict[str, object]) -> bool: return not any(_opening_cell_text(value) for key, value in row.items() if not str(key).startswith("_")) def _opening_row_is_sample(row: dict[str, object]) -> bool: lot_no = _opening_cell_text(row.get("批次号")) item_code = _opening_cell_text(row.get("物料编码")) remark = _opening_cell_text(row.get("备注")) return lot_no.startswith("示例-") and item_code.startswith("示例") and "示例行" in remark def _opening_sheet_title(warehouse_type: str) -> str: return f"{WAREHOUSE_TYPE_LABELS.get(warehouse_type, warehouse_type)}期初导入"[:31] def build_opening_inventory_template(db: Session, warehouse_type: str) -> Workbook: normalized_type = _normalize_warehouse_type(warehouse_type) if normalized_type not in WAREHOUSE_TYPE_LABELS: raise HTTPException(status_code=400, detail="未知仓库类型,无法导出期初模版") workbook = Workbook() worksheet = workbook.active worksheet.title = _opening_sheet_title(normalized_type) worksheet.freeze_panes = "A2" headers = _opening_inventory_headers_for_type(normalized_type) worksheet.append(headers) for row in OPENING_INVENTORY_SAMPLE_ROWS.get(normalized_type, []): worksheet.append(_opening_inventory_row_for_headers(row, headers)) widths_by_header = { "仓库类型": 14, "仓库名称": 18, "库位": 18, "物料编码": 20, "物料名称": 28, "批次号": 24, "数量": 12, "重量(kg)": 14, "单价": 12, "来源说明": 24, "备注": 24, } for index, header in enumerate(headers, start=1): width = widths_by_header.get(header, 14) worksheet.column_dimensions[worksheet.cell(row=1, column=index).column_letter].width = width return workbook def _read_opening_inventory_rows(content: bytes) -> list[dict[str, object]]: try: workbook = load_workbook(BytesIO(content), read_only=True, data_only=True) except Exception as exc: raise ValueError("Excel 文件读取失败,请确认上传的是期初导入 xlsx 文件") from exc rows: list[dict[str, object]] = [] for worksheet in workbook.worksheets: values = list(worksheet.iter_rows(values_only=True)) if not values: continue headers = [_opening_cell_text(value) for value in values[0]] required_headers = [header for header in OPENING_INVENTORY_HEADERS if header != "数量"] missing = [header for header in required_headers if header not in headers] if missing: raise ValueError(f"{worksheet.title} 缺少必要列:{'、'.join(missing)}") for row_no, raw_row in enumerate(values[1:], start=2): payload = {headers[index]: value for index, value in enumerate(raw_row) if index < len(headers)} if _opening_row_is_empty(payload) or _opening_row_is_sample(payload): continue payload["_row_no"] = row_no payload["_sheet_name"] = worksheet.title rows.append(payload) return rows def _find_opening_warehouse(db: Session, warehouse_type: str, warehouse_name: str, row_no: int) -> Warehouse: query = select(Warehouse).where(Warehouse.warehouse_type == warehouse_type, Warehouse.status == "ACTIVE") if warehouse_name: query = query.where(Warehouse.warehouse_name == warehouse_name) warehouses = db.scalars(query.order_by(Warehouse.id.asc())).all() if not warehouses: raise ValueError(f"第 {row_no} 行仓库不存在或未启用:{warehouse_name or WAREHOUSE_TYPE_LABELS[warehouse_type]}") if len(warehouses) > 1: raise ValueError(f"第 {row_no} 行仓库名称不能为空,该类型存在多个仓库") return warehouses[0] def _find_opening_location(db: Session, warehouse_id: int, location_name: str, row_no: int) -> WarehouseLocation | None: if not location_name: return get_location_or_default(db, warehouse_id, None) location = db.scalar( select(WarehouseLocation).where( WarehouseLocation.warehouse_id == warehouse_id, WarehouseLocation.location_name == location_name, WarehouseLocation.status == "ACTIVE", ) ) if not location: raise ValueError(f"第 {row_no} 行库位不存在或未启用:{location_name}") return location def _create_opening_inventory_lot( db: Session, *, item: Item, warehouse: Warehouse, location: WarehouseLocation | None, qty: Decimal, weight: Decimal, unit_cost: Decimal, lot_no: str | None, source_summary: str | None, remark: str | None, user_id: int | None, ) -> StockLot: warehouse_type = str(warehouse.warehouse_type or "").upper() raw_weight_only = warehouse_type in {"RAW", "SCRAP"} inbound_qty = Decimal("0") if raw_weight_only else qty if warehouse_type == "RAW": actual_lot_no = build_inventory_lot_no(db, item.id) else: actual_lot_no = lot_no or _build_opening_lot_no(db, item, warehouse) if db.scalar(select(StockLot).where(StockLot.lot_no == actual_lot_no)): raise ValueError(f"批次号 {actual_lot_no} 已存在,请更换后重试") now = datetime.now() lot = StockLot( lot_no=actual_lot_no, parent_lot_id=None, lot_role="OPENING_STOCK", material_sub_batch_no=None, item_id=item.id, warehouse_id=warehouse.id, location_id=location.id if location else None, source_doc_type="OPENING_STOCK", source_doc_id=0, source_line_id=None, source_material_lot_id=None, source_material_sub_batch_no=None, source_material_summary=source_summary or "期初导入", inbound_qty=inbound_qty, inbound_weight_kg=weight, remaining_qty=inbound_qty, remaining_weight_kg=weight, locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=unit_cost, production_date=now.date(), expire_date=None, quality_status="PASS", status="AVAILABLE", remark=remark or "期初导入", ) db.add(lot) db.flush() lot.source_doc_id = lot.id db.add(lot) upsert_stock_balance( db, item_id=item.id, warehouse_id=warehouse.id, location_id=location.id if location else None, qty_delta=inbound_qty, weight_delta=weight, available_qty_delta=inbound_qty, available_weight_delta=weight, unit_cost=unit_cost, ) create_inventory_txn( db, txn_type="OPENING_IN", item_id=item.id, warehouse_id=warehouse.id, location_id=location.id if location else None, lot_id=lot.id, qty_change=inbound_qty, weight_change=weight, unit_cost=unit_cost, source_doc_type="OPENING_STOCK", source_doc_id=lot.id, source_line_id=None, biz_time=now, operator_user_id=user_id, remark=remark or "期初导入", amount_basis="WEIGHT" if raw_weight_only else "QTY", ) return lot def import_opening_inventory_workbook(db: Session, warehouse_type: str, content: bytes, user_id: int | None) -> dict[str, object]: normalized_type = _normalize_warehouse_type(warehouse_type) if normalized_type not in WAREHOUSE_TYPE_LABELS: raise ValueError("未知仓库类型,无法导入期初库存") rows = _read_opening_inventory_rows(content) if not rows: raise ValueError("导入文件没有可导入的期初库存明细") imported = 0 for row in rows: row_no = int(row["_row_no"]) row_type = _normalize_warehouse_type(_opening_cell_text(row.get("仓库类型"))) if row_type and row_type != normalized_type: raise ValueError(f"第 {row_no} 行仓库类型与当前导入类型不一致") warehouse = _find_opening_warehouse(db, normalized_type, _opening_cell_text(row.get("仓库名称")), row_no) ensure_warehouses_unlocked(db, [warehouse.id], "期初导入") item_code = _opening_cell_text(row.get("物料编码")) item_name = _opening_cell_text(row.get("物料名称")) if not item_code: raise ValueError(f"第 {row_no} 行物料编码不能为空") item = db.scalar(select(Item).where(Item.item_code == item_code).limit(1)) if not item: raise ValueError(f"第 {row_no} 行物料不存在:{item_code}") if item_name and item.item_name != item_name: raise ValueError(f"第 {row_no} 行物料名称与系统不一致:{item_code} / {item_name}") _validate_warehouse_item(warehouse, item) location = _find_opening_location(db, warehouse.id, _opening_cell_text(row.get("库位")), row_no) qty = _opening_decimal(row.get("数量")) weight = _opening_decimal(row.get("重量(kg)")) unit_cost = _opening_decimal(row.get("单价"), places="0.0001") if normalized_type == "AUX": if qty <= 0: raise ValueError(f"第 {row_no} 行辅料库数量必须大于0") weight = Decimal("0") elif weight <= 0: raise ValueError(f"第 {row_no} 行重量(kg)必须大于0") if normalized_type in {"SEMI", "FINISHED", "RETURN"} and qty <= 0: raise ValueError(f"第 {row_no} 行{WAREHOUSE_TYPE_LABELS[normalized_type]}数量必须大于0") if unit_cost < 0: raise ValueError(f"第 {row_no} 行单价不能小于0") _create_opening_inventory_lot( db, item=item, warehouse=warehouse, location=location, qty=qty, weight=weight, unit_cost=unit_cost, lot_no=_opening_cell_text(row.get("批次号")) or None, source_summary=_opening_cell_text(row.get("来源说明")) or None, remark=_opening_cell_text(row.get("备注")) or None, user_id=user_id, ) imported += 1 db.commit() return { "imported": imported, "warehouse_type": normalized_type, "message": f"期初库存导入完成:新增 {imported} 条", } @router.get("/opening/template") def export_opening_inventory_template( warehouse_type: str = Query(...), db: Session = Depends(get_db), ) -> Response: normalized_type = _normalize_warehouse_type(warehouse_type) workbook = build_opening_inventory_template(db, normalized_type) filename = f"{WAREHOUSE_TYPE_LABELS[normalized_type]}期初导入模版.xlsx" return _excel_response(workbook, filename) @router.post("/opening/import", response_model=OpeningInventoryImportResult) async def import_opening_inventory( warehouse_type: str = Query(...), file: UploadFile = File(...), context: AuthContext = Depends(require_authenticated_user), db: Session = Depends(get_db), ) -> OpeningInventoryImportResult: content = await file.read() try: result = import_opening_inventory_workbook(db, warehouse_type, content, user_id=context.user.id) except ValueError as exc: db.rollback() raise HTTPException(status_code=400, detail=str(exc)) from exc return OpeningInventoryImportResult.model_validate(result) @router.get("/stock-lots", response_model=list[StockLotRead]) def list_stock_lots( item_type: str | None = Query(default=None), limit: int = Query(default=200, ge=1, le=500), db: Session = Depends(get_db), ) -> list[StockLotRead]: rows = db.execute(get_stock_lots_query(limit=limit, item_type=item_type)).mappings().all() return [StockLotRead.model_validate(dict(row)) for row in rows] @router.get("/stock-lots/{lot_id}/purchase-links", response_model=list[StockLotPurchaseLinkRead]) def list_stock_lot_purchase_links(lot_id: int, db: Session = Depends(get_db)) -> list[StockLotPurchaseLinkRead]: return [StockLotPurchaseLinkRead.model_validate(row) for row in get_stock_lot_purchase_links(db, lot_id)] @router.get("/stock-lot-options", response_model=list[StockLotOptionRead]) def list_stock_lot_options( material_item_id: int | None = Query(default=None, ge=1), limit: int = Query(default=500, ge=1, le=1000), db: Session = Depends(get_db), ) -> list[StockLotOptionRead]: rows = db.execute(get_stock_lot_options_query(material_item_id=material_item_id).limit(limit)).mappings().all() return [StockLotOptionRead.model_validate(dict(row)) for row in rows] @router.get("/stock-balances", response_model=list[StockBalanceRead]) def list_stock_balances( limit: int = Query(default=200, ge=1, le=500), db: Session = Depends(get_db), ) -> list[StockBalanceRead]: rows = db.execute(get_stock_balances_query(limit=limit)).mappings().all() return [StockBalanceRead.model_validate(dict(row)) for row in rows] @router.get("/transactions", response_model=list[InventoryTxnRead]) def list_inventory_transactions( limit: int = Query(default=300, ge=1, le=800), db: Session = Depends(get_db), ) -> list[InventoryTxnRead]: rows = _apply_production_inbound_archive_links( db, [dict(row) for row in db.execute(get_inventory_txn_query(limit=limit)).mappings().all()], ) return [InventoryTxnRead.model_validate(row) for row in rows] PRODUCTION_LEDGER_INBOUND_TXN_ORDER = { "成品入库": 1, "生产余料入库": 2, "生产废料入库": 3, } def _latest_inventory_archive_map(db: Session, document_type: str, business_ids: list[int]) -> dict[int, DocumentArchive]: normalized_ids = sorted({int(business_id) for business_id in business_ids if business_id}) if not normalized_ids: return {} latest_version_subquery = ( select( DocumentArchive.business_id.label("business_id"), func.max(DocumentArchive.archive_version).label("archive_version"), ) .where( DocumentArchive.document_type == document_type, DocumentArchive.file_format == FILE_FORMAT_PDF, DocumentArchive.business_id.in_(normalized_ids), ) .group_by(DocumentArchive.business_id) .subquery() ) latest_archive_id_subquery = ( select( DocumentArchive.business_id.label("business_id"), func.max(DocumentArchive.id).label("archive_id"), ) .join( latest_version_subquery, and_( latest_version_subquery.c.business_id == DocumentArchive.business_id, latest_version_subquery.c.archive_version == DocumentArchive.archive_version, ), ) .where( DocumentArchive.document_type == document_type, DocumentArchive.file_format == FILE_FORMAT_PDF, DocumentArchive.business_id.in_(normalized_ids), ) .group_by(DocumentArchive.business_id) .subquery() ) archives = db.scalars( select(DocumentArchive).join( latest_archive_id_subquery, latest_archive_id_subquery.c.archive_id == DocumentArchive.id, ) ).all() return {int(archive.business_id): archive for archive in archives} def _production_inbound_group_key(txn: ProductionBatchLedgerTxn) -> tuple[int, object]: batch_no = production_document_batch_no_from_text(txn.remark) return ( int(txn.production_ledger_id), f"批次:{batch_no}" if batch_no else txn.biz_time, ) def _apply_production_inbound_archive_links(db: Session, rows: list[dict[str, object]]) -> list[dict[str, object]]: inventory_txn_ids = [ int(row["inventory_txn_id"]) for row in rows if row.get("archive_document_type") == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT and row.get("inventory_txn_id") ] if not inventory_txn_ids: return rows page_ledger_txns = db.scalars( select(ProductionBatchLedgerTxn).where( ProductionBatchLedgerTxn.source_doc_type == "库存流水", ProductionBatchLedgerTxn.source_doc_id.in_(inventory_txn_ids), ProductionBatchLedgerTxn.txn_type.in_(PRODUCTION_LEDGER_INBOUND_TXN_ORDER.keys()), ) ).all() ledger_ids = sorted({int(txn.production_ledger_id) for txn in page_ledger_txns}) if not ledger_ids: return rows all_ledger_txns = db.scalars( select(ProductionBatchLedgerTxn).where( ProductionBatchLedgerTxn.production_ledger_id.in_(ledger_ids), ProductionBatchLedgerTxn.source_doc_type == "库存流水", ProductionBatchLedgerTxn.source_doc_id.is_not(None), ProductionBatchLedgerTxn.txn_type.in_(PRODUCTION_LEDGER_INBOUND_TXN_ORDER.keys()), ) ).all() grouped_txns: dict[tuple[int, object], list[ProductionBatchLedgerTxn]] = {} for txn in all_ledger_txns: grouped_txns.setdefault(_production_inbound_group_key(txn), []).append(txn) settlement_business_id_by_inventory_txn_id: dict[int, int] = {} for group_txns in grouped_txns.values(): main_txn = sorted( group_txns, key=lambda item: ( PRODUCTION_LEDGER_INBOUND_TXN_ORDER.get(str(item.txn_type or ""), 99), int(item.source_doc_id or 0), ), )[0] if not main_txn.source_doc_id: continue main_business_id = int(main_txn.source_doc_id) for txn in group_txns: if txn.source_doc_id: settlement_business_id_by_inventory_txn_id[int(txn.source_doc_id)] = main_business_id if not settlement_business_id_by_inventory_txn_id: return rows archive_map = _latest_inventory_archive_map( db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, list(set(settlement_business_id_by_inventory_txn_id.values())), ) for row in rows: inventory_txn_id = int(row.get("inventory_txn_id") or 0) main_business_id = settlement_business_id_by_inventory_txn_id.get(inventory_txn_id) if not main_business_id: continue archive = archive_map.get(main_business_id) row["archive_document_type"] = DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT row["archive_business_id"] = main_business_id row["archive_status"] = archive.status if archive else ARCHIVE_STATUS_MISSING row["archive_error_message"] = archive.error_message if archive else None return rows @router.get("/transaction-ledger", response_model=InventoryTxnLedgerResponse) def list_inventory_transaction_ledger( warehouse_type: str = Query(...), warehouse_id: int | None = None, keyword: str | None = None, direction: str = "ALL", txn_type: str | None = None, item_id: int | None = None, lot_no: str | None = None, start_date: date | None = None, end_date: date | None = None, page: int = 1, page_size: int = 10, sort_key: str = "biz_time", sort_direction: str = "desc", db: Session = Depends(get_db), ) -> InventoryTxnLedgerResponse: normalized_warehouse_type = str(warehouse_type or "").upper() if normalized_warehouse_type not in WAREHOUSE_LEDGER_TYPES: raise HTTPException(status_code=400, detail="未知仓库类型,无法查询出入库流水") normalized_direction = str(direction or "ALL").upper() if normalized_direction not in {"ALL", "IN", "OUT", "ADJUST"}: raise HTTPException(status_code=400, detail="流水方向只能是 ALL、IN、OUT、ADJUST") if page < 1: raise HTTPException(status_code=400, detail="页码必须大于等于 1") if page_size < 1 or page_size > 100: raise HTTPException(status_code=400, detail="每页条数必须在 1 到 100 之间") if warehouse_id is not None and warehouse_id < 1: raise HTTPException(status_code=400, detail="仓库 ID 必须大于等于 1") if item_id is not None and item_id < 1: raise HTTPException(status_code=400, detail="物料 ID 必须大于等于 1") stmt = get_inventory_txn_ledger_query( warehouse_type=normalized_warehouse_type, warehouse_id=warehouse_id, keyword=keyword, direction=normalized_direction, txn_type=txn_type, item_id=item_id, lot_no=lot_no, start_date=start_date, end_date=end_date, sort_key=sort_key, sort_direction=sort_direction, ) total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0 summary = summarize_inventory_txn_ledger(db, stmt) offset = (page - 1) * page_size rows = _apply_production_inbound_archive_links( db, [dict(row) for row in db.execute(stmt.offset(offset).limit(page_size)).mappings().all()], ) return InventoryTxnLedgerResponse( items=rows, total=total, summary=summary, ) WAREHOUSE_LEDGER_EXPORT_HEADERS = [ ("biz_time", "业务时间"), ("warehouse_name", "仓库"), ("location_name", "库位"), ("direction_label", "方向"), ("txn_type_label", "流水类型"), ("txn_no", "流水号"), ("item_code", "物料/产品编码"), ("item_name", "物料/产品名称"), ("lot_no", "库存批次号"), ("source_material_lot_no", "来源库存批次号"), ("qty_change", "数量变化"), ("weight_change_kg", "重量变化(kg)"), ("unit_cost", "单价"), ("amount", "金额"), ("source_doc_label", "来源单据"), ("operator_name", "经办人"), ("logistics_waybill_no", "运单号"), ("logistics_freight_amount", "运费"), ("logistics_photo_label", "辅助照片"), ("remark", "备注"), ("archive_status", "PDF归档状态"), ("archive_business_id", "PDF归档业务ID"), ] def _ledger_direction_label(value: str | None) -> str: return { "IN": "入库", "OUT": "出库", "ADJUST": "调整", "ALL": "全部", }.get(str(value or "").upper(), "未知方向") def _ledger_source_doc_type_label(value: str | None) -> str: normalized = str(value or "").strip() upper_value = normalized.upper() labels = { "OPENING_STOCK": "期初入库", "CUSTOMER_SUPPLIED_IN": "客料入库", "PURCHASE_RECEIPT": "到货入库单", "PURCHASE_RETURN": "采购退货", "WORK_ORDER": "生产工单", "FG_RECEIPT": "成品入库单", "DELIVERY": "发货单", "STOCKTAKE": "盘库", "SPECIAL_ADJUSTMENT": "特殊调整", "PRODUCT_ROUTE_OPERATION": "产品工序", "PRODUCTION_LEDGER": "生产台账", } if normalized == "生产台账": return "生产台账" if upper_value in labels: return labels[upper_value] if not normalized: return "" if all(char.isalnum() or char == "_" for char in normalized): return "系统单据" return normalized def _ledger_export_value(row: dict[str, object], key: str) -> object: if key == "direction_label": return _ledger_direction_label(row.get("direction")) if key == "txn_type_label": return _inventory_biz_type_label(row.get("txn_type")) if key == "source_doc_label": source_type = _ledger_source_doc_type_label(row.get("source_doc_type")) source_id = row.get("source_doc_id") return f"{source_type} #{source_id}" if source_type and source_id is not None else source_type if key == "logistics_photo_label": return "已上传" if row.get("logistics_photo_url") else "" value = row.get(key) return "" if value is None else value @router.get("/transaction-ledger/export") def export_inventory_transaction_ledger( warehouse_type: str = Query(...), warehouse_id: int | None = None, keyword: str | None = None, direction: str = "ALL", txn_type: str | None = None, item_id: int | None = None, lot_no: str | None = None, start_date: date | None = None, end_date: date | None = None, sort_key: str = "biz_time", sort_direction: str = "desc", db: Session = Depends(get_db), ) -> Response: normalized_warehouse_type = str(warehouse_type or "").upper() if normalized_warehouse_type not in WAREHOUSE_LEDGER_TYPES: raise HTTPException(status_code=400, detail="未知仓库类型,无法导出出入库流水") normalized_direction = str(direction or "ALL").upper() if normalized_direction not in {"ALL", "IN", "OUT", "ADJUST"}: raise HTTPException(status_code=400, detail="流水方向只能是 ALL、IN、OUT、ADJUST") if warehouse_id is not None and warehouse_id < 1: raise HTTPException(status_code=400, detail="仓库 ID 必须大于等于 1") if item_id is not None and item_id < 1: raise HTTPException(status_code=400, detail="物料 ID 必须大于等于 1") stmt = get_inventory_txn_ledger_query( warehouse_type=normalized_warehouse_type, warehouse_id=warehouse_id, keyword=keyword, direction=normalized_direction, txn_type=txn_type, item_id=item_id, lot_no=lot_no, start_date=start_date, end_date=end_date, sort_key=sort_key, sort_direction=sort_direction, ) rows = _apply_production_inbound_archive_links( db, [dict(row) for row in db.execute(stmt).mappings().all()], ) workbook = Workbook() sheet = workbook.active sheet.title = "出入库流水" sheet.append([label for _, label in WAREHOUSE_LEDGER_EXPORT_HEADERS]) for row in rows: sheet.append([_ledger_export_value(row, key) for key, _ in WAREHOUSE_LEDGER_EXPORT_HEADERS]) sheet.freeze_panes = "A2" sheet.auto_filter.ref = sheet.dimensions for column_cells in sheet.columns: header = str(column_cells[0].value or "") max_length = max(len(str(cell.value or "")) for cell in column_cells) sheet.column_dimensions[column_cells[0].column_letter].width = min(max(max_length + 2, len(header) + 2), 42) date_range = f"{start_date or '全部'}_{end_date or '全部'}" warehouse_label = WAREHOUSE_TYPE_LABELS.get(normalized_warehouse_type, normalized_warehouse_type) return _excel_response(workbook, f"{warehouse_label}出入库流水_{date_range}.xlsx") @router.post("/logistics-photos") async def upload_logistics_photo(file: UploadFile = File(...)) -> dict[str, str]: suffix = Path(file.filename or "").suffix.lower() content_type = (file.content_type or "").lower() if suffix not in LOGISTICS_ALLOWED_SUFFIXES or not content_type.startswith("image/"): raise HTTPException(status_code=400, detail="辅助照片仅支持 jpg、png、webp、heic 图片") content = await file.read() if len(content) <= 0: raise HTTPException(status_code=400, detail="上传的辅助照片为空") if len(content) > LOGISTICS_MAX_BYTES: raise HTTPException(status_code=400, detail="辅助照片不能超过10MB") LOGISTICS_UPLOAD_DIR.mkdir(parents=True, exist_ok=True) stored_name = f"{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid4().hex}{suffix}" target = LOGISTICS_UPLOAD_DIR / stored_name target.write_bytes(content) return {"filename": stored_name, "url": f"/inventory/logistics-photos/{stored_name}"} @router.get("/logistics-photos/{filename}") def get_logistics_photo(filename: str) -> FileResponse: if Path(filename).name != filename: raise HTTPException(status_code=400, detail="照片路径不合法") target = LOGISTICS_UPLOAD_DIR / filename if not target.exists() or not target.is_file(): raise HTTPException(status_code=404, detail="辅助照片不存在") return FileResponse(target) @router.get("/settlements", response_model=list[InventorySettlementRead]) def list_inventory_settlements( limit: int = Query(default=200, ge=1, le=800), db: Session = Depends(get_db), ) -> list[InventorySettlementRead]: return [InventorySettlementRead.model_validate(row) for row in get_inventory_settlements(db, limit=limit)] @router.post("/special-adjustments", response_model=SpecialWarehouseAdjustmentRead) def create_special_warehouse_adjustment( payload: SpecialWarehouseAdjustmentCreate, context: AuthContext = Depends(require_authenticated_user), db: Session = Depends(get_db), ) -> SpecialWarehouseAdjustmentRead: try: adjustment = create_special_inventory_adjustment(db, payload, user_id=context.user.id) except ValueError as exc: db.rollback() raise HTTPException(status_code=400, detail=str(exc)) from exc archive_business_ids = [ int(inventory_txn_id) for inventory_txn_id in db.scalars( select(SpecialWarehouseAdjustmentLine.inventory_txn_id) .where( SpecialWarehouseAdjustmentLine.adjustment_id == adjustment.id, SpecialWarehouseAdjustmentLine.inventory_txn_id.is_not(None), ) .order_by(SpecialWarehouseAdjustmentLine.line_no.asc()) ).all() ] archive_fields = _generate_inventory_archives_for_txns( db, document_type=DOCUMENT_TYPE_WAREHOUSE_OPERATION, business_ids=archive_business_ids, created_by=context.user.id, ) result = SpecialWarehouseAdjustmentRead.model_validate(adjustment) result.archive_status = archive_fields["archive_status"] result.archive_business_id = archive_fields["archive_business_id"] result.archive_document_type = archive_fields["archive_document_type"] result.archive_error_message = archive_fields["archive_error_message"] return result @router.post("/customer-supplied-inbound", response_model=StockLotRead) def create_customer_supplied_inbound( payload: CustomerSuppliedInboundCreate, context: AuthContext = Depends(require_authenticated_user), db: Session = Depends(get_db), ) -> StockLotRead: material = db.get(Item, payload.material_item_id) if not material or material.item_type != "RAW_MATERIAL": raise HTTPException(status_code=404, detail="客供料入库只能选择原材料") warehouse = db.get(Warehouse, payload.warehouse_id) if not warehouse: raise HTTPException(status_code=404, detail="仓库不存在") ensure_warehouses_unlocked(db, [payload.warehouse_id], "客料入库") warehouse_type = str(warehouse.warehouse_type or "").upper() biz_type = "CUSTOMER_SUPPLIED" if biz_type not in _ALLOWED_INBOUND_BY_WAREHOUSE_TYPE.get(warehouse_type, set()): raise HTTPException( status_code=400, detail=f"{_warehouse_type_label(warehouse_type)}不允许执行该入库业务", ) _validate_warehouse_item(warehouse, material) inbound_weight = to_decimal(payload.inbound_weight_kg) inbound_qty = Decimal("0") if inbound_weight <= 0: raise HTTPException(status_code=400, detail="客供料入库重量必须大于0") location = get_location_or_default(db, payload.warehouse_id, payload.location_id) now = datetime.now() lot = StockLot( lot_no=_build_customer_supplied_lot_no(db, material), parent_lot_id=None, lot_role="CUSTOMER_SUPPLIED_RAW", material_sub_batch_no=None, item_id=payload.material_item_id, warehouse_id=payload.warehouse_id, location_id=location.id if location else None, source_doc_type="CUSTOMER_SUPPLIED_IN", source_doc_id=0, source_line_id=None, source_material_lot_id=None, source_material_sub_batch_no=None, source_material_summary=payload.provider_name or "客供料", inbound_qty=inbound_qty, inbound_weight_kg=inbound_weight, remaining_qty=inbound_qty, remaining_weight_kg=inbound_weight, locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=Decimal("0"), production_date=now.date(), expire_date=None, quality_status="PASS", status="AVAILABLE", remark=payload.remark or f"客供料入库:{payload.provider_name or '未填写提供方'}", ) db.add(lot) db.flush() lot.source_doc_id = lot.id db.add(lot) upsert_stock_balance( db, item_id=payload.material_item_id, warehouse_id=payload.warehouse_id, location_id=location.id if location else None, qty_delta=Decimal("0"), weight_delta=inbound_weight, available_qty_delta=Decimal("0"), available_weight_delta=inbound_weight, unit_cost=Decimal("0"), ) create_inventory_txn( db, txn_type="CUSTOMER_SUPPLIED_IN", item_id=payload.material_item_id, warehouse_id=payload.warehouse_id, location_id=location.id if location else None, lot_id=lot.id, qty_change=Decimal("0"), weight_change=inbound_weight, unit_cost=Decimal("0"), source_doc_type="CUSTOMER_SUPPLIED_IN", source_doc_id=lot.id, source_line_id=None, biz_time=now, operator_user_id=context.user.id, remark=lot.remark, amount_basis="WEIGHT", ) db.commit() row = db.execute(get_stock_lots_query(limit=1).where(StockLot.id == lot.id)).mappings().first() if not row: raise HTTPException(status_code=500, detail="客供料入库后读取失败") return StockLotRead.model_validate(dict(row)) # ---------- unified lot_no builders ---------- def _build_opening_lot_no(db: Session, item: Item, warehouse: Warehouse) -> str: date_part = datetime.now().strftime("%Y%m%d") item_part = _code_token(item.item_code or item.item_name, f"IT{item.id:04d}") wh_part = _code_token(warehouse.warehouse_name or "", "WH")[:4] prefix = f"QC-{wh_part}-{date_part}-{item_part}" count = db.scalar(select(func.count(StockLot.id)).where(StockLot.lot_no.like(f"{prefix}-%"))) or 0 return f"{prefix}-{count + 1:03d}" def _build_surplus_lot_no(db: Session, item: Item, warehouse: Warehouse, prefix_tag: str) -> str: date_part = datetime.now().strftime("%Y%m%d") item_part = _code_token(item.item_code or item.item_name, f"IT{item.id:04d}") prefix = f"{prefix_tag}-{date_part}-{item_part}" count = db.scalar(select(func.count(StockLot.id)).where(StockLot.lot_no.like(f"{prefix}-%"))) or 0 return f"{prefix}-{count + 1:03d}" def _build_wip_lot_no(db: Session, item: Item, warehouse: Warehouse) -> str: date_part = datetime.now().strftime("%Y%m%d") item_part = _code_token(item.item_code or item.item_name, f"IT{item.id:04d}") prefix = f"WIP-{date_part}-{item_part}" count = db.scalar(select(func.count(StockLot.id)).where(StockLot.lot_no.like(f"{prefix}-%"))) or 0 return f"{prefix}-{count + 1:03d}" def _build_finished_lot_no(db: Session, item: Item, warehouse: Warehouse) -> str: date_part = datetime.now().strftime("%Y%m%d") item_part = _code_token(item.item_code or item.item_name, f"IT{item.id:04d}") prefix = f"FG-{date_part}-{item_part}" count = db.scalar(select(func.count(StockLot.id)).where(StockLot.lot_no.like(f"{prefix}-%"))) or 0 return f"{prefix}-{count + 1:03d}" # ---------- inbound biz_type -> (lot_role, txn_type, source_doc_type, default_cost) ---------- _INBOUND_BIZ_MAP = { "OPENING": ("OPENING_STOCK", "OPENING_IN", "OPENING_STOCK", Decimal("0")), "CUSTOMER_SUPPLIED": ("CUSTOMER_SUPPLIED_RAW","CUSTOMER_SUPPLIED_IN","CUSTOMER_SUPPLIED_IN", Decimal("0")), "PRODUCTION_SURPLUS": ("PRODUCTION_SURPLUS", "PRODUCTION_SURPLUS_IN","PRODUCTION_SURPLUS_IN", None), "OUTSOURCING_SURPLUS": ("OUTSOURCING_SURPLUS", "OUTSOURCING_SURPLUS_IN","OUTSOURCING_SURPLUS_IN", None), "PRODUCTION_SCRAP_IN": ("PRODUCTION_SCRAP", "PRODUCTION_SCRAP_IN", "PRODUCTION_SCRAP_IN", None), "REWORK_SCRAP_IN": ("REWORK_SCRAP", "REWORK_SCRAP_IN", "REWORK_SCRAP_IN", None), "OUTSOURCING_SCRAP_IN":("OUTSOURCING_SCRAP", "OUTSOURCING_SCRAP_IN","OUTSOURCING_SCRAP_IN", None), "WIP_IN": ("WIP_STAGING", "WIP_IN", "WIP_IN", None), "OUTSOURCING_IN": ("OUTSOURCING_IN", "OUTSOURCING_IN", "OUTSOURCING_IN", None), "PRODUCTION_FINISHED": ("INBOUND_FINISHED", "PRODUCTION_FINISHED_IN","PRODUCTION_FINISHED_IN", None), } _ALLOWED_INBOUND_BY_WAREHOUSE_TYPE = { "RAW": {"OPENING", "CUSTOMER_SUPPLIED", "PRODUCTION_SURPLUS", "OUTSOURCING_SURPLUS"}, "SEMI": {"WIP_IN", "OPENING", "OUTSOURCING_IN"}, "FINISHED": {"PRODUCTION_FINISHED", "OUTSOURCING_IN", "OPENING"}, "AUX": {"OPENING"}, "SCRAP": {"PRODUCTION_SCRAP_IN", "REWORK_SCRAP_IN", "OPENING", "OUTSOURCING_SCRAP_IN"}, } _LOGISTICS_REQUIRED_INBOUND_BIZ_TYPES = { "CUSTOMER_SUPPLIED", "OUTSOURCING_SURPLUS", "OUTSOURCING_IN", "OUTSOURCING_SCRAP_IN", } _SOURCE_LOT_RETURN_INBOUND_BIZ_TYPES = {"PRODUCTION_SURPLUS", "OUTSOURCING_SURPLUS"} PRODUCTION_ARCHIVE_INBOUND_BIZ_TYPES = {"PRODUCTION_FINISHED", "PRODUCTION_SURPLUS", "PRODUCTION_SCRAP_IN"} _PRODUCTION_WORK_ORDER_INBOUND_SETTLE_MARKERS = { "PRODUCTION_SURPLUS": "生产余料已结单", "PRODUCTION_SCRAP_IN": "生产废料已结单", } _PRODUCTION_WORK_ORDER_REQUIRED_SETTLE_MARKERS = ( "生产余料已结单", "生产废料已结单", ) _PRODUCTION_FINISHED_SETTLE_MARKER = "生产入库已结单" def _require_work_order_inbound_source(payload: WarehouseInboundCreate, label: str) -> int: if str(payload.source_doc_type or "").upper() != "WORK_ORDER" or not payload.source_doc_id: raise HTTPException(status_code=400, detail=f"{label}必须选择生产工单") return int(payload.source_doc_id) def _is_production_ledger_source(payload: WarehouseInboundCreate) -> bool: source_type = str(payload.source_doc_type or "").strip() return source_type.upper() == "PRODUCTION_LEDGER" or source_type == "生产台账" def _require_production_ledger_inbound_source(payload: WarehouseInboundCreate, label: str) -> int: if not _is_production_ledger_source(payload) or not payload.source_doc_id: raise HTTPException(status_code=400, detail=f"{label}必须选择生产台账") return int(payload.source_doc_id) def _should_archive_production_inbound( biz_type: str, *, production_ledger_id: int | None, production_work_order_id: int | None, ) -> bool: return ( biz_type in PRODUCTION_ARCHIVE_INBOUND_BIZ_TYPES and (production_ledger_id is not None or production_work_order_id is not None) ) def _get_active_work_order_for_inbound(db: Session, work_order_id: int, label: str, biz_type: str | None = None) -> WorkOrder: work_order = db.get(WorkOrder, work_order_id) if not work_order: raise HTTPException(status_code=404, detail="生产工单不存在") marker = _PRODUCTION_WORK_ORDER_INBOUND_SETTLE_MARKERS.get(str(biz_type or "").upper()) if marker and marker in set(_work_order_remark_parts(work_order)): raise HTTPException(status_code=400, detail=f"{label}选择的生产工单已完成本项结单,不能继续使用") if str(work_order.status or "").upper() == "SETTLED": if marker and _PRODUCTION_FINISHED_SETTLE_MARKER in set(_work_order_remark_parts(work_order)): return work_order raise HTTPException(status_code=400, detail=f"{label}选择的生产工单已结单,不能继续使用") return work_order def _append_work_order_settle_remark(work_order: WorkOrder, settle_remark: str | None) -> None: remark = (settle_remark or "").strip() if not remark: return existing = (work_order.remark or "").strip() addition = f"结单说明:{remark}" next_remark = f"{existing};{addition}" if existing else addition work_order.remark = next_remark[-255:] def _work_order_remark_parts(work_order: WorkOrder) -> list[str]: return [part.strip() for part in str(work_order.remark or "").replace(";", ";").split(";") if part.strip()] def _set_work_order_remark_parts(work_order: WorkOrder, parts: list[str]) -> None: unique_parts: list[str] = [] for part in parts: normalized = str(part or "").strip() if normalized and normalized not in unique_parts: unique_parts.append(normalized) marker_parts = [marker for marker in _PRODUCTION_WORK_ORDER_REQUIRED_SETTLE_MARKERS if marker in unique_parts] other_parts = [part for part in unique_parts if part not in _PRODUCTION_WORK_ORDER_REQUIRED_SETTLE_MARKERS] next_remark = ";".join(marker_parts + other_parts) if len(next_remark) <= 255: work_order.remark = next_remark return marker_text = ";".join(marker_parts) remaining_length = max(255 - len(marker_text) - (1 if marker_text else 0), 0) other_text = ";".join(other_parts) if marker_text and remaining_length > 0 and other_text: work_order.remark = f"{marker_text};{other_text[-remaining_length:]}" else: work_order.remark = marker_text[:255] if marker_text else other_text[-255:] def _mark_production_work_order_inbound_settled( work_order: WorkOrder, *, biz_type: str, settle_remark: str | None, ) -> None: marker = _PRODUCTION_WORK_ORDER_INBOUND_SETTLE_MARKERS.get(biz_type) if not marker: _append_work_order_settle_remark(work_order, settle_remark) return parts = _work_order_remark_parts(work_order) if marker not in parts: parts.append(marker) remark = (settle_remark or "").strip() if remark: parts.append(f"{marker}说明:{remark}") _set_work_order_remark_parts(work_order, parts) def _production_work_order_inbound_settlements_complete(work_order: WorkOrder) -> bool: parts = set(_work_order_remark_parts(work_order)) return all(marker in parts for marker in _PRODUCTION_WORK_ORDER_REQUIRED_SETTLE_MARKERS) def _apply_production_work_order_inbound_settlement( work_order: WorkOrder, *, biz_type: str, settle_remark: str | None, ) -> None: _mark_production_work_order_inbound_settled(work_order, biz_type=biz_type, settle_remark=settle_remark) if _production_work_order_inbound_settlements_complete(work_order): work_order.status = "SETTLED" work_order.actual_end_time = work_order.actual_end_time or datetime.now() work_order.updated_at = datetime.now() def _validate_surplus_settle_request( payload: WarehouseInboundCreate, *, inbound_weight: Decimal, expected_return_weight: Decimal, returned_weight_before: Decimal, ) -> None: final_return_weight = returned_weight_before + inbound_weight lower_limit = expected_return_weight * Decimal("0.85") upper_limit = expected_return_weight * INBOUND_TOLERANCE_MULTIPLIER if lower_limit <= final_return_weight <= upper_limit: return if not (payload.settle_remark or "").strip(): raise HTTPException(status_code=400, detail="该工单余料结单归还重量超出标准归还重量上下15%,请填写结单说明(余料结单说明)") def _validate_surplus_non_settle_request( *, inbound_weight: Decimal, expected_return_weight: Decimal, returned_weight_before: Decimal, ) -> None: if inbound_weight <= 0: raise HTTPException(status_code=400, detail="生产余料入库重量必须大于0") upper_limit = to_decimal(max(expected_return_weight, Decimal("0")) * INBOUND_TOLERANCE_MULTIPLIER, "0.001") final_return_weight = returned_weight_before + inbound_weight if final_return_weight > upper_limit: raise HTTPException(status_code=400, detail=f"累计归还重量超过允许入库上限 {upper_limit}") def _validate_production_scrap_settle_request( payload: WarehouseInboundCreate, *, inbound_weight: Decimal, expected_scrap_weight: Decimal, scrap_inbound_before: Decimal, ) -> None: final_scrap_weight = scrap_inbound_before + inbound_weight lower_limit = expected_scrap_weight * Decimal("0.85") upper_limit = expected_scrap_weight * INBOUND_TOLERANCE_MULTIPLIER if lower_limit <= final_scrap_weight <= upper_limit: return if not (payload.settle_remark or "").strip(): raise HTTPException(status_code=400, detail="该工单废料结单入库废料重量超出标准废料重量上下15%,请填写结单说明(废料结单说明)") def _validate_production_scrap_non_settle_request( *, inbound_weight: Decimal, expected_scrap_weight: Decimal, scrap_inbound_before: Decimal, ) -> None: if inbound_weight <= 0: raise HTTPException(status_code=400, detail="生产废料入库重量必须大于0") upper_limit = to_decimal(max(expected_scrap_weight, Decimal("0")) * INBOUND_TOLERANCE_MULTIPLIER, "0.001") final_scrap_weight = scrap_inbound_before + inbound_weight if final_scrap_weight > upper_limit: raise HTTPException(status_code=400, detail=f"累计入库废料重量超过允许入库上限 {upper_limit}") def _get_rework_work_order_for_scrap_inbound(db: Session, payload: WarehouseInboundCreate) -> WorkOrder: if str(payload.source_doc_type or "").upper() != "WORK_ORDER" or not payload.source_doc_id: raise HTTPException(status_code=400, detail="返工废料入库请选择返工工单") work_order = db.get(WorkOrder, int(payload.source_doc_id)) if not work_order: raise HTTPException(status_code=404, detail="返工工单不存在") if str(work_order.work_order_type or "").upper() != "REWORK": raise HTTPException(status_code=400, detail="返工废料入库只能选择返工工单") if str(work_order.status or "").upper() == "SETTLED": raise HTTPException(status_code=400, detail="该返工工单已结单,不能继续入废料库") return work_order def _rework_report_scrap_qty(db: Session, work_order_id: int) -> Decimal: return to_decimal( db.scalar( select(func.coalesce(func.sum(OperationReport.scrap_qty), 0)).where(OperationReport.work_order_id == work_order_id) ), "0.000001", ) def _rework_scrap_inbound_qty(db: Session, work_order_id: int) -> Decimal: return to_decimal( db.scalar( select(func.coalesce(func.sum(StockLot.inbound_qty), 0)).where( StockLot.lot_role == "REWORK_SCRAP", StockLot.source_doc_type == "WORK_ORDER", StockLot.source_doc_id == work_order_id, ) ), "0.000001", ) def _rework_finished_inbound_qty(db: Session, work_order_id: int) -> Decimal: return to_decimal( db.scalar( select(func.coalesce(func.sum(InventoryTxn.qty_change), 0)).where( InventoryTxn.txn_type == "REWORK_FINISHED_IN", InventoryTxn.source_doc_type == "WORK_ORDER", InventoryTxn.source_doc_id == work_order_id, ) ), "0.000001", ) def _settle_rework_work_order_if_ready( db: Session, work_order: WorkOrder, *, incoming_scrap_qty: Decimal = Decimal("0"), settle_remark: str | None = None, ) -> None: planned_qty = to_decimal(work_order.planned_qty) processed_qty = _rework_finished_inbound_qty(db, work_order.id) + _rework_scrap_inbound_qty(db, work_order.id) + incoming_scrap_qty if processed_qty < planned_qty: raise HTTPException( status_code=400, detail=f"返工工单尚未闭环:返工出库 {planned_qty} 件,已回库/入废料 {processed_qty} 件", ) work_order.status = "SETTLED" work_order.actual_end_time = work_order.actual_end_time or datetime.now() parts = [part.strip() for part in str(work_order.remark or "").replace(";", ";").split(";") if part.strip()] if "返工工单已结单" not in parts: parts.append("返工工单已结单") remark = str(settle_remark or "").strip() if remark: parts.append(f"返工结单说明:{remark}") work_order.remark = ";".join(dict.fromkeys(parts))[-255:] db.add(work_order) @router.post("/inbound", response_model=StockLotRead) def create_warehouse_inbound( payload: WarehouseInboundCreate, context: AuthContext = Depends(require_authenticated_user), db: Session = Depends(get_db), ) -> StockLotRead: biz_type = (payload.biz_type or "").upper() if biz_type not in _INBOUND_BIZ_MAP: raise HTTPException(status_code=400, detail=f"未知的入库业务类型: {biz_type}") item = db.get(Item, payload.item_id) if not item: raise HTTPException(status_code=404, detail="物料不存在") warehouse = db.get(Warehouse, payload.warehouse_id) if not warehouse: raise HTTPException(status_code=404, detail="仓库不存在") ensure_warehouses_unlocked(db, [payload.warehouse_id], "入库") warehouse_type = str(warehouse.warehouse_type or "").upper() if biz_type not in _ALLOWED_INBOUND_BY_WAREHOUSE_TYPE.get(warehouse_type, set()): raise HTTPException( status_code=400, detail=f"{_warehouse_type_label(warehouse_type)}不允许执行该入库业务", ) _validate_warehouse_item(warehouse, item) logistics_waybill_no, logistics_freight_amount, logistics_photo_url = normalize_logistics_fields( payload.waybill_no, payload.freight_amount, required=biz_type in _LOGISTICS_REQUIRED_INBOUND_BIZ_TYPES, order_photo_url=payload.order_photo_url, ) inbound_weight = to_decimal(payload.inbound_weight_kg) inbound_qty = to_decimal(payload.inbound_qty) is_semi_source_lot_trace_inbound = warehouse_type == "SEMI" and biz_type in {"WIP_IN", "OUTSOURCING_IN"} is_finished_outsourcing_quantity_inbound = warehouse_type == "FINISHED" and biz_type == "OUTSOURCING_IN" is_finished_production_quantity_inbound = ( warehouse_type == "FINISHED" and biz_type == "PRODUCTION_FINISHED" and (_is_production_ledger_source(payload) or str(payload.source_doc_type or "").upper() == "WORK_ORDER") ) is_outsourcing_scrap_source_lot_trace_inbound = warehouse_type == "SCRAP" and biz_type == "OUTSOURCING_SCRAP_IN" is_rework_scrap_inbound = warehouse_type == "SCRAP" and biz_type == "REWORK_SCRAP_IN" is_product_raw_source_lot_trace_inbound = ( is_semi_source_lot_trace_inbound or is_finished_outsourcing_quantity_inbound or is_outsourcing_scrap_source_lot_trace_inbound ) is_aux_quantity_inbound = warehouse_type == "AUX" semi_inbound_measure_basis: str | None = None if inbound_weight < 0 or inbound_qty < 0: raise HTTPException(status_code=400, detail="入库重量和入库数量不能小于0") if is_semi_source_lot_trace_inbound: semi_inbound_measure_basis = _semi_measure_basis_from_values(inbound_weight, inbound_qty, "入库") elif is_finished_outsourcing_quantity_inbound or is_finished_production_quantity_inbound: if inbound_qty <= 0: label = "成品生产入库" if is_finished_production_quantity_inbound else "成品委外入库" raise HTTPException(status_code=400, detail=f"{label}数量必须大于0") if is_finished_production_quantity_inbound and inbound_weight <= 0: inbound_weight = inbound_qty * to_decimal(item.unit_weight_kg) if is_finished_outsourcing_quantity_inbound: inbound_weight = Decimal("0") elif is_aux_quantity_inbound: if inbound_qty <= 0: raise HTTPException(status_code=400, detail="辅料入库数量必须大于0") inbound_weight = Decimal("0") elif is_rework_scrap_inbound: if inbound_qty <= 0: raise HTTPException(status_code=400, detail="返工废料入库数量必须大于0") if inbound_weight <= 0: inbound_weight = inbound_qty * to_decimal(item.unit_weight_kg) if inbound_weight <= 0: raise HTTPException(status_code=400, detail="返工废料入库重量必须大于0") else: if inbound_weight <= 0: raise HTTPException(status_code=400, detail="入库重量必须大于0") inbound_qty = Decimal("0") lot_role, txn_type, source_doc_type, default_cost = _INBOUND_BIZ_MAP[biz_type] production_ledger_id: int | None = None production_work_order_id: int | None = None production_work_order_material = None production_work_order: WorkOrder | None = None rework_scrap_work_order: WorkOrder | None = None settle_production_work_order = False if biz_type == "PRODUCTION_SURPLUS": if _is_production_ledger_source(payload): production_ledger_id = _require_production_ledger_inbound_source(payload, "生产余料入库") else: production_work_order_id = _require_work_order_inbound_source(payload, "生产余料入库") production_work_order = _get_active_work_order_for_inbound(db, production_work_order_id, "生产余料入库", biz_type) snapshot = get_work_order_limit_snapshot(db, production_work_order_id) if payload.settle_work_order: expected_return_weight = to_decimal(snapshot.issued_weight_kg) - to_decimal(snapshot.used_material_weight_kg) _validate_surplus_settle_request( payload, inbound_weight=inbound_weight, expected_return_weight=max(expected_return_weight, Decimal("0")), returned_weight_before=to_decimal(snapshot.returned_material_weight_kg), ) settle_production_work_order = True else: expected_return_weight = to_decimal(snapshot.issued_weight_kg) - to_decimal(snapshot.used_material_weight_kg) _validate_surplus_non_settle_request( inbound_weight=inbound_weight, expected_return_weight=max(expected_return_weight, Decimal("0")), returned_weight_before=to_decimal(snapshot.returned_material_weight_kg), ) if payload.source_lot_id: production_work_order_material = validate_work_order_source_lot( db, production_work_order_id, int(payload.source_lot_id), payload.item_id, ) elif biz_type == "PRODUCTION_SCRAP_IN": if _is_production_ledger_source(payload): production_ledger_id = _require_production_ledger_inbound_source(payload, "生产废料入库") else: production_work_order_id = _require_work_order_inbound_source(payload, "生产废料入库") production_work_order = _get_active_work_order_for_inbound(db, production_work_order_id, "生产废料入库", biz_type) snapshot = get_work_order_limit_snapshot(db, production_work_order_id) expected_scrap_weight = to_decimal(snapshot.standard_scrap_weight_kg) if payload.settle_work_order: _validate_production_scrap_settle_request( payload, inbound_weight=inbound_weight, expected_scrap_weight=expected_scrap_weight, scrap_inbound_before=to_decimal(snapshot.scrap_inbound_weight_kg), ) settle_production_work_order = True else: _validate_production_scrap_non_settle_request( inbound_weight=inbound_weight, expected_scrap_weight=expected_scrap_weight, scrap_inbound_before=to_decimal(snapshot.scrap_inbound_weight_kg), ) elif biz_type == "PRODUCTION_FINISHED": if _is_production_ledger_source(payload): production_ledger_id = _require_production_ledger_inbound_source(payload, "生产入库") production_ledger = db.get(ProductionBatchLedger, production_ledger_id) if not production_ledger: raise HTTPException(status_code=404, detail="生产台账不存在") if int(production_ledger.product_item_id) != int(payload.item_id): raise HTTPException(status_code=400, detail="生产台账产品与入库产品不一致") elif str(payload.source_doc_type or "").upper() == "WORK_ORDER" and payload.source_doc_id: production_work_order_id = _require_work_order_inbound_source(payload, "生产入库") production_work_order = _get_active_work_order_for_inbound(db, production_work_order_id, "生产入库", biz_type) if int(production_work_order.product_item_id) != int(payload.item_id): raise HTTPException(status_code=400, detail="生产工单产品与入库产品不一致") elif biz_type == "REWORK_SCRAP_IN": rework_scrap_work_order = _get_rework_work_order_for_scrap_inbound(db, payload) reported_scrap_qty = _rework_report_scrap_qty(db, rework_scrap_work_order.id) already_scrap_inbound_qty = _rework_scrap_inbound_qty(db, rework_scrap_work_order.id) max_rework_scrap_qty = reported_scrap_qty - already_scrap_inbound_qty if inbound_qty > max_rework_scrap_qty: raise HTTPException(status_code=400, detail=f"返工废料入库数量不能超过当前可入库废品数量 {max_rework_scrap_qty}") if payload.settle_work_order: _settle_rework_work_order_if_ready( db, rework_scrap_work_order, incoming_scrap_qty=inbound_qty, settle_remark=payload.settle_remark, ) if biz_type in _SOURCE_LOT_RETURN_INBOUND_BIZ_TYPES: if not payload.source_lot_id: raise HTTPException(status_code=400, detail="余料入库请选择来源库存批次号") source_lot = db.get(StockLot, payload.source_lot_id) if not source_lot: raise HTTPException(status_code=404, detail="来源库存批次号不存在") if source_lot.item_id != payload.item_id: raise HTTPException(status_code=400, detail="来源库存批次号与入库物料不一致") if source_lot.warehouse_id != payload.warehouse_id: raise HTTPException(status_code=400, detail="来源库存批次号不属于当前仓库") if source_lot.quality_status != "PASS": raise HTTPException(status_code=400, detail="来源库存批次号未质检放行,不能归还余料") lot_location_id = source_lot.location_id if lot_location_id is None: lot_location = get_location_or_default(db, payload.warehouse_id, payload.location_id) lot_location_id = lot_location.id if lot_location else None source_lot.location_id = lot_location_id unit_cost = to_decimal(source_lot.unit_cost, "0.0001") source_lot.remaining_weight_kg = to_decimal(source_lot.remaining_weight_kg) + inbound_weight if source_lot.status == "DEPLETED" and to_decimal(source_lot.remaining_weight_kg) > 0: source_lot.status = "AVAILABLE" source_lot.updated_at = datetime.now() db.add(source_lot) if production_work_order_material is not None: production_work_order_material.returned_weight_kg = ( to_decimal(production_work_order_material.returned_weight_kg) + inbound_weight ) production_work_order_material.updated_at = datetime.now() db.add(production_work_order_material) if settle_production_work_order and production_work_order is not None: _apply_production_work_order_inbound_settlement( production_work_order, biz_type=biz_type, settle_remark=payload.settle_remark, ) db.add(production_work_order) upsert_stock_balance( db, item_id=payload.item_id, warehouse_id=payload.warehouse_id, location_id=lot_location_id, qty_delta=Decimal("0"), weight_delta=inbound_weight, available_qty_delta=Decimal("0"), available_weight_delta=inbound_weight, unit_cost=unit_cost, ) return_remark = payload.remark or _inventory_biz_type_label(biz_type) if payload.provider_name: return_remark = f"来源/提供方:{payload.provider_name};{return_remark}" return_txn = create_inventory_txn( db, txn_type=txn_type, item_id=payload.item_id, warehouse_id=payload.warehouse_id, location_id=lot_location_id, lot_id=source_lot.id, qty_change=Decimal("0"), weight_change=inbound_weight, unit_cost=unit_cost, source_doc_type="生产台账" if production_ledger_id else "WORK_ORDER" if production_work_order_id else source_doc_type, source_doc_id=production_ledger_id or production_work_order_id or source_lot.id, source_line_id=source_lot.id if (production_work_order_id or production_ledger_id) else None, biz_time=datetime.now(), operator_user_id=context.user.id, remark=return_remark, amount_basis="WEIGHT", logistics_waybill_no=logistics_waybill_no, logistics_freight_amount=logistics_freight_amount, logistics_photo_url=logistics_photo_url, ) if production_ledger_id: post_surplus_inbound_to_ledger( db, production_ledger_id=production_ledger_id, surplus_weight_kg=inbound_weight, operator_user_id=context.user.id, source_doc_type="库存流水", source_doc_id=return_txn.id, source_line_id=source_lot.id, biz_time=datetime.now(), remark=return_remark, ) if payload.settle_work_order: lock_production_batch_ledger( db, production_ledger_id=production_ledger_id, operator_user_id=context.user.id, biz_time=datetime.now(), remark=payload.settle_remark or "生产余料入库后该批材料结单", ) use_production_archive = _should_archive_production_inbound( biz_type, production_ledger_id=production_ledger_id, production_work_order_id=production_work_order_id, ) archive_document_type = DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT if use_production_archive else DOCUMENT_TYPE_WAREHOUSE_OPERATION archive_business_id = int(return_txn.id) db.commit() archive_fields = _generate_inventory_archive_fields( db, document_type=archive_document_type, business_id=archive_business_id, created_by=context.user.id, ) row = db.execute(get_stock_lots_query(limit=1).where(StockLot.id == source_lot.id)).mappings().first() if not row: raise HTTPException(status_code=500, detail="余料入库后读取失败") return _stock_lot_read_with_archive(dict(row), **archive_fields) unit_cost = to_decimal(payload.unit_cost, "0.0001") if payload.unit_cost is not None else default_cost if unit_cost is None: # try to look up from latest stock balance latest = db.scalar( select(StockBalance.avg_unit_cost) .where(StockBalance.item_id == payload.item_id, StockBalance.warehouse_id == payload.warehouse_id) .order_by(StockBalance.id.desc()) .limit(1) ) unit_cost = to_decimal(latest, "0.0001") if latest else Decimal("0") location = get_location_or_default(db, payload.warehouse_id, payload.location_id) now = datetime.now() operation_source_doc_type = source_doc_type operation_source_doc_id = payload.source_doc_id or 0 operation_source_line_id = payload.source_line_id if biz_type == "PRODUCTION_FINISHED" and production_ledger_id: operation_source_doc_type = "生产台账" operation_source_doc_id = production_ledger_id operation_source_line_id = None if biz_type == "PRODUCTION_FINISHED" and production_work_order_id: operation_source_doc_type = "WORK_ORDER" operation_source_doc_id = production_work_order_id operation_source_line_id = None if biz_type == "PRODUCTION_SCRAP_IN" and production_ledger_id: operation_source_doc_type = "生产台账" operation_source_doc_id = production_ledger_id operation_source_line_id = None if biz_type == "PRODUCTION_SCRAP_IN" and production_work_order_id: operation_source_doc_type = "WORK_ORDER" operation_source_doc_id = production_work_order_id if biz_type == "REWORK_SCRAP_IN" and rework_scrap_work_order is not None: operation_source_doc_type = "WORK_ORDER" operation_source_doc_id = rework_scrap_work_order.id if warehouse_type == "SEMI" and biz_type in {"WIP_IN", "OUTSOURCING_IN"}: route_operation = _validate_semi_product_operation(db, payload.item_id, payload.source_line_id) operation_source_doc_type = payload.source_doc_type or "PRODUCT_ROUTE_OPERATION" operation_source_doc_id = payload.source_doc_id or route_operation.route_id operation_source_line_id = route_operation.id existing_measure_basis = _get_existing_semi_measure_basis(db, payload.item_id, payload.warehouse_id, route_operation.id) if existing_measure_basis == "MIXED": raise HTTPException(status_code=400, detail="该半成品已有历史混合库存,请先清理历史库存后再入库") if existing_measure_basis and semi_inbound_measure_basis and existing_measure_basis != semi_inbound_measure_basis: raise HTTPException( status_code=400, detail=f"该半成品已有库存{_semi_measure_basis_label(existing_measure_basis)}管理,后续入库必须沿用该口径", ) product_source_lots = _resolve_product_raw_source_lots(db, payload.item_id, payload.source_lot_ids) if is_product_raw_source_lot_trace_inbound else [] product_source_lot_nos = [lot.lot_no for lot in product_source_lots] source_material_sub_batch_no = payload.source_material_sub_batch_no source_material_summary = payload.source_material_summary or payload.provider_name or _inventory_biz_type_label(biz_type) if biz_type == "REWORK_SCRAP_IN" and rework_scrap_work_order is not None: source_material_summary = payload.source_material_summary or f"返工废料入库;工单:{rework_scrap_work_order.work_order_no}" if biz_type == "CUSTOMER_SUPPLIED": source_material_sub_batch_no = None source_material_summary = payload.provider_name or "客料入库" if product_source_lot_nos: source_material_sub_batch_no = "、".join(product_source_lot_nos) source_lot_summary = f"来源库存批次号:{source_material_sub_batch_no}" source_material_summary = f"{source_material_summary};{source_lot_summary}" if source_material_summary else source_lot_summary # build lot_no lot_no = payload.lot_no if warehouse_type == "RAW" and biz_type in {"OPENING", "CUSTOMER_SUPPLIED"}: lot_no = None if not lot_no: if warehouse_type == "RAW" and biz_type in {"OPENING", "CUSTOMER_SUPPLIED"}: lot_no = build_inventory_lot_no(db, item.id) elif biz_type in ("OPENING",): lot_no = _build_opening_lot_no(db, item, warehouse) elif biz_type in ("PRODUCTION_SURPLUS", "OUTSOURCING_SURPLUS"): tag = "SCSY" if biz_type == "PRODUCTION_SURPLUS" else "WWSY" lot_no = _build_surplus_lot_no(db, item, warehouse, tag) elif biz_type in ("PRODUCTION_SCRAP_IN", "OUTSOURCING_SCRAP_IN", "REWORK_SCRAP_IN"): tag = "SCFL" if biz_type == "PRODUCTION_SCRAP_IN" else "FWFL" if biz_type == "REWORK_SCRAP_IN" else "WWFL" lot_no = _build_surplus_lot_no(db, item, warehouse, tag) elif biz_type == "WIP_IN": lot_no = _build_wip_lot_no(db, item, warehouse) elif biz_type == "PRODUCTION_FINISHED": lot_no = _build_finished_lot_no(db, item, warehouse) elif biz_type == "OUTSOURCING_IN": lot_no = _build_surplus_lot_no(db, item, warehouse, "WWJK") else: lot_no = _build_opening_lot_no(db, item, warehouse) existing_lot = db.scalar(select(StockLot).where(StockLot.lot_no == lot_no)) if existing_lot: raise HTTPException(status_code=400, detail=f"批次号 {lot_no} 已存在,请更换后重试") lot = StockLot( lot_no=lot_no, parent_lot_id=None, lot_role=lot_role, material_sub_batch_no=None, item_id=payload.item_id, warehouse_id=payload.warehouse_id, location_id=location.id if location else None, source_doc_type=operation_source_doc_type, source_doc_id=operation_source_doc_id, source_line_id=operation_source_line_id, source_material_lot_id=product_source_lots[0].id if product_source_lots else None, source_material_sub_batch_no=source_material_sub_batch_no, source_material_summary=source_material_summary, logistics_waybill_no=logistics_waybill_no, logistics_freight_amount=logistics_freight_amount, logistics_photo_url=logistics_photo_url, inbound_qty=inbound_qty, inbound_weight_kg=inbound_weight, remaining_qty=inbound_qty, remaining_weight_kg=inbound_weight, locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=unit_cost, production_date=now.date(), expire_date=None, quality_status="PASS", status="AVAILABLE", remark=payload.remark or _inventory_biz_type_label(biz_type), ) db.add(lot) db.flush() if not operation_source_doc_id: lot.source_doc_id = lot.id db.add(lot) balance_qty_delta = Decimal("0") if warehouse_type in WEIGHT_ONLY_WAREHOUSE_TYPES else inbound_qty upsert_stock_balance( db, item_id=payload.item_id, warehouse_id=payload.warehouse_id, location_id=location.id if location else None, qty_delta=balance_qty_delta, weight_delta=inbound_weight, available_qty_delta=balance_qty_delta, available_weight_delta=inbound_weight, unit_cost=unit_cost, ) inbound_txn = create_inventory_txn( db, txn_type=txn_type, item_id=payload.item_id, warehouse_id=payload.warehouse_id, location_id=location.id if location else None, lot_id=lot.id, qty_change=inbound_qty, weight_change=inbound_weight, unit_cost=unit_cost, source_doc_type=operation_source_doc_type, source_doc_id=lot.source_doc_id, source_line_id=operation_source_line_id, biz_time=now, operator_user_id=context.user.id, remark=payload.remark or _inventory_biz_type_label(biz_type), amount_basis="QTY" if (is_aux_quantity_inbound or is_finished_outsourcing_quantity_inbound or is_finished_production_quantity_inbound) else "WEIGHT", logistics_waybill_no=logistics_waybill_no, logistics_freight_amount=logistics_freight_amount, logistics_photo_url=logistics_photo_url, ) if biz_type == "PRODUCTION_SCRAP_IN" and production_ledger_id: post_scrap_inbound_to_ledger( db, production_ledger_id=production_ledger_id, scrap_weight_kg=inbound_weight, operator_user_id=context.user.id, source_doc_type="库存流水", source_doc_id=inbound_txn.id, source_line_id=lot.id, biz_time=now, remark=payload.remark or "生产废料入库联动生产台账", ) if payload.settle_work_order: lock_production_batch_ledger( db, production_ledger_id=production_ledger_id, operator_user_id=context.user.id, biz_time=now, remark=payload.settle_remark or "生产废料入库后该批材料结单", ) if biz_type == "PRODUCTION_FINISHED" and production_ledger_id: post_finished_inbound_to_ledger( db, production_ledger_id=production_ledger_id, finished_qty=inbound_qty, operator_user_id=context.user.id, source_doc_type="库存流水", source_doc_id=inbound_txn.id, source_line_id=lot.id, biz_time=now, remark=payload.remark or "生产入库联动生产台账", ) if payload.settle_work_order: lock_production_batch_ledger( db, production_ledger_id=production_ledger_id, operator_user_id=context.user.id, biz_time=now, remark=payload.settle_remark or "生产入库后该批材料结单", ) if biz_type == "PRODUCTION_FINISHED" and production_work_order is not None: production_work_order.finished_qty = to_decimal(production_work_order.finished_qty) + inbound_qty if payload.settle_work_order: _apply_production_work_order_inbound_settlement( production_work_order, biz_type=biz_type, settle_remark=payload.settle_remark, ) db.add(production_work_order) if settle_production_work_order and production_work_order is not None: _apply_production_work_order_inbound_settlement( production_work_order, biz_type=biz_type, settle_remark=payload.settle_remark, ) db.add(production_work_order) use_production_archive = _should_archive_production_inbound( biz_type, production_ledger_id=production_ledger_id, production_work_order_id=production_work_order_id, ) archive_document_type = DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT if use_production_archive else DOCUMENT_TYPE_WAREHOUSE_OPERATION archive_business_id = int(inbound_txn.id) db.commit() archive_fields = _generate_inventory_archive_fields( db, document_type=archive_document_type, business_id=archive_business_id, created_by=context.user.id, ) row = db.execute(get_stock_lots_query(limit=1).where(StockLot.id == lot.id)).mappings().first() if not row: raise HTTPException(status_code=500, detail="入库后读取失败") return _stock_lot_read_with_archive(dict(row), **archive_fields) # ---------- outbound biz_type -> (txn_type, source_doc_type) ---------- _OUTBOUND_BIZ_MAP = { "PRODUCTION_OUT": ("PRODUCTION_OUT", "PRODUCTION_OUT"), "OUTSOURCING_OUT": ("OUTSOURCING_OUT", "OUTSOURCING_OUT"), "SCRAP_OUT": ("SCRAP_OUT", "SCRAP_OUT"), "SCRAP_SALE_OUT": ("SCRAP_SALE_OUT", "SCRAP_SALE_OUT"), "WIP_OUT": ("WIP_OUT", "WIP_OUT"), "SALES_OUT": ("SALES_OUT", "SALES_OUT"), "PURCHASE_RETURN_OUT": ("PURCHASE_RETURN_OUT", "PURCHASE_RETURN"), } _ALLOWED_OUTBOUND_BY_WAREHOUSE_TYPE = { "RAW": {"OUTSOURCING_OUT", "SCRAP_OUT", "PURCHASE_RETURN_OUT"}, "SEMI": {"WIP_OUT", "OUTSOURCING_OUT"}, "FINISHED": {"SALES_OUT"}, "AUX": {"PRODUCTION_OUT"}, "SCRAP": {"SCRAP_SALE_OUT"}, } _LOGISTICS_REQUIRED_OUTBOUND_BIZ_TYPES = { "OUTSOURCING_OUT", "SCRAP_SALE_OUT", "SALES_OUT", } _SELECTED_SOURCE_LOT_OUTBOUND_BIZ_TYPES = { "OUTSOURCING_OUT", "SCRAP_OUT", "SCRAP_SALE_OUT", } _INVENTORY_BIZ_TYPE_LABELS = { "OPENING": "期初入库", "CUSTOMER_SUPPLIED": "客料入库", "PRODUCTION_SURPLUS": "生产余料入库", "OUTSOURCING_SURPLUS": "委外余料入库", "PRODUCTION_SCRAP_IN": "生产废料入库", "REWORK_SCRAP_IN": "返工废料入库", "OUTSOURCING_SCRAP_IN": "委外废料入库", "WIP_IN": "产中入库", "OUTSOURCING_IN": "委外入库", "PRODUCTION_FINISHED": "生产入库", "PRODUCTION_OUT": "生产出库", "OUTSOURCING_OUT": "委外出库", "SCRAP_OUT": "报废出库", "SCRAP_SALE_OUT": "售卖出库", "WIP_OUT": "产中出库", "SALES_OUT": "销售出库", "PURCHASE_RETURN_OUT": "退货出库", "SPECIAL_IN": "特殊入库", "SPECIAL_OUT": "特殊出库", } def _inventory_biz_type_label(value: str | None) -> str: normalized = str(value or "").upper() return _INVENTORY_BIZ_TYPE_LABELS.get(normalized, "库存业务") def _warehouse_type_label(value: str | None) -> str: return { "RAW": "原材料库", "SEMI": "半成品库", "FINISHED": "成品库", "AUX": "辅料库", "SCRAP": "废料库", "RETURN": "退货库", }.get(str(value or "").upper(), str(value or "未知仓库")) def _validate_warehouse_item(warehouse: Warehouse, item: Item) -> None: warehouse_type = str(warehouse.warehouse_type or "").upper() if warehouse_type in {"RAW", "AUX"} and item.item_type != "RAW_MATERIAL": raise HTTPException(status_code=400, detail=f"{_warehouse_type_label(warehouse_type)}只能选择原材料/辅料类物料") if warehouse_type in {"SEMI", "FINISHED"} and item.item_type not in {"PRODUCT", "FINISHED_GOOD"}: raise HTTPException(status_code=400, detail=f"{_warehouse_type_label(warehouse_type)}只能选择产品类物料") if warehouse_type == "SCRAP" and item.item_type not in {"RAW_MATERIAL", "PRODUCT", "FINISHED_GOOD"}: raise HTTPException(status_code=400, detail="废料库只能选择原材料、辅料、产品或报废品类物料") if warehouse_type == "RETURN" and item.item_type not in {"PRODUCT", "FINISHED_GOOD"}: raise HTTPException(status_code=400, detail="退货库只能选择客户退回且可返工的产品类物料") def _validate_semi_product_operation(db: Session, item_id: int, source_line_id: int | None) -> ProcessRouteOperation: if not source_line_id: raise HTTPException(status_code=400, detail="半成品入出库必须选择半成品") operation = db.get(ProcessRouteOperation, source_line_id) if not operation: raise HTTPException(status_code=404, detail="半成品不存在") route = db.get(ProcessRoute, operation.route_id) if not route or route.product_item_id != item_id: raise HTTPException(status_code=400, detail="半成品与所选产品不一致") if str(route.status or "").upper() != "ACTIVE": raise HTTPException(status_code=400, detail="停用的产品需规不能用于半成品入出库") if str(operation.status or "").upper() != "ACTIVE": raise HTTPException(status_code=400, detail="停用的半成品不能用于半成品入出库") return operation def _semi_measure_basis_label(value: str | None) -> str: return {"PIECE": "按件", "WEIGHT": "按重", "MIXED": "历史混合"}.get(str(value or "").upper(), "未确定") def _semi_measure_basis_from_values(weight: Decimal, qty: Decimal, action_label: str) -> str: if weight <= 0 and qty <= 0: raise HTTPException(status_code=400, detail=f"半成品{action_label}必须选择按件或按重一种口径,{action_label}重量和{action_label}数量至少填写一项") if weight > 0 and qty > 0: raise HTTPException(status_code=400, detail=f"半成品{action_label}只能选择按件或按重一种口径") return "PIECE" if qty > 0 else "WEIGHT" def _derive_semi_measure_basis_from_lots(lots: list[StockLot]) -> str | None: basis_set: set[str] = set() for lot in lots: has_qty = to_decimal(lot.remaining_qty) > 0 has_weight = to_decimal(lot.remaining_weight_kg) > 0 if has_qty and has_weight: return "MIXED" if has_qty: basis_set.add("PIECE") if has_weight: basis_set.add("WEIGHT") if len(basis_set) > 1: return "MIXED" return next(iter(basis_set), None) def _get_existing_semi_measure_basis(db: Session, item_id: int, warehouse_id: int, source_line_id: int) -> str | None: lots = db.scalars( select(StockLot).where( StockLot.item_id == item_id, StockLot.warehouse_id == warehouse_id, StockLot.source_line_id == source_line_id, or_(StockLot.remaining_qty > 0, StockLot.remaining_weight_kg > 0), ) ).all() return _derive_semi_measure_basis_from_lots(lots) def _get_product_material_item_ids(db: Session, product_item_id: int) -> set[int]: bom_ids = db.scalars( select(Bom.id) .where(Bom.product_item_id == product_item_id, Bom.status == "ACTIVE") .order_by(Bom.is_default.desc(), Bom.id.desc()) ).all() if not bom_ids: return set() rows = db.scalars( select(BomItem.material_item_id) .where(BomItem.bom_id.in_(bom_ids)) .order_by(BomItem.seq_no.asc(), BomItem.id.asc()) ).all() return {int(row) for row in rows if row} def _resolve_product_raw_source_lots(db: Session, product_item_id: int, source_lot_ids: list[int]) -> list[StockLot]: ordered_lot_ids = [int(row) for row in source_lot_ids if int(row or 0) > 0] if not ordered_lot_ids: return [] if len(ordered_lot_ids) != len(set(ordered_lot_ids)): raise HTTPException(status_code=400, detail="来源库存批次号不能重复选择") material_item_ids = _get_product_material_item_ids(db, product_item_id) if not material_item_ids: raise HTTPException(status_code=400, detail="该产品未维护默认用料,不能选择来源库存批次号") source_lots = db.scalars(select(StockLot).where(StockLot.id.in_(ordered_lot_ids))).all() source_lot_by_id = {int(lot.id): lot for lot in source_lots} ordered_lots: list[StockLot] = [] for source_lot_id in ordered_lot_ids: source_lot = source_lot_by_id.get(source_lot_id) if not source_lot: raise HTTPException(status_code=404, detail="来源库存批次号不存在") source_warehouse = db.get(Warehouse, source_lot.warehouse_id) if str(source_warehouse.warehouse_type if source_warehouse else "").upper() != "RAW": raise HTTPException(status_code=400, detail=f"来源库存批次号 {source_lot.lot_no} 不属于原材料库") if int(source_lot.item_id) not in material_item_ids: raise HTTPException(status_code=400, detail=f"来源库存批次号 {source_lot.lot_no} 不是该产品需用清单中的默认用料") ordered_lots.append(source_lot) return ordered_lots def _source_material_lot_note(db: Session, lot: StockLot) -> str | None: if lot.source_material_lot_id: source_lot_no = db.scalar(select(StockLot.lot_no).where(StockLot.id == lot.source_material_lot_id)) if source_lot_no: return str(source_lot_no) if lot.source_material_sub_batch_no: return str(lot.source_material_sub_batch_no) source_summary = str(lot.source_material_summary or "") marker = "来源库存批次号:" for part in source_summary.split(";"): if part.strip().startswith(marker): return part.strip()[len(marker):].strip() or None return lot.lot_no @router.post("/outbound", response_model=StockLotRead) def create_warehouse_outbound( payload: WarehouseOutboundCreate, context: AuthContext = Depends(require_authenticated_user), db: Session = Depends(get_db), ) -> StockLotRead: biz_type = (payload.biz_type or "").upper() if biz_type not in _OUTBOUND_BIZ_MAP: raise HTTPException(status_code=400, detail=f"未知的出库业务类型: {biz_type}") item = db.get(Item, payload.item_id) if not item: raise HTTPException(status_code=404, detail="物料不存在") warehouse = db.get(Warehouse, payload.warehouse_id) if not warehouse: raise HTTPException(status_code=404, detail="仓库不存在") ensure_warehouses_unlocked(db, [payload.warehouse_id], "出库") warehouse_type = str(warehouse.warehouse_type or "").upper() if biz_type not in _ALLOWED_OUTBOUND_BY_WAREHOUSE_TYPE.get(warehouse_type, set()): raise HTTPException( status_code=400, detail=f"{_warehouse_type_label(warehouse_type)}不允许执行该出库业务", ) _validate_warehouse_item(warehouse, item) logistics_waybill_no, logistics_freight_amount, logistics_photo_url = normalize_logistics_fields( payload.waybill_no, payload.freight_amount, required=biz_type in _LOGISTICS_REQUIRED_OUTBOUND_BIZ_TYPES, order_photo_url=payload.order_photo_url, ) outbound_weight = to_decimal(payload.outbound_weight_kg) outbound_qty = to_decimal(payload.outbound_qty) is_semi_quantity_weight_outbound = warehouse_type == "SEMI" and biz_type in {"WIP_OUT", "OUTSOURCING_OUT"} is_aux_quantity_outbound = warehouse_type == "AUX" and biz_type == "PRODUCTION_OUT" semi_outbound_measure_basis: str | None = None if outbound_weight < 0 or outbound_qty < 0: raise HTTPException(status_code=400, detail="出库重量和出库数量不能小于0") if is_semi_quantity_weight_outbound: semi_outbound_measure_basis = _semi_measure_basis_from_values(outbound_weight, outbound_qty, "出库") elif is_aux_quantity_outbound: if outbound_qty <= 0: raise HTTPException(status_code=400, detail="辅料生产出库数量必须大于0") if not str(payload.remark or "").strip(): raise HTTPException(status_code=400, detail="辅料生产出库必须填写用途") outbound_weight = Decimal("0") else: if outbound_weight <= 0: raise HTTPException(status_code=400, detail="出库重量必须大于0") outbound_qty = Decimal("0") sale_unit_price = Decimal("0") if biz_type == "SCRAP_SALE_OUT": sale_unit_price = to_decimal(payload.sale_unit_price, "0.0001") if payload.sale_unit_price is not None else Decimal("0") if sale_unit_price <= 0: raise HTTPException(status_code=400, detail="废料售卖出库必须填写大于0的售卖单价") txn_type, source_doc_type = _OUTBOUND_BIZ_MAP[biz_type] now = datetime.now() if warehouse_type == "SEMI" and biz_type in {"WIP_OUT", "OUTSOURCING_OUT"}: _validate_semi_product_operation(db, payload.item_id, payload.target_doc_line_id) selected_source_lines = payload.source_lots or [] deduction_plan: list[tuple[StockLot, Decimal, Decimal]] = [] if selected_source_lines: if biz_type not in _SELECTED_SOURCE_LOT_OUTBOUND_BIZ_TYPES: raise HTTPException(status_code=400, detail="该出库业务不支持指定来源库存批次号") requested_weights_by_lot_id: dict[int, Decimal] = {} for source_line in selected_source_lines: source_lot_id = int(source_line.source_lot_id) if source_lot_id in requested_weights_by_lot_id: raise HTTPException(status_code=400, detail="来源库存批次号不能重复选择") source_weight = to_decimal(source_line.outbound_weight_kg) if source_weight <= 0: raise HTTPException(status_code=400, detail="每个来源库存批次号的出库重量必须大于0") requested_weights_by_lot_id[source_lot_id] = source_weight selected_total_weight = sum(requested_weights_by_lot_id.values(), Decimal("0")) if selected_total_weight != outbound_weight: raise HTTPException(status_code=400, detail="出库总重量必须等于各来源库存批次号出库重量之和") source_lots = db.scalars( select(StockLot).where(StockLot.id.in_(requested_weights_by_lot_id.keys())) ).all() source_lot_by_id = {lot.id: lot for lot in source_lots} for source_lot_id, selected_weight in requested_weights_by_lot_id.items(): source_lot = source_lot_by_id.get(source_lot_id) if not source_lot: raise HTTPException(status_code=404, detail="来源库存批次号不存在") if source_lot.item_id != payload.item_id or source_lot.warehouse_id != payload.warehouse_id: raise HTTPException(status_code=400, detail=f"来源库存批次号 {source_lot.lot_no} 与本次出库物料或仓库不一致") if payload.location_id and source_lot.location_id != payload.location_id: raise HTTPException(status_code=400, detail=f"来源库存批次号 {source_lot.lot_no} 不属于所选库位") if source_lot.quality_status != "PASS": raise HTTPException(status_code=400, detail=f"来源库存批次号 {source_lot.lot_no} 未质检放行") if source_lot.status != "AVAILABLE": raise HTTPException(status_code=400, detail=f"来源库存批次号 {source_lot.lot_no} 当前状态不可用") usable_weight = to_decimal(source_lot.remaining_weight_kg) - to_decimal(source_lot.locked_weight_kg) if usable_weight < selected_weight: raise HTTPException( status_code=400, detail=f"来源库存批次号 {source_lot.lot_no} 可用重量不足,无法完成本次出库", ) deduction_plan.append((source_lot, selected_weight, Decimal("0"))) else: lot_filters = [ StockLot.item_id == payload.item_id, StockLot.warehouse_id == payload.warehouse_id, StockLot.status == "AVAILABLE", or_(StockLot.remaining_weight_kg > 0, StockLot.remaining_qty > 0) if is_semi_quantity_weight_outbound else (StockLot.remaining_qty > 0 if is_aux_quantity_outbound else StockLot.remaining_weight_kg > 0), ] if payload.location_id: lot_filters.append(StockLot.location_id == payload.location_id) if warehouse_type == "SEMI" and payload.target_doc_line_id: lot_filters.append(StockLot.source_line_id == payload.target_doc_line_id) available_lots = db.scalars( select(StockLot) .where(*lot_filters) .order_by(StockLot.created_at.asc(), StockLot.id.asc()) ).all() if not available_lots: raise HTTPException(status_code=400, detail="该物料在该仓库无可用库存批次") existing_measure_basis = _derive_semi_measure_basis_from_lots(available_lots) if is_semi_quantity_weight_outbound else None if existing_measure_basis == "MIXED": raise HTTPException(status_code=400, detail="该半成品已有历史混合库存,请先清理历史库存后再出库") if existing_measure_basis and semi_outbound_measure_basis and existing_measure_basis != semi_outbound_measure_basis: required_field = "出库数量" if existing_measure_basis == "PIECE" else "出库重量" raise HTTPException( status_code=400, detail=f"该半成品现有库存{_semi_measure_basis_label(existing_measure_basis)}管理,请填写{required_field}", ) total_available_weight = sum((to_decimal(lot.remaining_weight_kg) for lot in available_lots), Decimal("0")) total_available_qty = sum((to_decimal(lot.remaining_qty) for lot in available_lots), Decimal("0")) if outbound_weight > 0 and total_available_weight < outbound_weight: raise HTTPException(status_code=400, detail="该物料可用重量不足,无法完成本次出库") if (is_semi_quantity_weight_outbound or is_aux_quantity_outbound) and outbound_qty > 0 and total_available_qty < outbound_qty: shortage_label = "该辅料" if is_aux_quantity_outbound else "该产品工序" raise HTTPException(status_code=400, detail=f"{shortage_label}可用数量不足,无法完成本次{_inventory_biz_type_label(biz_type)}") remaining_weight = outbound_weight remaining_qty = outbound_qty for available_lot in available_lots: if remaining_weight <= 0 and remaining_qty <= 0: break available_weight = to_decimal(available_lot.remaining_weight_kg) available_qty = to_decimal(available_lot.remaining_qty) if is_aux_quantity_outbound: actual_qty = min(remaining_qty, available_qty) actual_deduction = Decimal("0") elif is_semi_quantity_weight_outbound and outbound_qty > 0: actual_qty = min(remaining_qty, available_qty) if actual_qty <= 0: continue if outbound_weight > 0: actual_deduction = remaining_weight if remaining_qty == actual_qty else (outbound_weight * actual_qty / outbound_qty) actual_deduction = min(actual_deduction, available_weight, remaining_weight) else: ratio = actual_qty / available_qty if available_qty > 0 else Decimal("0") actual_deduction = min(available_weight * ratio, available_weight) else: actual_deduction = min(remaining_weight, available_weight) if is_semi_quantity_weight_outbound: ratio = actual_deduction / available_weight if available_weight > 0 else Decimal("0") actual_qty = min(available_qty * ratio, available_qty) else: actual_qty = Decimal("0") if actual_deduction <= 0 and actual_qty <= 0: continue deduction_plan.append((available_lot, actual_deduction, actual_qty)) remaining_weight -= actual_deduction remaining_qty -= actual_qty first_lot_id: int | None = None first_inventory_txn_id: int | None = None for available_lot, actual_deduction, actual_qty in deduction_plan: available_weight = to_decimal(available_lot.remaining_weight_kg) available_qty = to_decimal(available_lot.remaining_qty) unit_cost = to_decimal(available_lot.unit_cost, "0.0001") txn_unit_cost = sale_unit_price if biz_type == "SCRAP_SALE_OUT" else unit_cost available_lot.remaining_weight_kg = available_weight - actual_deduction available_lot.remaining_qty = available_qty - actual_qty if (is_semi_quantity_weight_outbound or is_aux_quantity_outbound) else Decimal("0") if available_lot.remaining_weight_kg <= 0 and available_lot.remaining_qty <= 0: available_lot.status = "DEPLETED" db.add(available_lot) first_lot_id = first_lot_id or available_lot.id upsert_stock_balance( db, item_id=payload.item_id, warehouse_id=payload.warehouse_id, location_id=available_lot.location_id, qty_delta=-actual_qty, weight_delta=-actual_deduction, available_qty_delta=-actual_qty, available_weight_delta=-actual_deduction, unit_cost=unit_cost, ) if is_aux_quantity_outbound: remark = f"用途:{str(payload.remark or '').strip()}" else: remark = payload.remark or _inventory_biz_type_label(biz_type) if biz_type == "SCRAP_SALE_OUT": sale_parts = [] if payload.buyer_name: sale_parts.append(f"购买方 {payload.buyer_name}") if payload.sale_remark: sale_parts.append(payload.sale_remark) if sale_parts: remark = f"{remark};{';'.join(sale_parts)}" if biz_type == "OUTSOURCING_OUT" and payload.outsourcing_party_name: remark = f"{remark};委外方:{payload.outsourcing_party_name}" if biz_type == "SCRAP_SALE_OUT": source_lot_note = _source_material_lot_note(db, available_lot) else: source_lot_note = available_lot.lot_no if (selected_source_lines or is_semi_quantity_weight_outbound) else payload.target_material_sub_batch_no if source_lot_note: remark = f"{remark};来源库存批次号 {source_lot_note}" outbound_txn = create_inventory_txn( db, txn_type=txn_type, item_id=payload.item_id, warehouse_id=payload.warehouse_id, location_id=available_lot.location_id, lot_id=available_lot.id, qty_change=-actual_qty, weight_change=-actual_deduction, unit_cost=txn_unit_cost, source_doc_type=source_doc_type, source_doc_id=payload.target_doc_id or 0, source_line_id=payload.target_doc_line_id, biz_time=now, operator_user_id=context.user.id, remark=remark, amount_basis="QTY" if is_aux_quantity_outbound else "WEIGHT", logistics_waybill_no=logistics_waybill_no, logistics_freight_amount=logistics_freight_amount, logistics_photo_url=logistics_photo_url, ) first_inventory_txn_id = first_inventory_txn_id or int(outbound_txn.id) db.commit() archive_fields = _generate_inventory_archive_fields( db, document_type=DOCUMENT_TYPE_WAREHOUSE_OPERATION, business_id=first_inventory_txn_id, created_by=context.user.id, ) row = db.execute(get_stock_lots_query(limit=1).where(StockLot.id == first_lot_id)).mappings().first() if not row: raise HTTPException(status_code=500, detail="出库后读取失败") return _stock_lot_read_with_archive(dict(row), **archive_fields) @router.post("/raw-material-return-outbound", response_model=PurchaseReturnRead) def create_raw_material_return_outbound( payload: RawMaterialReturnOutboundCreate, context: AuthContext = Depends(require_authenticated_user), db: Session = Depends(get_db), ) -> PurchaseReturnRead: if not payload.items: raise HTTPException(status_code=400, detail="退货出库明细不能为空") warehouse = db.get(Warehouse, payload.warehouse_id) if not warehouse: raise HTTPException(status_code=404, detail="仓库不存在") if str(warehouse.warehouse_type or "").upper() != "RAW": raise HTTPException(status_code=400, detail="退货出库只能从原材料库发起") ensure_warehouses_unlocked(db, [payload.warehouse_id], "退货出库") logistics_waybill_no, logistics_freight_amount, logistics_photo_url = normalize_logistics_fields( payload.waybill_no, payload.freight_amount, required=True, freight_required=False, order_photo_url=payload.order_photo_url, ) seen_lot_ids: set[int] = set() purchase_order_id: int | None = None prepared_lines: list[dict[str, object]] = [] for line_no, item_payload in enumerate(payload.items, start=1): if item_payload.lot_id in seen_lot_ids: raise HTTPException(status_code=400, detail="同一张退货出库单不能重复选择同一库存批次") seen_lot_ids.add(item_payload.lot_id) return_weight = to_decimal(item_payload.return_weight_kg) if return_weight <= 0: raise HTTPException(status_code=400, detail="退货重量必须大于0") lot = db.get(StockLot, item_payload.lot_id) if not lot: raise HTTPException(status_code=404, detail="库存批次不存在") if lot.warehouse_id != payload.warehouse_id: raise HTTPException(status_code=400, detail="库存批次不属于当前原材料库") if lot.status != "AVAILABLE" or lot.quality_status != "PASS": raise HTTPException(status_code=400, detail="只能选择可用且质检合格的原材料批次退货") purchase_order_item = db.get(PurchaseOrderItem, item_payload.purchase_order_item_id) if not purchase_order_item: raise HTTPException(status_code=404, detail="采购订单明细不存在") if purchase_order_item.material_item_id != lot.item_id: raise HTTPException(status_code=400, detail="采购订单明细物料与库存批次物料不一致") if purchase_order_id is None: purchase_order_id = purchase_order_item.purchase_order_id elif purchase_order_id != purchase_order_item.purchase_order_id: raise HTTPException(status_code=400, detail="一张退货出库单只能对应一个采购订单,请分开登记") usable_lot_weight = to_decimal(lot.remaining_weight_kg) - to_decimal(lot.locked_weight_kg) if return_weight > usable_lot_weight: raise HTTPException(status_code=400, detail="退货重量不能超过批次可用重量") matched_link = next( ( link for link in get_stock_lot_purchase_links(db, lot.id) if int(link["purchase_order_item_id"]) == int(item_payload.purchase_order_item_id) ), None, ) if not matched_link: raise HTTPException(status_code=400, detail="该库存批次未关联所选采购订单明细") if return_weight > to_decimal(matched_link["returnable_weight_kg"]): raise HTTPException(status_code=400, detail="退货重量不能超过采购入库可退重量") prepared_lines.append( { "line_no": line_no, "payload": item_payload, "lot": lot, "lot_id": lot.id, "purchase_order_item": purchase_order_item, "purchase_order_item_id": purchase_order_item.id, "return_weight": return_weight, "unit_cost": to_decimal(matched_link["unit_cost"], "0.0001"), } ) if purchase_order_id is None: raise HTTPException(status_code=400, detail="退货出库明细不能为空") _lock_purchase_order_items_for_return( db, {int(prepared["purchase_order_item_id"]) for prepared in prepared_lines}, ) _recheck_raw_material_return_purchase_line_availability(db, prepared_lines) now = datetime.now() purchase_return = PurchaseReturn( return_no=_build_purchase_return_no(db), purchase_order_id=purchase_order_id, warehouse_id=payload.warehouse_id, return_date=payload.return_date or now, operator_user_id=context.user.id, logistics_waybill_no=logistics_waybill_no, logistics_freight_amount=logistics_freight_amount, logistics_photo_url=logistics_photo_url, reason=payload.reason, status="CONFIRMED", remark=payload.remark, created_at=now, updated_at=now, ) db.add(purchase_return) db.flush() first_inventory_txn_id: int | None = None for prepared in prepared_lines: item_payload = prepared["payload"] lot = prepared["lot"] purchase_order_item = prepared["purchase_order_item"] return_weight = prepared["return_weight"] unit_cost = prepared["unit_cost"] amount = to_decimal(return_weight * unit_cost, "0.01") purchase_return_item = PurchaseReturnItem( purchase_return_id=purchase_return.id, line_no=prepared["line_no"], purchase_order_item_id=purchase_order_item.id, material_item_id=lot.item_id, lot_id=lot.id, return_weight_kg=return_weight, unit_cost=unit_cost, amount=amount, reason=item_payload.reason, status="CONFIRMED", remark=item_payload.remark, created_at=now, updated_at=now, ) db.add(purchase_return_item) db.flush() deduction_result = db.execute( update(StockLot) .where( StockLot.id == lot.id, StockLot.remaining_weight_kg - StockLot.locked_weight_kg >= return_weight, StockLot.status == "AVAILABLE", StockLot.quality_status == "PASS", ) .values( remaining_weight_kg=StockLot.remaining_weight_kg - return_weight, remaining_qty=Decimal("0"), status=case( (StockLot.remaining_weight_kg - return_weight <= 0, "DEPLETED"), else_="AVAILABLE", ), updated_at=now, ) .execution_options(synchronize_session=False) ) if deduction_result.rowcount != 1: db.rollback() raise HTTPException(status_code=400, detail="退货重量不能超过批次可用重量") db.refresh(lot) upsert_stock_balance( db, item_id=lot.item_id, warehouse_id=payload.warehouse_id, location_id=lot.location_id, qty_delta=Decimal("0"), weight_delta=-return_weight, available_qty_delta=Decimal("0"), available_weight_delta=-return_weight, unit_cost=unit_cost, ) txn = create_inventory_txn( db, txn_type="PURCHASE_RETURN_OUT", item_id=lot.item_id, warehouse_id=payload.warehouse_id, location_id=lot.location_id, lot_id=lot.id, qty_change=Decimal("0"), weight_change=-return_weight, unit_cost=unit_cost, source_doc_type="PURCHASE_RETURN", source_doc_id=purchase_return.id, source_line_id=purchase_return_item.id, biz_time=purchase_return.return_date, operator_user_id=context.user.id, remark=item_payload.remark or item_payload.reason or payload.remark, amount_basis="WEIGHT", logistics_waybill_no=logistics_waybill_no, logistics_freight_amount=logistics_freight_amount, logistics_photo_url=logistics_photo_url, ) if first_inventory_txn_id is None: first_inventory_txn_id = int(txn.id) db.commit() archive_fields = _generate_inventory_archive_fields( db, document_type=DOCUMENT_TYPE_WAREHOUSE_OPERATION, business_id=first_inventory_txn_id, created_by=context.user.id, ) result = _purchase_return_read(db, purchase_return.id) result.archive_status = archive_fields["archive_status"] result.archive_business_id = archive_fields["archive_business_id"] result.archive_document_type = archive_fields["archive_document_type"] result.archive_error_message = archive_fields["archive_error_message"] return result