from datetime import date, datetime, timedelta from decimal import Decimal, ROUND_FLOOR import re from uuid import uuid4 from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import func, or_, select from sqlalchemy.orm import Session, aliased from app.db.session import get_db from app.models.document_archive import DocumentArchive from app.models.master_data import BomItem, Item, Warehouse from app.models.miniapp import MiniAppPersonnel, MiniAppProductionReport, MiniAppProductionReportItem from app.models.operations import ( CompletionReceipt, CompletionReceiptItem, Equipment, InventoryTxn, OperationReport, ProcessRouteOperation, ProductionBatchLedger, ProductionBatchLedgerTxn, ReturnDisposition, ReturnItem, ScrapRecord, StockLot, WorkOrder, WorkOrderMaterial, WorkOrderMaterialIssue, WorkOrderOperation, ) from app.models.org import Department, Employee from app.models.sales import SalesOrderItem from app.schemas.operations import ( CompletionReceiptCreate, CompletionReceiptItemRead, CompletionReceiptRead, MiniAppOperationReportRead, MiniAppOperationReportSyncRead, MiniAppOperationReportSyncStateRead, MiniAppScrapRecordCreate, OperationReportCreate, OperationReportRead, ProductionBatchLedgerRead, ProductionBatchLedgerInboundCreate, ProductionBatchLedgerInboundPreviewRead, ProductionBatchLedgerInboundRead, ProductionWorkOrderInboundCreate, ProductionWorkOrderInboundPreviewRead, ProductionWorkOrderInboundRead, ScrapRecordCreate, ScrapRecordRead, WorkOrderCreate, WorkOrderLedgerRead, WorkOrderMaterialIssueRead, WorkOrderMaterialRead, WorkOrderOperationRead, WorkOrderRead, ) from app.schemas.document_archives import DocumentArchiveGenerateResult from app.services.auth import AuthContext, require_authenticated_user from app.services.auth import next_employee_code from app.services.document_archives import ( ARCHIVE_STATUS_MISSING, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, FILE_FORMAT_PDF, generate_document_archive, production_document_batch_no_from_text, ) from app.services.operations import ( build_raw_lot_product_work_order_no, build_yearly_product_work_order_no, create_inventory_txn, create_work_order_materials_and_operations, clamp_non_negative, get_completion_receipt_items_query, get_completion_receipts_query, get_default_bom, get_default_route, get_location_or_default, get_operation_reports_query, get_scrap_records_query, get_work_order_materials_query, get_work_order_material_issues_query, get_work_order_operations_query, get_work_orders_query, issue_selected_stock_lots, sync_recent_work_order_reference_qty, sync_work_order_reference_qty, sync_work_order_status, to_decimal, upsert_stock_balance, ) from app.services.system_permissions import ensure_employee_has_permission from app.services.system_config import ( get_miniapp_operation_report_last_sync, lock_miniapp_operation_report_last_sync, upsert_miniapp_operation_report_last_sync, ) from app.services.production_batch_ledger import ( calculate_production_batch_ledger_snapshot, lock_production_batch_ledger, post_finished_inbound_to_ledger, post_scrap_inbound_to_ledger, post_surplus_inbound_to_ledger, record_production_issue_to_ledger, ) from app.services.production_work_order_ledger import ( INBOUND_TOLERANCE_MULTIPLIER, get_work_order_ledger_rows, get_work_order_limit_snapshot, validate_inbound_with_tolerance, ) from app.services.production_work_order_inbound import ( create_production_work_order_inbound, preview_production_work_order_inbound, ) from app.services.stocktake import ensure_warehouses_unlocked router = APIRouter(dependencies=[Depends(require_authenticated_user)]) _MINIAPP_WORK_ORDER_SHORT_CODE_RE = re.compile(r"^JH(\d{4})$") PRODUCTION_LEDGER_INBOUND_TXN_TYPES = {"成品入库", "生产余料入库", "生产废料入库"} def _archive_fields_from_result( result: DocumentArchiveGenerateResult | None, *, document_type: str, business_id: int | None, ) -> dict[str, object]: if result is None: return { "archive_status": "未生成", "archive_business_id": business_id, "archive_document_type": document_type if business_id else None, "archive_error_message": None, } return { "archive_status": result.archive_status, "archive_business_id": business_id, "archive_document_type": document_type, "archive_error_message": result.archive_error_message, } def _failed_archive_result(document_type: str, business_id: int, document_no: str, exc: Exception) -> DocumentArchiveGenerateResult: _ = exc return DocumentArchiveGenerateResult( business_id=business_id, document_type=document_type, document_no=document_no, archive_status="归档失败", archive_version=None, archive_error_message="归档失败:PDF归档服务异常,请稍后重新生成", ) def _production_document_batch_no(now: datetime) -> str: return f"生产结算{now.strftime('%Y%m%d%H%M%S')}{uuid4().int % 1_000_000:06d}" def _with_document_batch_no(remark: str, batch_no: str | None) -> str: if not batch_no: return remark return f"{remark};单据批次号:{batch_no}" def _latest_archive_map(db: Session, document_type: str, business_ids: list[int]) -> dict[int, DocumentArchive]: if not business_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_(business_ids), ) .group_by(DocumentArchive.business_id) .subquery() ) latest_id_subquery = ( select( DocumentArchive.business_id.label("business_id"), func.max(DocumentArchive.id).label("archive_id"), ) .join( latest_version_subquery, (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, ) .group_by(DocumentArchive.business_id) .subquery() ) rows = db.scalars( select(DocumentArchive).join(latest_id_subquery, latest_id_subquery.c.archive_id == DocumentArchive.id) ).all() return {int(row.business_id): row for row in rows} def _code_token(value: str | None, fallback: str, max_length: int = 16) -> str: token = "".join(ch for ch in str(value or "").upper() if ch.isalnum()) return (token or fallback)[-max_length:] def _next_doc_no(db: Session, model, column_name: str, prefix: str) -> str: column = getattr(model, column_name) count = db.scalar(select(func.count(model.id)).where(column.like(f"{prefix}-%"))) or 0 return f"{prefix}-{count + 1:03d}" def _next_optional_sequence_doc_no(db: Session, model, column_name: str, prefix: str, max_length: int = 80) -> str: normalized_prefix = " ".join(str(prefix or "").split()).strip("- ") or "未命名" sequence_prefix = normalized_prefix[: max(max_length - 4, 1)] column = getattr(model, column_name) if not db.scalar(select(model.id).where(column == sequence_prefix).limit(1)): return sequence_prefix count = db.scalar(select(func.count(model.id)).where(column.like(f"{sequence_prefix}-%"))) or 0 return f"{sequence_prefix}-{count + 1:03d}" def _next_yearly_sequence_work_order_no(db: Session, product: Item, now: datetime | None = None) -> str: current_time = now or datetime.now() year_part = current_time.strftime("%Y") existing_nos = db.scalars(select(WorkOrder.work_order_no).where(WorkOrder.work_order_no.like(f"{year_part}____-%"))).all() used_sequences = [ int(work_order_no[4:8]) for work_order_no in existing_nos if len(work_order_no) > 8 and work_order_no[:4] == year_part and work_order_no[4:8].isdigit() and work_order_no[8] == "-" ] next_sequence = max(used_sequences, default=0) + 1 product_name = " ".join(str(product.item_name or product.item_code or f"产品{product.id}").split()).strip("- ") or f"产品{product.id}" max_product_name_length = max(80 - len(year_part) - 4 - 1, 1) return f"{year_part}{next_sequence:04d}-{product_name[:max_product_name_length]}" def _build_work_order_no(db: Session, product: Item) -> str: return build_yearly_product_work_order_no(db, product) def _miniapp_work_order_short_code_sequence(value: str | None) -> int | None: match = _MINIAPP_WORK_ORDER_SHORT_CODE_RE.match(str(value or "").strip()) if not match: return None return int(match.group(1)) def _is_miniapp_work_order_short_code(value: str | None) -> bool: return _miniapp_work_order_short_code_sequence(value) is not None def _miniapp_syncable_work_order_status_filter(): return func.upper(func.coalesce(WorkOrder.status, "")).notin_(("SETTLED", "CANCELED", "CANCELLED")) def _resolve_work_order_by_miniapp_short_code(db: Session, short_code: str, row: dict) -> WorkOrder | None: sequence = _miniapp_work_order_short_code_sequence(short_code) if sequence is None: return None product_name = str(row.get("product_name") or "").strip() project_no = str(row.get("project_no") or "").strip() product_filters = [] if product_name: product_filters.append(Item.item_name == product_name) if project_no: product_filters.append(Item.item_code == project_no) if not product_filters: return None year_part = datetime.now().strftime("%Y") work_order_prefix = f"{year_part}{sequence:04d}-" candidates = db.scalars( select(WorkOrder) .join(Item, Item.id == WorkOrder.product_item_id) .where( WorkOrder.work_order_no.like(f"{work_order_prefix}%"), _miniapp_syncable_work_order_status_filter(), or_(*product_filters), ) .order_by(WorkOrder.id.desc()) ).all() if len(candidates) == 1: return candidates[0] return None def _build_scrap_no(db: Session, work_order: WorkOrder, disposal_type: str) -> str: date_part = date.today().strftime("%Y%m%d") type_part = "RWK" if disposal_type == "REWORK" else "SCRAP" work_order_part = _code_token(work_order.work_order_no, f"WO{work_order.id:04d}", 18) return _next_doc_no(db, ScrapRecord, "scrap_no", f"DSP-{type_part}-{date_part}-{work_order_part}") def _build_completion_receipt_no(db: Session, work_order: WorkOrder) -> str: date_part = date.today().strftime("%Y%m%d") work_order_part = _code_token(work_order.work_order_no, f"WO{work_order.id:04d}", 18) return _next_doc_no(db, CompletionReceipt, "receipt_no", f"FGI-NB-{date_part}-{work_order_part}") def _build_operation_report_no(db: Session, work_order: WorkOrder, operation: WorkOrderOperation, employee: Employee) -> str: date_part = date.today().strftime("%Y%m%d") work_order_part = _code_token(work_order.work_order_no, f"WO{work_order.id:04d}", 12) operation_part = f"OP{int(operation.seq_no or 0):02d}" employee_part = _code_token(employee.employee_code or employee.employee_name, f"EMP{employee.id:04d}", 8) return _next_doc_no(db, OperationReport, "report_no", f"RPT-NB-{date_part}-{work_order_part}-{operation_part}-{employee_part}") def _production_ledger_txn_read( txn: ProductionBatchLedgerTxn, archive: DocumentArchive | None = None, archive_business_id: int | None = None, ) -> dict: return { "production_ledger_txn_id": txn.id, "production_ledger_id": txn.production_ledger_id, "txn_type": txn.txn_type, "qty_delta": float(to_decimal(txn.qty_delta)), "weight_delta_kg": float(to_decimal(txn.weight_delta_kg)), "outside_weight_after_kg": float(to_decimal(txn.outside_weight_after_kg)), "source_doc_type": txn.source_doc_type, "source_doc_id": txn.source_doc_id, "source_line_id": txn.source_line_id, "biz_time": txn.biz_time, "operator_user_id": txn.operator_user_id, "remark": txn.remark, "archive_status": archive.status if archive else (ARCHIVE_STATUS_MISSING if archive_business_id else None), "archive_business_id": archive_business_id, "archive_document_type": DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT if archive_business_id else None, "archive_error_message": archive.error_message if archive else None, } def _build_finished_lot_no(db: Session, work_order: WorkOrder, product: Item, receipt_time: datetime, line_no: int) -> str: date_part = receipt_time.strftime("%Y%m%d") product_part = _code_token(product.item_code or product.item_name, f"FG{product.id:04d}", 12) work_order_part = _code_token(work_order.work_order_no, f"WO{work_order.id:04d}", 10) base_lot_no = f"FGL-NB-{date_part}-{product_part}-{work_order_part}-L{line_no:02d}" candidate = base_lot_no suffix = 1 while db.scalar(select(func.count(StockLot.id)).where(StockLot.lot_no == candidate)): suffix += 1 candidate = f"{base_lot_no}-{suffix:02d}" return candidate def _get_active_warehouse(db: Session, warehouse_type: str, operation_name: str) -> Warehouse: warehouse = db.scalar( select(Warehouse) .where(Warehouse.warehouse_type == warehouse_type, Warehouse.status == "ACTIVE") .order_by(Warehouse.id) .limit(1) ) if not warehouse: warehouse_name = {"FINISHED": "成品库", "SCRAP": "废料库", "RAW": "原材料库"}.get(warehouse_type, "目标仓库") raise HTTPException(status_code=400, detail=f"{operation_name}缺少启用的{warehouse_name}") return warehouse def _build_production_ledger_lot_no(db: Session, prefix: str, ledger: ProductionBatchLedger, item: Item, biz_time: datetime) -> str: item_part = _code_token(item.item_code or item.item_name, f"ITEM{item.id:04d}", 12) source_part = _code_token(ledger.material_lot_no, f"LOT{ledger.material_lot_id:04d}", 10) return _next_doc_no(db, StockLot, "lot_no", f"{prefix}-NB-{biz_time.strftime('%Y%m%d')}-{item_part}-{source_part}") def _issue_raw_lot_to_production_ledger( db: Session, *, lot: StockLot, material_item_id: int, issued_weight: Decimal, production_ledger_id: int, operator_user_id: int | None, biz_time: datetime, ) -> InventoryTxn: if issued_weight <= 0: raise HTTPException(status_code=400, detail="出库重量必须大于0") if not lot or int(lot.item_id) != int(material_item_id): raise HTTPException(status_code=400, detail="库存批次不存在或物料不匹配") warehouse = db.get(Warehouse, lot.warehouse_id) if not warehouse or warehouse.warehouse_type != "RAW": raise HTTPException(status_code=400, detail="库存批次不属于原材料库") usable_weight = clamp_non_negative(to_decimal(lot.remaining_weight_kg) - to_decimal(lot.locked_weight_kg)) if lot.quality_status != "PASS" or lot.status != "AVAILABLE" or usable_weight <= 0: raise HTTPException(status_code=400, detail=f"库存批次 {lot.lot_no} 不可用") if issued_weight > usable_weight: raise HTTPException(status_code=400, detail=f"库存批次 {lot.lot_no} 剩余重量不足") lot.remaining_qty = Decimal("0") lot.remaining_weight_kg = clamp_non_negative(to_decimal(lot.remaining_weight_kg) - issued_weight) if to_decimal(lot.remaining_weight_kg) <= 0: lot.status = "DEPLETED" db.add(lot) unit_cost = to_decimal(lot.unit_cost, "0.0001") upsert_stock_balance( db, item_id=material_item_id, warehouse_id=lot.warehouse_id, location_id=lot.location_id, qty_delta=Decimal("0"), weight_delta=-issued_weight, available_qty_delta=Decimal("0"), available_weight_delta=-issued_weight, unit_cost=unit_cost, ) txn = create_inventory_txn( db, txn_type="MATERIAL_ISSUE", item_id=material_item_id, warehouse_id=lot.warehouse_id, location_id=lot.location_id, lot_id=lot.id, qty_change=Decimal("0"), weight_change=-issued_weight, unit_cost=unit_cost, source_doc_type="生产台账", source_doc_id=production_ledger_id, source_line_id=None, biz_time=biz_time, operator_user_id=operator_user_id, remark=f"生产出库:库存批次号 {lot.lot_no}", amount_basis="WEIGHT", ) db.add(txn) db.flush() return txn def _link_latest_issue_ledger_txn_to_inventory_txn( db: Session, *, production_ledger_id: int, inventory_txn: InventoryTxn, ) -> None: ledger_txn = db.scalar( select(ProductionBatchLedgerTxn) .where( ProductionBatchLedgerTxn.production_ledger_id == production_ledger_id, ProductionBatchLedgerTxn.txn_type == "生产出库", ) .order_by(ProductionBatchLedgerTxn.id.desc()) .limit(1) ) if not ledger_txn: return ledger_txn.source_doc_type = "库存流水" ledger_txn.source_doc_id = inventory_txn.id ledger_txn.source_line_id = inventory_txn.id db.add(ledger_txn) def _build_production_ledger_work_order_compat_read( *, production_ledger_id: int, material_lot_no: str, product: Item, route, bom, planned_qty: Decimal, payload: WorkOrderCreate, biz_time: datetime, status: str, operation_count: int, ) -> WorkOrderRead: return WorkOrderRead( work_order_id=production_ledger_id, work_order_no=material_lot_no, work_order_type="正常生产", source_sales_order_item_id=payload.source_sales_order_item_id, source_order_no=None, product_item_id=product.id, product_code=product.item_code, product_name=product.item_name, product_version_no=None, route_id=route.id, route_name=route.route_name, bom_id=bom.id, planned_qty=float(planned_qty), released_qty=float(planned_qty), finished_qty=0, scrap_qty=0, planned_start_time=payload.planned_start_time or biz_time, planned_end_time=payload.planned_end_time, actual_start_time=None, actual_end_time=None, priority_level=payload.priority_level or 3, status=status, remark=payload.remark, operation_count=operation_count, abnormal_operation_count=0, reported_good_qty=0, received_qty=0, max_receivable_qty=float(planned_qty), all_operations_done=False, ) def _calculate_actual_cycle_seconds(start_time: datetime, end_time: datetime, report_qty: Decimal) -> float | None: if report_qty <= 0 or end_time <= start_time: return None return round((end_time - start_time).total_seconds() / float(report_qty), 2) def _build_operation_abnormal_reason(row: dict, actual_cycle_seconds: float | None) -> str | None: reasons: list[str] = [] report_qty = to_decimal(row.get("report_qty")) scrap_qty = to_decimal(row.get("scrap_qty")) allowed_scrap_rate = to_decimal(row.get("allowed_scrap_rate"), "0.0001") ref_cycle_seconds = to_decimal(row.get("ref_cycle_seconds"), "0.01") if report_qty > 0 and allowed_scrap_rate >= 0: actual_scrap_rate = scrap_qty / report_qty if report_qty > 0 else Decimal("0") if actual_scrap_rate > allowed_scrap_rate: reasons.append( f"报废率超标 {round(float(actual_scrap_rate * 100), 2)}% > {round(float(allowed_scrap_rate * 100), 2)}%" ) if actual_cycle_seconds is not None and ref_cycle_seconds > 0 and Decimal(str(actual_cycle_seconds)) > ref_cycle_seconds: reasons.append(f"实际节拍 {actual_cycle_seconds}s/件 慢于参考节拍 {float(ref_cycle_seconds):.2f}s/件") scrap_reason = row.get("scrap_reason") if scrap_reason: reasons.append(f"异常说明:{scrap_reason}") if not reasons: return None return ";".join(reasons) def _build_operation_report_read(row: dict) -> OperationReportRead: payload = dict(row) actual_cycle_seconds = _calculate_actual_cycle_seconds( payload["start_time"], payload["end_time"], to_decimal(payload.get("report_qty")), ) payload["ref_cycle_seconds"] = float(payload.get("ref_cycle_seconds") or 0) payload["actual_cycle_seconds"] = actual_cycle_seconds payload["abnormal_reason"] = _build_operation_abnormal_reason(payload, actual_cycle_seconds) return OperationReportRead.model_validate(payload) def _get_average_issued_weight_cost(db: Session, work_order_id: int) -> Decimal: row = db.execute( select( func.coalesce(func.sum(InventoryTxn.amount), 0).label("total_amount"), func.coalesce(func.sum(InventoryTxn.weight_change_kg), 0).label("total_weight"), ).where( InventoryTxn.txn_type == "MATERIAL_ISSUE", InventoryTxn.source_doc_type == "WORK_ORDER", InventoryTxn.source_doc_id == work_order_id, ) ).mappings().first() if not row: return Decimal("0") total_amount = to_decimal(row["total_amount"], "0.01") total_weight = abs(to_decimal(row["total_weight"])) if total_weight <= 0: return Decimal("0") return total_amount / total_weight def _get_active_scrap_record(db: Session, report_id: int) -> ScrapRecord | None: return db.scalar( select(ScrapRecord) .where( ScrapRecord.report_id == report_id, ScrapRecord.status == "CONFIRMED", ) .order_by(ScrapRecord.id.desc()) .limit(1) ) def _revert_scrap_record_effect(work_order: WorkOrder, record: ScrapRecord) -> None: if record.disposal_type == "SCRAP": work_order.scrap_qty = max(Decimal("0"), to_decimal(work_order.scrap_qty) - to_decimal(record.scrap_qty)) MINIAPP_REPORT_STATUS_LABELS = { "pending": "待审核", "approved": "已审核", "rejected": "已驳回", } def _miniapp_report_no(report_id: int, item_id: int) -> str: return f"WRS-{report_id}-{item_id}" def _miniapp_effective_scrap_qty(row: dict) -> Decimal: scrap_qty = to_decimal(row.get("miniapp_scrap_qty")) defect_qty = to_decimal(row.get("defect_qty")) return scrap_qty if scrap_qty > 0 else defect_qty def _new_miniapp_resolve_cache() -> dict[str, dict]: return { "production_ledger": {}, "work_order": {}, "operation": {}, "employee": {}, "report": {}, } def _miniapp_report_item_rows( db: Session, *, limit: int | None = 300, source_item_id: int | None = None, start_date: date | None = None, end_date: date | None = None, updated_after: datetime | None = None, updated_until: datetime | None = None, order_by_updated_asc: bool = False, ) -> list[dict]: stmt = ( select( MiniAppProductionReport.id.label("source_report_id"), MiniAppProductionReportItem.id.label("source_item_id"), func.coalesce( MiniAppProductionReportItem.attendance_point_name, MiniAppProductionReport.attendance_point_name, ).label("attendance_point_name"), MiniAppProductionReport.employee_phone.label("employee_phone"), MiniAppPersonnel.name.label("employee_name"), MiniAppProductionReport.report_date.label("report_date"), MiniAppProductionReport.start_at.label("start_time"), MiniAppProductionReport.end_at.label("end_time"), MiniAppProductionReport.duration_minutes.label("duration_minutes"), MiniAppProductionReport.effective_minutes.label("effective_minutes"), MiniAppProductionReport.status.label("status"), MiniAppProductionReport.reviewer_phone.label("reviewer_phone"), MiniAppProductionReport.reviewed_at.label("reviewed_at"), MiniAppProductionReport.reject_reason.label("reject_reason"), MiniAppProductionReportItem.device_no.label("device_no"), MiniAppProductionReportItem.project_no.label("project_no"), MiniAppProductionReportItem.product_name.label("product_name"), MiniAppProductionReportItem.material_code.label("material_code"), MiniAppProductionReportItem.material_name.label("material_name"), MiniAppProductionReportItem.raw_material_batch_no.label("raw_material_batch_no"), MiniAppProductionReportItem.process_name.label("process_name"), MiniAppProductionReportItem.stamping_method.label("stamping_method"), MiniAppProductionReportItem.standard_beat.label("standard_beat"), MiniAppProductionReportItem.standard_workload.label("standard_workload"), MiniAppProductionReportItem.good_qty.label("good_qty"), MiniAppProductionReportItem.defect_qty.label("defect_qty"), MiniAppProductionReportItem.scrap_qty.label("miniapp_scrap_qty"), MiniAppProductionReportItem.allocated_minutes.label("allocated_minutes"), MiniAppProductionReportItem.started_at.label("started_at"), ) .join(MiniAppProductionReport, MiniAppProductionReport.id == MiniAppProductionReportItem.report_id) .outerjoin(MiniAppPersonnel, MiniAppPersonnel.phone == MiniAppProductionReport.employee_phone) ) if source_item_id is not None: stmt = stmt.where(MiniAppProductionReportItem.id == source_item_id) if start_date is not None: stmt = stmt.where(MiniAppProductionReport.report_date >= start_date) if end_date is not None: stmt = stmt.where(MiniAppProductionReport.report_date <= end_date) if updated_after is not None: stmt = stmt.where(MiniAppProductionReport.updated_at > updated_after) if updated_until is not None: stmt = stmt.where(MiniAppProductionReport.updated_at <= updated_until) if order_by_updated_asc: stmt = stmt.order_by(MiniAppProductionReport.updated_at.asc(), MiniAppProductionReportItem.id.asc()) else: stmt = stmt.order_by(MiniAppProductionReportItem.id.desc()) if limit is not None: stmt = stmt.limit(limit) rows = db.execute(stmt).mappings().all() return [dict(row) for row in rows] def _preload_miniapp_operation_reports(db: Session, report_nos: list[str], cache: dict[str, dict]) -> None: if not report_nos: return unique_report_nos = list(dict.fromkeys(report_nos)) existing_reports = db.scalars(select(OperationReport).where(OperationReport.report_no.in_(unique_report_nos))).all() cache["report"].update({report_no: None for report_no in unique_report_nos}) cache["report"].update({report.report_no: report for report in existing_reports}) def _get_miniapp_report_item_row(db: Session, source_item_id: int) -> dict: rows = _miniapp_report_item_rows(db, limit=1, source_item_id=source_item_id) if rows: return rows[0] raise HTTPException(status_code=404, detail="小程序报工明细不存在") def _miniapp_report_matches_target( report: OperationReport | None, work_order: WorkOrder | None, operation: WorkOrderOperation | None, ) -> bool: return bool( report and work_order and operation and int(report.work_order_id) == int(work_order.id) and int(report.work_order_operation_id) == int(operation.id) ) def _miniapp_report_matches_production_ledger( report: OperationReport | None, production_ledger: ProductionBatchLedger | None, ) -> bool: return bool( report and production_ledger and report.production_ledger_id and int(report.production_ledger_id) == int(production_ledger.id) ) def _unlink_stale_miniapp_report( db: Session, report: OperationReport | None, *, cache: dict[str, dict] | None = None, ) -> None: if not report: return scrap_count = db.scalar(select(func.count(ScrapRecord.id)).where(ScrapRecord.report_id == report.id)) or 0 if scrap_count: # Do not delete a report already used by disposal records. It will be hidden from miniapp sync state # by strict matching, and manual correction can handle the dependent disposal data if needed. return operation = db.get(WorkOrderOperation, report.work_order_operation_id) if report.work_order_operation_id else None work_order_id = int(report.work_order_id) if report.work_order_id else None if operation: operation.report_qty = max(Decimal("0"), to_decimal(operation.report_qty) - to_decimal(report.report_qty)) operation.good_qty = max(Decimal("0"), to_decimal(operation.good_qty) - to_decimal(report.good_qty)) operation.scrap_qty = max(Decimal("0"), to_decimal(operation.scrap_qty) - to_decimal(report.scrap_qty)) operation.rework_qty = max(Decimal("0"), to_decimal(operation.rework_qty) - to_decimal(report.rework_qty)) report_qty = to_decimal(operation.report_qty) planned_qty = to_decimal(operation.planned_qty) if planned_qty > 0 and report_qty >= planned_qty: operation.status = "DONE" elif report_qty > 0: operation.status = "IN_PROGRESS" else: operation.status = "PENDING" operation.is_abnormal = 1 if report_qty > 0 and ( to_decimal(operation.scrap_qty) / report_qty ) > to_decimal(operation.allowed_scrap_rate, "0.0001") else 0 db.add(operation) report_no = report.report_no db.delete(report) db.flush() if cache is not None: cache["report"][report_no] = None if work_order_id: sync_work_order_status(db, work_order_id) def _select_miniapp_issue_candidate( db: Session, candidates: list[WorkOrderMaterialIssue], row: dict, ) -> WorkOrderMaterialIssue | None: unsettled_work_order_ids = { work_order_id for (work_order_id,) in db.execute( select(WorkOrder.id).where( WorkOrder.id.in_({issue.work_order_id for issue in candidates}), _miniapp_syncable_work_order_status_filter(), ) ).all() } latest_by_work_order: dict[int, WorkOrderMaterialIssue] = {} for issue in candidates: if issue.work_order_id not in unsettled_work_order_ids: continue latest_by_work_order.setdefault(issue.work_order_id, issue) if len(latest_by_work_order) == 1: return next(iter(latest_by_work_order.values())) product_name = str(row.get("product_name") or "").strip() project_no = str(row.get("project_no") or "").strip() if not product_name and not project_no: return None matched_work_order_ids: set[int] = set() product_rows = db.execute( select(WorkOrder.id, Item.item_name, Item.item_code) .join(Item, Item.id == WorkOrder.product_item_id) .where( WorkOrder.id.in_(latest_by_work_order), _miniapp_syncable_work_order_status_filter(), ) ).all() for work_order_id, item_name, item_code in product_rows: if product_name and str(item_name or "").strip() == product_name: matched_work_order_ids.add(work_order_id) if project_no and str(item_code or "").strip() == project_no: matched_work_order_ids.add(work_order_id) if len(matched_work_order_ids) == 1: return latest_by_work_order[next(iter(matched_work_order_ids))] return None def _resolve_work_order_issue_by_inventory_lot_no( db: Session, lot_no: str, row: dict, ) -> tuple[WorkOrderMaterialIssue | None, bool]: candidates = db.scalars( select(WorkOrderMaterialIssue) .join(WorkOrder, WorkOrder.id == WorkOrderMaterialIssue.work_order_id) .join(StockLot, StockLot.id == WorkOrderMaterialIssue.source_lot_id) .where( StockLot.lot_no == lot_no, _miniapp_syncable_work_order_status_filter(), ) .order_by(WorkOrderMaterialIssue.id.desc()) ).all() if candidates: return _select_miniapp_issue_candidate(db, list(candidates), row), True has_filtered_source_lot_candidate = bool(db.scalar( select(func.count(WorkOrderMaterialIssue.id)) .join(StockLot, StockLot.id == WorkOrderMaterialIssue.source_lot_id) .where(StockLot.lot_no == lot_no) ) or 0) candidates = db.scalars( select(WorkOrderMaterialIssue) .join(WorkOrder, WorkOrder.id == WorkOrderMaterialIssue.work_order_id) .where( WorkOrderMaterialIssue.material_sub_batch_no == lot_no, _miniapp_syncable_work_order_status_filter(), ) .order_by(WorkOrderMaterialIssue.id.desc()) ).all() if candidates: return _select_miniapp_issue_candidate(db, list(candidates), row), True has_filtered_sub_batch_candidate = bool(db.scalar( select(func.count(WorkOrderMaterialIssue.id)).where(WorkOrderMaterialIssue.material_sub_batch_no == lot_no) ) or 0) if has_filtered_source_lot_candidate or has_filtered_sub_batch_candidate: return None, True return None, False def _find_work_order_for_miniapp_item(db: Session, row: dict, cache: dict[str, dict] | None = None) -> WorkOrder | None: batch_no = str(row.get("raw_material_batch_no") or "").strip() product_name = str(row.get("product_name") or "").strip() project_no = str(row.get("project_no") or "").strip() cache_key = ("batch", batch_no, product_name, project_no) if cache is not None and cache_key in cache["work_order"]: return cache["work_order"][cache_key] if not batch_no: if cache is not None: cache["work_order"][cache_key] = None return None issue, has_issue_candidates = _resolve_work_order_issue_by_inventory_lot_no(db, batch_no, row) work_order = db.get(WorkOrder, issue.work_order_id) if issue else None if not work_order and _is_miniapp_work_order_short_code(batch_no): work_order = _resolve_work_order_by_miniapp_short_code(db, batch_no, row) if not work_order and not has_issue_candidates: work_order = db.scalar( select(WorkOrder) .where( WorkOrder.work_order_no == batch_no, _miniapp_syncable_work_order_status_filter(), ) .limit(1) ) if work_order: if cache is not None: cache["work_order"][cache_key] = work_order return work_order if cache is not None: cache["work_order"][cache_key] = work_order return work_order def _find_production_ledger_for_miniapp_item( db: Session, row: dict, cache: dict[str, dict] | None = None, ) -> ProductionBatchLedger | None: batch_no = str(row.get("raw_material_batch_no") or "").strip() product_name = str(row.get("product_name") or "").strip() project_no = str(row.get("project_no") or "").strip() cache_key = ("production_ledger", batch_no, product_name, project_no) if cache is not None and cache_key in cache["production_ledger"]: return cache["production_ledger"][cache_key] if not batch_no or (not product_name and not project_no): if cache is not None: cache["production_ledger"][cache_key] = None return None product_filters = [] if product_name: product_filters.append(Item.item_name == product_name) if project_no: product_filters.append(Item.item_code == project_no) ledger = db.scalar( select(ProductionBatchLedger) .join(Item, Item.id == ProductionBatchLedger.product_item_id) .where( ProductionBatchLedger.material_lot_no == batch_no, ProductionBatchLedger.status == "在生产", ProductionBatchLedger.miniapp_selectable == 1, or_(*product_filters), ) .order_by(ProductionBatchLedger.id.desc()) .limit(1) ) if cache is not None: cache["production_ledger"][cache_key] = ledger return ledger def _find_work_order_operation_for_miniapp_item( db: Session, work_order_id: int, process_name: str | None, cache: dict[str, dict] | None = None, *, allow_fallback: bool = True, ) -> WorkOrderOperation | None: normalized_process = str(process_name or "").strip() cache_key = (work_order_id, normalized_process, allow_fallback) if cache is not None and cache_key in cache["operation"]: return cache["operation"][cache_key] operation = None if normalized_process: operation = db.scalar( select(WorkOrderOperation) .where( WorkOrderOperation.work_order_id == work_order_id, WorkOrderOperation.operation_name == normalized_process, ) .order_by(WorkOrderOperation.seq_no) .limit(1) ) if operation: if cache is not None: cache["operation"][cache_key] = operation return operation if not allow_fallback: if cache is not None: cache["operation"][cache_key] = None return None operation = db.scalar( select(WorkOrderOperation) .where(WorkOrderOperation.work_order_id == work_order_id) .order_by(WorkOrderOperation.seq_no) .limit(1) ) if cache is not None: cache["operation"][cache_key] = operation return operation def _get_or_create_miniapp_employee(db: Session, row: dict, cache: dict[str, dict] | None = None) -> Employee: phone = str(row.get("employee_phone") or "").strip() employee_name = str(row.get("employee_name") or "").strip() or phone or "小程序员工" cache_key = phone or f"name:{employee_name}" if cache is not None and cache_key in cache["employee"]: return cache["employee"][cache_key] if phone: employee = db.scalar(select(Employee).where(Employee.mobile == phone).order_by(Employee.id.desc()).limit(1)) if employee: if employee.employee_name != employee_name: employee.employee_name = employee_name db.add(employee) if cache is not None: cache["employee"][cache_key] = employee return employee department = db.scalar(select(Department).order_by(Department.id).limit(1)) if not department: raise HTTPException(status_code=400, detail="ERP 未配置部门,无法把小程序报工员工映射到 ERP 员工档案") employee = Employee( employee_code=next_employee_code(db), employee_name=employee_name, dept_id=department.id, mobile=phone or None, gender=None, hire_date=None, job_title="小程序报工员工", shift_code=None, manager_employee_id=None, is_operator=1, is_workshop_staff=1, status="ACTIVE", remark="由小程序报工同步生成", ) db.add(employee) db.flush() if cache is not None: cache["employee"][cache_key] = employee return employee def _sync_miniapp_item_to_erp( db: Session, source_item_id: int, *, required: bool = False, row: dict | None = None, cache: dict[str, dict] | None = None, sync_status: bool = True, ) -> OperationReport | None: if row is None: row = _get_miniapp_report_item_row(db, source_item_id) report_no = _miniapp_report_no(int(row["source_report_id"]), int(row["source_item_id"])) if cache is not None and report_no in cache["report"]: report = cache["report"][report_no] else: report = db.scalar(select(OperationReport).where(OperationReport.report_no == report_no).limit(1)) if cache is not None: cache["report"][report_no] = report if str(row.get("status") or "") == "rejected": _unlink_stale_miniapp_report(db, report, cache=cache) if required: raise HTTPException(status_code=400, detail="该小程序报工已驳回,不能同步为 ERP 报工") return None production_ledger = _find_production_ledger_for_miniapp_item(db, row, cache) work_order = None if production_ledger else _find_work_order_for_miniapp_item(db, row, cache) if not work_order: if not production_ledger: _unlink_stale_miniapp_report(db, report, cache=cache) if required: raise HTTPException(status_code=400, detail="小程序报工缺少有效库存批次号,不能同步为 ERP 报工") return None operation = None if work_order: operation = _find_work_order_operation_for_miniapp_item( db, work_order.id, row.get("process_name"), cache, allow_fallback=not _is_miniapp_work_order_short_code(row.get("raw_material_batch_no")), ) if not operation: _unlink_stale_miniapp_report(db, report, cache=cache) if required: raise HTTPException(status_code=400, detail="该库存批次缺少可匹配的小程序报工工序") return None employee = _get_or_create_miniapp_employee(db, row, cache) start_time = row.get("started_at") or row.get("start_time") or datetime.now() end_time = row.get("end_time") or start_time + timedelta(minutes=float(row.get("allocated_minutes") or 0) or 1) if end_time <= start_time: end_time = start_time + timedelta(minutes=1) good_qty = to_decimal(row.get("good_qty")) scrap_qty = _miniapp_effective_scrap_qty(row) report_qty = good_qty + scrap_qty if report_qty <= 0: if required: raise HTTPException(status_code=400, detail="该小程序报工没有有效产出或报废数量") return None if production_ledger: if report and not _miniapp_report_matches_production_ledger(report, production_ledger): _unlink_stale_miniapp_report(db, report, cache=cache) report = None elif report and not _miniapp_report_matches_target(report, work_order, operation): _unlink_stale_miniapp_report(db, report, cache=cache) report = None old_report_qty = to_decimal(report.report_qty) if report else Decimal("0") old_good_qty = to_decimal(report.good_qty) if report else Decimal("0") old_scrap_qty = to_decimal(report.scrap_qty) if report else Decimal("0") actual_cycle_seconds = _calculate_actual_cycle_seconds(start_time, end_time, report_qty) ref_cycle_seconds = to_decimal(row.get("standard_beat"), "0.01") abnormal = 0 if operation and report_qty > 0 and (scrap_qty / report_qty) > to_decimal(operation.allowed_scrap_rate, "0.0001"): abnormal = 1 if actual_cycle_seconds is not None and ref_cycle_seconds > 0 and Decimal(str(actual_cycle_seconds)) > ref_cycle_seconds: abnormal = 1 if not report: report = OperationReport( report_no=report_no, work_order_id=work_order.id if work_order else None, work_order_operation_id=operation.id if operation else None, production_ledger_id=production_ledger.id if production_ledger else None, employee_id=employee.id, equipment_id=None, report_source="MINIAPP", shift_code=None, start_time=start_time, end_time=end_time, report_qty=report_qty, good_qty=good_qty, scrap_qty=scrap_qty, rework_qty=to_decimal(0), scrap_reason=None, downtime_minutes=to_decimal(0, "0.01"), extra_cost_amount=to_decimal(0, "0.01"), is_abnormal=abnormal, approved_by=None, approved_at=None, remark=( f"小程序报工同步:考勤点={row.get('attendance_point_name') or '-'} " f"report_id={row['source_report_id']} item_id={row['source_item_id']}" ), ) else: report.work_order_id = work_order.id if work_order else None report.work_order_operation_id = operation.id if operation else None report.production_ledger_id = production_ledger.id if production_ledger else None report.employee_id = employee.id report.report_source = "MINIAPP" report.start_time = start_time report.end_time = end_time report.report_qty = report_qty report.good_qty = good_qty report.scrap_qty = scrap_qty report.rework_qty = to_decimal(0) report.is_abnormal = abnormal report.remark = ( f"小程序报工同步:考勤点={row.get('attendance_point_name') or '-'} " f"report_id={row['source_report_id']} item_id={row['source_item_id']}" ) db.add(report) if operation and work_order: delta_report_qty = report_qty - old_report_qty delta_good_qty = good_qty - old_good_qty delta_scrap_qty = scrap_qty - old_scrap_qty operation.report_qty = max(Decimal("0"), to_decimal(operation.report_qty) + delta_report_qty) operation.good_qty = max(Decimal("0"), to_decimal(operation.good_qty) + delta_good_qty) operation.scrap_qty = max(Decimal("0"), to_decimal(operation.scrap_qty) + delta_scrap_qty) if abnormal: operation.is_abnormal = 1 if to_decimal(operation.report_qty) >= to_decimal(operation.planned_qty): operation.status = "DONE" elif to_decimal(operation.report_qty) > 0: operation.status = "IN_PROGRESS" db.add(operation) if work_order.actual_start_time is None: work_order.actual_start_time = start_time db.add(work_order) if sync_status: sync_work_order_status(db, work_order.id) db.flush() if cache is not None: cache["report"][report_no] = report return report def _build_miniapp_operation_report_read( db: Session, row: dict, report: OperationReport | None = None, cache: dict[str, dict] | None = None, resolve_targets: bool = True, ) -> MiniAppOperationReportRead: if not report: report_no = _miniapp_report_no(int(row["source_report_id"]), int(row["source_item_id"])) if cache is not None and report_no in cache["report"]: report = cache["report"][report_no] else: report = db.scalar(select(OperationReport).where(OperationReport.report_no == report_no).limit(1)) if cache is not None: cache["report"][report_no] = report erp_scrap_qty = _miniapp_effective_scrap_qty(row) status = str(row.get("status") or "pending") if not resolve_targets: return MiniAppOperationReportRead( source_report_id=int(row["source_report_id"]), source_item_id=int(row["source_item_id"]), report_no=_miniapp_report_no(int(row["source_report_id"]), int(row["source_item_id"])), attendance_point_name=str(row.get("attendance_point_name") or ""), employee_phone=str(row.get("employee_phone") or ""), employee_name=str(row.get("employee_name") or ""), report_date=row["report_date"], start_time=row["start_time"], end_time=row["end_time"], duration_minutes=float(row.get("duration_minutes") or 0), effective_minutes=float(row.get("effective_minutes") or 0), status=status, status_name=MINIAPP_REPORT_STATUS_LABELS.get(status, status), reviewer_phone=row.get("reviewer_phone"), reviewed_at=row.get("reviewed_at"), reject_reason=row.get("reject_reason"), device_no=str(row.get("device_no") or ""), project_no=str(row.get("project_no") or ""), product_name=str(row.get("product_name") or ""), material_code=row.get("material_code"), material_name=row.get("material_name"), raw_material_batch_no=row.get("raw_material_batch_no"), process_name=row.get("process_name"), standard_beat=float(row.get("standard_beat") or 0), standard_workload=float(row.get("standard_workload") or 0), good_qty=float(row.get("good_qty") or 0), defect_qty=float(row.get("defect_qty") or 0), scrap_qty=float(row.get("miniapp_scrap_qty") or 0), erp_scrap_qty=float(erp_scrap_qty), allocated_minutes=float(row.get("allocated_minutes") or 0), started_at=row.get("started_at"), work_order_id=report.work_order_id if report else None, work_order_no=row.get("raw_material_batch_no"), work_order_operation_id=report.work_order_operation_id if report else None, operation_name=row.get("process_name"), erp_report_id=report.id if report else None, erp_report_no=report.report_no if report else None, is_synced_to_erp=bool(report), abnormal_reason=None, ) production_ledger = _find_production_ledger_for_miniapp_item(db, row, cache) work_order = None if production_ledger else _find_work_order_for_miniapp_item(db, row, cache) operation = ( _find_work_order_operation_for_miniapp_item( db, work_order.id, row.get("process_name"), cache, allow_fallback=not _is_miniapp_work_order_short_code(row.get("raw_material_batch_no")), ) if work_order else None ) synced_report = ( report if ( _miniapp_report_matches_production_ledger(report, production_ledger) or _miniapp_report_matches_target(report, work_order, operation) ) else None ) report_qty = to_decimal(row.get("good_qty")) + erp_scrap_qty abnormal_reason = None if report_qty > 0 and operation: scrap_rate = erp_scrap_qty / report_qty if scrap_rate > to_decimal(operation.allowed_scrap_rate, "0.0001"): abnormal_reason = ( f"报废率超标 {round(float(scrap_rate * 100), 2)}% > " f"{round(float(to_decimal(operation.allowed_scrap_rate, '0.0001') * 100), 2)}%" ) return MiniAppOperationReportRead( source_report_id=int(row["source_report_id"]), source_item_id=int(row["source_item_id"]), report_no=_miniapp_report_no(int(row["source_report_id"]), int(row["source_item_id"])), attendance_point_name=str(row.get("attendance_point_name") or ""), employee_phone=str(row.get("employee_phone") or ""), employee_name=str(row.get("employee_name") or ""), report_date=row["report_date"], start_time=row["start_time"], end_time=row["end_time"], duration_minutes=float(row.get("duration_minutes") or 0), effective_minutes=float(row.get("effective_minutes") or 0), status=status, status_name=MINIAPP_REPORT_STATUS_LABELS.get(status, status), reviewer_phone=row.get("reviewer_phone"), reviewed_at=row.get("reviewed_at"), reject_reason=row.get("reject_reason"), device_no=str(row.get("device_no") or ""), project_no=str(row.get("project_no") or ""), product_name=str(row.get("product_name") or ""), material_code=row.get("material_code"), material_name=row.get("material_name"), raw_material_batch_no=row.get("raw_material_batch_no"), process_name=row.get("process_name"), standard_beat=float(row.get("standard_beat") or 0), standard_workload=float(row.get("standard_workload") or 0), good_qty=float(row.get("good_qty") or 0), defect_qty=float(row.get("defect_qty") or 0), scrap_qty=float(row.get("miniapp_scrap_qty") or 0), erp_scrap_qty=float(erp_scrap_qty), allocated_minutes=float(row.get("allocated_minutes") or 0), started_at=row.get("started_at"), work_order_id=work_order.id if work_order else None, work_order_no=work_order.work_order_no if work_order else row.get("raw_material_batch_no"), work_order_operation_id=operation.id if operation else None, operation_name=operation.operation_name if operation else row.get("process_name"), erp_report_id=synced_report.id if synced_report else None, erp_report_no=synced_report.report_no if synced_report else None, is_synced_to_erp=bool(synced_report), abnormal_reason=abnormal_reason, ) @router.get("/work-order-ledger", response_model=list[WorkOrderLedgerRead]) def list_work_order_ledger( work_order_id: int | None = Query(default=None), limit: int = Query(default=200, ge=1, le=500), db: Session = Depends(get_db), ) -> list[WorkOrderLedgerRead]: if work_order_id and sync_work_order_reference_qty(db, work_order_id): db.commit() rows = get_work_order_ledger_rows(db, limit=limit, work_order_id=work_order_id) return [WorkOrderLedgerRead.model_validate(row.model_dump()) for row in rows] @router.get("/production-ledger", response_model=list[ProductionBatchLedgerRead]) def list_production_batch_ledger( production_ledger_id: int | None = Query(default=None), status: str | None = Query(default=None), limit: int = Query(default=200, ge=1, le=500), db: Session = Depends(get_db), ) -> list[ProductionBatchLedgerRead]: material_item = aliased(Item) product_item = aliased(Item) query = ( select( ProductionBatchLedger, material_item.item_code.label("material_code"), material_item.item_name.label("material_name"), product_item.item_code.label("product_code"), product_item.item_name.label("product_name"), ) .join(material_item, material_item.id == ProductionBatchLedger.material_item_id, isouter=True) .join(product_item, product_item.id == ProductionBatchLedger.product_item_id, isouter=True) .order_by(ProductionBatchLedger.updated_at.desc(), ProductionBatchLedger.id.desc()) .limit(limit) ) if production_ledger_id: query = query.where(ProductionBatchLedger.id == production_ledger_id) if status: query = query.where(ProductionBatchLedger.status == status) rows = db.execute(query).all() ledger_ids = [row.ProductionBatchLedger.id for row in rows] material_out_archives = _latest_archive_map(db, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, [int(ledger_id) for ledger_id in ledger_ids]) txns_by_ledger_id: dict[int, list[ProductionBatchLedgerTxn]] = {ledger_id: [] for ledger_id in ledger_ids} settlement_archives: dict[int, DocumentArchive] = {} settlement_business_id_by_txn_id: dict[int, int] = {} if ledger_ids: txn_rows = db.scalars( select(ProductionBatchLedgerTxn) .where(ProductionBatchLedgerTxn.production_ledger_id.in_(ledger_ids)) .order_by(ProductionBatchLedgerTxn.biz_time.desc(), ProductionBatchLedgerTxn.id.desc()) ).all() for txn in txn_rows: txns_by_ledger_id.setdefault(txn.production_ledger_id, []).append(txn) settlement_group_order = {"成品入库": 1, "生产余料入库": 2, "生产废料入库": 3} settlement_grouped_txns: dict[tuple[int, object], list[ProductionBatchLedgerTxn]] = {} for txn in txn_rows: if txn.source_doc_type != "库存流水" or not txn.source_doc_id or txn.txn_type not in PRODUCTION_LEDGER_INBOUND_TXN_TYPES: continue batch_no = production_document_batch_no_from_text(txn.remark) group_key = (int(txn.production_ledger_id), f"批次:{batch_no}" if batch_no else txn.biz_time) settlement_grouped_txns.setdefault(group_key, []).append(txn) for grouped_txns in settlement_grouped_txns.values(): main_txn = sorted( grouped_txns, key=lambda item: (settlement_group_order.get(str(item.txn_type or ""), 99), int(item.source_doc_id or 0)), )[0] main_business_id = int(main_txn.source_doc_id) for txn in grouped_txns: settlement_business_id_by_txn_id[int(txn.id)] = main_business_id settlement_business_ids = list(set(settlement_business_id_by_txn_id.values())) settlement_archives = _latest_archive_map(db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, settlement_business_ids) result: list[ProductionBatchLedgerRead] = [] for row in rows: ledger = row.ProductionBatchLedger material_archive = material_out_archives.get(int(ledger.id)) result.append( ProductionBatchLedgerRead( production_ledger_id=ledger.id, material_lot_id=ledger.material_lot_id, material_lot_no=ledger.material_lot_no, material_item_id=ledger.material_item_id, material_code=row.material_code, material_name=row.material_name, product_item_id=ledger.product_item_id, product_code=row.product_code, product_name=row.product_name, status=ledger.status, miniapp_selectable=bool(ledger.miniapp_selectable), total_issued_weight_kg=float(to_decimal(ledger.total_issued_weight_kg)), outside_weight_kg=float(to_decimal(ledger.outside_weight_kg)), finished_inbound_qty=float(to_decimal(ledger.finished_inbound_qty)), surplus_inbound_weight_kg=float(to_decimal(ledger.surplus_inbound_weight_kg)), scrap_inbound_weight_kg=float(to_decimal(ledger.scrap_inbound_weight_kg)), writeoff_weight_kg=float(to_decimal(ledger.writeoff_weight_kg)), first_issue_time=ledger.first_issue_time, last_issue_time=ledger.last_issue_time, locked_at=ledger.locked_at, reopened_at=ledger.reopened_at, lock_count=int(ledger.lock_count or 0), remark=ledger.remark, material_out_archive_status=material_archive.status if material_archive else ARCHIVE_STATUS_MISSING, material_out_archive_business_id=ledger.id, material_out_archive_document_type=DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, material_out_archive_error_message=material_archive.error_message if material_archive else None, txns=[ _production_ledger_txn_read( txn, settlement_archives.get(settlement_business_id_by_txn_id[int(txn.id)]) if int(txn.id) in settlement_business_id_by_txn_id else None, settlement_business_id_by_txn_id.get(int(txn.id)), ) for txn in txns_by_ledger_id.get(ledger.id, []) ], ) ) return result @router.get("/work-order-inbounds/preview", response_model=ProductionWorkOrderInboundPreviewRead) def preview_work_order_inbound( work_order_id: int = Query(...), finished_qty: float | None = Query(default=None), db: Session = Depends(get_db), ) -> ProductionWorkOrderInboundPreviewRead: return preview_production_work_order_inbound(db, work_order_id=work_order_id, finished_qty=finished_qty) @router.post("/work-order-inbounds", response_model=ProductionWorkOrderInboundRead) def create_work_order_inbound( payload: ProductionWorkOrderInboundCreate, context: AuthContext = Depends(require_authenticated_user), db: Session = Depends(get_db), ) -> ProductionWorkOrderInboundRead: return create_production_work_order_inbound(db, payload=payload, operator_user_id=context.user.id) @router.get("/production-ledger-inbounds/preview", response_model=ProductionBatchLedgerInboundPreviewRead) def preview_production_batch_ledger_inbound( production_ledger_id: int = Query(...), finished_qty: float = Query(default=0), db: Session = Depends(get_db), ) -> ProductionBatchLedgerInboundPreviewRead: snapshot = calculate_production_batch_ledger_snapshot( db, production_ledger_id, smart_reporting_enabled=False, selected_finished_qty=to_decimal(finished_qty), ) material = db.get(Item, snapshot.material_item_id) product = db.get(Item, snapshot.product_item_id) return ProductionBatchLedgerInboundPreviewRead( production_ledger_id=snapshot.production_ledger_id, material_lot_no=snapshot.material_lot_no, product_item_id=snapshot.product_item_id, product_code=product.item_code if product else None, product_name=product.item_name if product else None, material_item_id=snapshot.material_item_id, material_code=material.item_code if material else None, material_name=material.item_name if material else None, gross_weight_per_piece_kg=float(snapshot.gross_weight_per_piece_kg), net_weight_per_piece_kg=float(snapshot.net_weight_per_piece_kg), total_issued_weight_kg=float(snapshot.total_issued_weight_kg), outside_weight_kg=float(snapshot.outside_weight_kg), finished_inbound_qty=float(snapshot.finished_inbound_qty), surplus_inbound_weight_kg=float(snapshot.surplus_inbound_weight_kg), scrap_inbound_weight_kg=float(snapshot.scrap_inbound_weight_kg), writeoff_weight_kg=float(snapshot.writeoff_weight_kg), reference_finished_qty=float(snapshot.reference_finished_qty), used_material_weight_kg=float(snapshot.used_material_weight_kg), theoretical_surplus_weight_kg=float(snapshot.theoretical_surplus_weight_kg), theoretical_scrap_weight_kg=float(snapshot.theoretical_scrap_weight_kg), ) @router.post("/production-ledger-inbounds", response_model=ProductionBatchLedgerInboundRead) def create_production_batch_ledger_inbound( payload: ProductionBatchLedgerInboundCreate, context: AuthContext = Depends(require_authenticated_user), db: Session = Depends(get_db), ) -> ProductionBatchLedgerInboundRead: finished_qty = to_decimal(payload.finished_qty) surplus_weight = to_decimal(payload.surplus_weight_kg) scrap_weight = to_decimal(payload.scrap_weight_kg) if finished_qty <= 0 and surplus_weight <= 0 and scrap_weight <= 0 and not payload.lock_material_batch: raise HTTPException(status_code=400, detail="请至少填写一种入库数量或勾选该批材料结单") ledger: ProductionBatchLedger | None = db.get(ProductionBatchLedger, payload.production_ledger_id) if not ledger: raise HTTPException(status_code=404, detail="生产台账不存在") source_lot = db.get(StockLot, ledger.material_lot_id) material = db.get(Item, ledger.material_item_id) product = db.get(Item, ledger.product_item_id) if not source_lot or not material or not product: raise HTTPException(status_code=400, detail="生产台账来源批次、原材料或产品数据不完整") snapshot = calculate_production_batch_ledger_snapshot( db, payload.production_ledger_id, smart_reporting_enabled=False, selected_finished_qty=finished_qty, ) deviation_remark = str(payload.deviation_remark or "").strip() total_finished_qty = to_decimal(snapshot.finished_inbound_qty) + finished_qty reference_finished_qty = to_decimal(snapshot.reference_finished_qty) if reference_finished_qty <= 0: finished_exceeded = total_finished_qty > 0 else: finished_exceeded = total_finished_qty > reference_finished_qty * Decimal("1.15") def _deviation_exceeded(actual: Decimal, theoretical: Decimal) -> bool: actual = to_decimal(actual) theoretical = to_decimal(theoretical) if theoretical == 0: return actual != 0 return abs(actual - theoretical) / abs(theoretical) > Decimal("0.15") if finished_exceeded and not deviation_remark: raise HTTPException(status_code=400, detail="成品入库数量超过本生产台账参考生产数量上限15%,请填写偏差说明") if ( _deviation_exceeded(surplus_weight, to_decimal(snapshot.theoretical_surplus_weight_kg)) or _deviation_exceeded(scrap_weight, to_decimal(snapshot.theoretical_scrap_weight_kg)) ) and not deviation_remark: raise HTTPException(status_code=400, detail="余料或废料入库重量与理论值偏差超过上下15%,请填写偏差说明") gross_weight = to_decimal(snapshot.gross_weight_per_piece_kg) net_weight = to_decimal(snapshot.net_weight_per_piece_kg) if net_weight <= 0: net_weight = to_decimal(product.unit_weight_kg) if net_weight <= 0: net_weight = gross_weight if gross_weight <= 0 and finished_qty > 0: raise HTTPException(status_code=400, detail="产品需用清单缺少单件毛重,不能进行生产台账入库") now = datetime.now() locked_check_warehouse_ids: set[int] = set() finished_warehouse = _get_active_warehouse(db, "FINISHED", "生产台账入库") if finished_qty > 0 else None scrap_warehouse = _get_active_warehouse(db, "SCRAP", "生产台账入库") if scrap_weight > 0 else None if finished_warehouse: locked_check_warehouse_ids.add(int(finished_warehouse.id)) if surplus_weight > 0: locked_check_warehouse_ids.add(int(source_lot.warehouse_id)) if scrap_warehouse: locked_check_warehouse_ids.add(int(scrap_warehouse.id)) ensure_warehouses_unlocked(db, locked_check_warehouse_ids, "生产台账入库") finished_lot_id = surplus_lot_id = scrap_lot_id = None finished_txn_id = surplus_txn_id = scrap_txn_id = None base_remark = payload.remark or "生产台账入库" if payload.deviation_remark: base_remark = f"{base_remark};偏差说明:{payload.deviation_remark}" document_batch_no = _production_document_batch_no(now) if (finished_qty > 0 or surplus_weight > 0 or scrap_weight > 0) else None if finished_qty > 0: receipt_weight = to_decimal(finished_qty * net_weight) unit_cost = to_decimal(to_decimal(source_lot.unit_cost) * gross_weight, "0.0001") lot_no = _build_production_ledger_lot_no(db, "FGL", ledger, product, now) source_summary = f"{material.item_name}:{ledger.material_lot_no}" finished_lot = StockLot( lot_no=lot_no, parent_lot_id=source_lot.id, lot_role="FINISHED_FROM_RAW_BATCH", material_sub_batch_no=None, item_id=product.id, warehouse_id=finished_warehouse.id, location_id=None, source_doc_type="PRODUCTION_LEDGER_IN", source_doc_id=ledger.id, source_line_id=None, source_material_lot_id=source_lot.id, source_material_sub_batch_no=ledger.material_lot_no, source_material_summary=source_summary, inbound_qty=finished_qty, inbound_weight_kg=receipt_weight, remaining_qty=finished_qty, remaining_weight_kg=receipt_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=_with_document_batch_no(f"{base_remark};成品入库;来源库存批次已记录", document_batch_no), ) db.add(finished_lot) db.flush() upsert_stock_balance( db, item_id=product.id, warehouse_id=finished_warehouse.id, location_id=None, qty_delta=finished_qty, weight_delta=receipt_weight, available_qty_delta=finished_qty, available_weight_delta=receipt_weight, unit_cost=unit_cost, ) finished_txn = create_inventory_txn( db, txn_type="FG_IN", item_id=product.id, warehouse_id=finished_warehouse.id, location_id=None, lot_id=finished_lot.id, qty_change=finished_qty, weight_change=receipt_weight, unit_cost=unit_cost, source_doc_type="生产台账", source_doc_id=ledger.id, source_line_id=finished_lot.id, biz_time=now, operator_user_id=context.user.id, remark=_with_document_batch_no(f"{base_remark};成品入库", document_batch_no), amount_basis="QTY", ) finished_lot_id = finished_lot.id finished_txn_id = finished_txn.id ledger = post_finished_inbound_to_ledger( db, production_ledger_id=payload.production_ledger_id, finished_qty=finished_qty, operator_user_id=context.user.id, source_doc_type="库存流水", source_doc_id=finished_txn.id, source_line_id=finished_lot.id, biz_time=now, remark=_with_document_batch_no(f"{base_remark};成品入库联动生产台账", document_batch_no), ) if surplus_weight > 0: source_lot.remaining_weight_kg = to_decimal(source_lot.remaining_weight_kg) + surplus_weight source_lot.status = "AVAILABLE" db.add(source_lot) upsert_stock_balance( db, item_id=material.id, warehouse_id=source_lot.warehouse_id, location_id=source_lot.location_id, qty_delta=Decimal("0"), weight_delta=surplus_weight, available_qty_delta=Decimal("0"), available_weight_delta=surplus_weight, unit_cost=to_decimal(source_lot.unit_cost), ) surplus_txn = create_inventory_txn( db, txn_type="PRODUCTION_SURPLUS_IN", item_id=material.id, warehouse_id=source_lot.warehouse_id, location_id=source_lot.location_id, lot_id=source_lot.id, qty_change=Decimal("0"), weight_change=surplus_weight, unit_cost=to_decimal(source_lot.unit_cost), source_doc_type="生产台账", source_doc_id=ledger.id, source_line_id=source_lot.id, biz_time=now, operator_user_id=context.user.id, remark=_with_document_batch_no(f"{base_remark};生产余料入库", document_batch_no), amount_basis="WEIGHT", ) surplus_lot_id = source_lot.id surplus_txn_id = surplus_txn.id ledger = post_surplus_inbound_to_ledger( db, production_ledger_id=payload.production_ledger_id, surplus_weight_kg=surplus_weight, operator_user_id=context.user.id, source_doc_type="库存流水", source_doc_id=surplus_txn.id, source_line_id=source_lot.id, biz_time=now, remark=_with_document_batch_no(f"{base_remark};生产余料入库联动生产台账", document_batch_no), ) if scrap_weight > 0: scrap_lot_no = _build_production_ledger_lot_no(db, "SCRAPI", ledger, material, now) scrap_lot = StockLot( lot_no=scrap_lot_no, parent_lot_id=source_lot.id, lot_role="PRODUCTION_SCRAP", material_sub_batch_no=None, item_id=material.id, warehouse_id=scrap_warehouse.id, location_id=None, source_doc_type="PRODUCTION_SCRAP_IN", source_doc_id=ledger.id, source_line_id=None, source_material_lot_id=source_lot.id, source_material_sub_batch_no=ledger.material_lot_no, source_material_summary=f"{material.item_name}:{ledger.material_lot_no}", inbound_qty=Decimal("0"), inbound_weight_kg=scrap_weight, remaining_qty=Decimal("0"), remaining_weight_kg=scrap_weight, locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=to_decimal(source_lot.unit_cost), production_date=now.date(), expire_date=None, quality_status="SCRAP", status="AVAILABLE", remark=_with_document_batch_no(f"{base_remark};生产废料入库;来源库存批次已记录", document_batch_no), ) db.add(scrap_lot) db.flush() upsert_stock_balance( db, item_id=material.id, warehouse_id=scrap_warehouse.id, location_id=None, qty_delta=Decimal("0"), weight_delta=scrap_weight, available_qty_delta=Decimal("0"), available_weight_delta=scrap_weight, unit_cost=to_decimal(source_lot.unit_cost), ) scrap_txn = create_inventory_txn( db, txn_type="PRODUCTION_SCRAP_IN", item_id=material.id, warehouse_id=scrap_warehouse.id, location_id=None, lot_id=scrap_lot.id, qty_change=Decimal("0"), weight_change=scrap_weight, unit_cost=to_decimal(source_lot.unit_cost), source_doc_type="生产台账", source_doc_id=ledger.id, source_line_id=scrap_lot.id, biz_time=now, operator_user_id=context.user.id, remark=_with_document_batch_no(f"{base_remark};生产废料入库", document_batch_no), amount_basis="WEIGHT", ) scrap_lot_id = scrap_lot.id scrap_txn_id = scrap_txn.id ledger = post_scrap_inbound_to_ledger( db, production_ledger_id=payload.production_ledger_id, scrap_weight_kg=scrap_weight, operator_user_id=context.user.id, source_doc_type="库存流水", source_doc_id=scrap_txn.id, source_line_id=scrap_lot.id, biz_time=now, remark=_with_document_batch_no(f"{base_remark};生产废料入库联动生产台账", document_batch_no), ) if payload.lock_material_batch: if not payload.lock_confirmed: raise HTTPException(status_code=400, detail="请确认该批材料结单后再提交") ledger = lock_production_batch_ledger( db, production_ledger_id=payload.production_ledger_id, operator_user_id=context.user.id, biz_time=now, remark=payload.deviation_remark or payload.remark or "生产台账入库后该批材料结单", ) response_ledger_id = int(ledger.id) response_material_lot_no = str(ledger.material_lot_no) response_status = str(ledger.status) main_txn_id = finished_txn_id or surplus_txn_id or scrap_txn_id db.commit() archive_result: DocumentArchiveGenerateResult | None = None if main_txn_id: try: archive_result = generate_document_archive( db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, int(main_txn_id), created_by=context.user.id, ) except Exception as exc: archive_result = _failed_archive_result( DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, int(main_txn_id), f"库存流水#{main_txn_id}", exc, ) response = ProductionBatchLedgerInboundRead( production_ledger_id=response_ledger_id, material_lot_no=response_material_lot_no, finished_qty=float(finished_qty), surplus_weight_kg=float(surplus_weight), scrap_weight_kg=float(scrap_weight), finished_lot_id=finished_lot_id, surplus_lot_id=surplus_lot_id, scrap_lot_id=scrap_lot_id, finished_txn_id=finished_txn_id, surplus_txn_id=surplus_txn_id, scrap_txn_id=scrap_txn_id, locked=bool(payload.lock_material_batch), status=response_status, remark=payload.remark, ) for key, value in _archive_fields_from_result( archive_result, document_type=DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, business_id=int(main_txn_id) if main_txn_id else None, ).items(): setattr(response, key, value) return response @router.get("/work-orders", response_model=list[WorkOrderRead]) def list_work_orders( limit: int = Query(default=100, ge=1, le=500), db: Session = Depends(get_db), ) -> list[WorkOrderRead]: if sync_recent_work_order_reference_qty(db, limit=limit): db.commit() rows = db.execute(get_work_orders_query(limit=limit)).mappings().all() return [WorkOrderRead.model_validate(dict(row)) for row in rows] @router.get("/work-order-materials", response_model=list[WorkOrderMaterialRead]) def list_work_order_materials( work_order_id: int | None = Query(default=None), limit: int = Query(default=200, ge=1, le=500), db: Session = Depends(get_db), ) -> list[WorkOrderMaterialRead]: rows = db.execute(get_work_order_materials_query(limit=limit, work_order_id=work_order_id)).mappings().all() return [WorkOrderMaterialRead.model_validate(dict(row)) for row in rows] @router.get("/work-order-material-issues", response_model=list[WorkOrderMaterialIssueRead]) def list_work_order_material_issues( work_order_id: int | None = Query(default=None), limit: int = Query(default=300, ge=1, le=800), db: Session = Depends(get_db), ) -> list[WorkOrderMaterialIssueRead]: rows = db.execute(get_work_order_material_issues_query(limit=limit, work_order_id=work_order_id)).mappings().all() return [WorkOrderMaterialIssueRead.model_validate(dict(row)) for row in rows] @router.get("/work-order-operations", response_model=list[WorkOrderOperationRead]) def list_work_order_operations( work_order_id: int | None = Query(default=None), limit: int = Query(default=200, ge=1, le=500), db: Session = Depends(get_db), ) -> list[WorkOrderOperationRead]: if work_order_id and sync_work_order_reference_qty(db, work_order_id): db.commit() rows = db.execute(get_work_order_operations_query(limit=limit, work_order_id=work_order_id)).mappings().all() return [WorkOrderOperationRead.model_validate(dict(row)) for row in rows] @router.post("/work-orders", response_model=WorkOrderRead) def create_work_order( payload: WorkOrderCreate, context: AuthContext = Depends(require_authenticated_user), db: Session = Depends(get_db), ) -> WorkOrderRead: product = db.get(Item, payload.product_item_id) if not product: raise HTTPException(status_code=404, detail="产品不存在") if payload.source_sales_order_item_id: source_order_item = db.get(SalesOrderItem, payload.source_sales_order_item_id) if not source_order_item: raise HTTPException(status_code=404, detail="来源订单明细不存在") selected_lots = [ { "source_lot_id": int(row.source_lot_id), "material_item_id": int(row.material_item_id) if row.material_item_id else None, "issued_weight_kg": to_decimal(row.issued_weight_kg), } for row in payload.selected_stock_lots ] if not selected_lots: raise HTTPException(status_code=400, detail="生产出库必须选择库存批次号") if len(selected_lots) != 1: raise HTTPException(status_code=400, detail="一次生产出库只能选择一个材料库存批次号") route = get_default_route(db, payload.product_item_id) bom = get_default_bom(db, payload.product_item_id) bom_items = db.scalars(select(BomItem).where(BomItem.bom_id == bom.id).order_by(BomItem.seq_no)).all() if not bom_items: raise HTTPException(status_code=400, detail="产品需规缺少默认用料,无法按库存批次出库") selected_lot_payload = selected_lots[0] selected_lot = db.scalar( select(StockLot) .where(StockLot.id == int(selected_lot_payload["source_lot_id"])) .with_for_update() ) if not selected_lot: raise HTTPException(status_code=400, detail="库存批次不存在") selected_material_item_id = int(selected_lot_payload["material_item_id"] or selected_lot.item_id) if selected_material_item_id != int(selected_lot.item_id): raise HTTPException(status_code=400, detail=f"库存批次 {selected_lot.lot_no} 与选择的原材料不匹配") selected_lot_payload["material_item_id"] = selected_material_item_id bom_item_by_material_id = {int(item.material_item_id): item for item in bom_items} selected_bom_item = bom_item_by_material_id.get(selected_material_item_id) if not selected_bom_item: raise HTTPException(status_code=400, detail=f"库存批次 {selected_lot.lot_no} 不属于该产品需规默认用料") ensure_warehouses_unlocked(db, {int(selected_lot.warehouse_id)}, "生产出库") issue_weight = to_decimal(selected_lot_payload["issued_weight_kg"]) if issue_weight <= 0: raise HTTPException(status_code=400, detail="出库重量必须大于0") planned_qty = to_decimal(payload.planned_qty) if issue_weight > 0: gross_weight = to_decimal(selected_bom_item.gross_weight_per_piece_kg) if gross_weight <= 0: gross_weight = to_decimal(product.unit_weight_kg) if gross_weight <= 0: raise HTTPException(status_code=400, detail="产品需用清单缺少单件毛重/净重,无法按领料重量计算参考生产数量") planned_qty = (issue_weight / gross_weight).to_integral_value(rounding=ROUND_FLOOR) if planned_qty <= 0: raise HTTPException(status_code=400, detail="参考生产数量必须大于0") biz_time = datetime.now() ledger = record_production_issue_to_ledger( db, material_lot_id=selected_lot.id, product_item_id=payload.product_item_id, issued_weight_kg=issue_weight, operator_user_id=context.user.id, source_doc_type="库存流水", source_doc_id=None, source_line_id=None, biz_time=biz_time, remark=payload.remark or "原材料库生产出库形成生产台账", ) inventory_txn = _issue_raw_lot_to_production_ledger( db, lot=selected_lot, material_item_id=selected_material_item_id, issued_weight=issue_weight, production_ledger_id=ledger.id, operator_user_id=context.user.id, biz_time=biz_time, ) _link_latest_issue_ledger_txn_to_inventory_txn(db, production_ledger_id=ledger.id, inventory_txn=inventory_txn) production_ledger_id = int(ledger.id) production_material_lot_no = str(ledger.material_lot_no) production_ledger_status = str(ledger.status) operation_count = db.scalar(select(func.count(ProcessRouteOperation.id)).where(ProcessRouteOperation.route_id == route.id)) or 0 response = _build_production_ledger_work_order_compat_read( production_ledger_id=production_ledger_id, material_lot_no=production_material_lot_no, product=product, route=route, bom=bom, planned_qty=planned_qty, payload=payload, biz_time=biz_time, status=production_ledger_status, operation_count=int(operation_count), ) db.commit() archive_result: DocumentArchiveGenerateResult | None = None try: archive_result = generate_document_archive( db, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, production_ledger_id, created_by=context.user.id, ) except Exception as exc: archive_result = _failed_archive_result( DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, production_ledger_id, f"生产台账#{production_ledger_id}", exc, ) for key, value in _archive_fields_from_result( archive_result, document_type=DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, business_id=production_ledger_id, ).items(): setattr(response, key, value) return response @router.get("/miniapp-operation-reports/sync-state", response_model=MiniAppOperationReportSyncStateRead) def get_miniapp_operation_report_sync_state( db: Session = Depends(get_db), ) -> MiniAppOperationReportSyncStateRead: return MiniAppOperationReportSyncStateRead(last_synced_at=get_miniapp_operation_report_last_sync(db)) @router.post("/miniapp-operation-reports/sync", response_model=MiniAppOperationReportSyncRead) def sync_miniapp_operation_reports( db: Session = Depends(get_db), ) -> MiniAppOperationReportSyncRead: lock_miniapp_operation_report_last_sync(db) sync_from = get_miniapp_operation_report_last_sync(db) sync_to = datetime.now() rows = _miniapp_report_item_rows( db, limit=None, updated_after=sync_from, updated_until=sync_to, order_by_updated_asc=True, ) cache = _new_miniapp_resolve_cache() report_nos = [_miniapp_report_no(int(row["source_report_id"]), int(row["source_item_id"])) for row in rows] _preload_miniapp_operation_reports(db, report_nos, cache) touched_work_order_ids: set[int] = set() synced = 0 skipped = 0 for row in rows: synced_report = _sync_miniapp_item_to_erp( db, int(row["source_item_id"]), required=False, row=row, cache=cache, sync_status=False, ) if synced_report: synced += 1 if synced_report.work_order_id: touched_work_order_ids.add(int(synced_report.work_order_id)) else: skipped += 1 for work_order_id in touched_work_order_ids: sync_work_order_status(db, work_order_id) row = upsert_miniapp_operation_report_last_sync(db, sync_to) db.commit() db.refresh(row) return MiniAppOperationReportSyncRead( sync_from=sync_from, sync_to=sync_to, last_synced_at=sync_to, total=len(rows), synced=synced, skipped=skipped, failed=0, ) @router.get("/miniapp-operation-reports", response_model=list[MiniAppOperationReportRead]) def list_miniapp_operation_reports( limit: int = Query(default=300, ge=1, le=800), sync_to_erp: bool = Query(default=True), start_date: date | None = Query(default=None), end_date: date | None = Query(default=None), db: Session = Depends(get_db), ) -> list[MiniAppOperationReportRead]: if start_date and end_date and start_date > end_date: raise HTTPException(status_code=400, detail="开始日期不能晚于结束日期") rows = _miniapp_report_item_rows(db, limit=limit, start_date=start_date, end_date=end_date) cache = _new_miniapp_resolve_cache() report_nos = [_miniapp_report_no(int(row["source_report_id"]), int(row["source_item_id"])) for row in rows] _preload_miniapp_operation_reports(db, report_nos, cache) result: list[MiniAppOperationReportRead] = [] touched_work_order_ids: set[int] = set() for row in rows: synced_report = None if sync_to_erp: synced_report = _sync_miniapp_item_to_erp( db, int(row["source_item_id"]), required=False, row=row, cache=cache, sync_status=False, ) if synced_report and synced_report.work_order_id: touched_work_order_ids.add(int(synced_report.work_order_id)) result.append(_build_miniapp_operation_report_read(db, row, synced_report, cache=cache, resolve_targets=sync_to_erp)) if sync_to_erp: for work_order_id in touched_work_order_ids: sync_work_order_status(db, work_order_id) db.commit() return result @router.get("/operation-reports", response_model=list[OperationReportRead]) def list_operation_reports( work_order_id: int | None = Query(default=None), limit: int = Query(default=200, ge=1, le=500), db: Session = Depends(get_db), ) -> list[OperationReportRead]: rows = db.execute(get_operation_reports_query(limit=limit, work_order_id=work_order_id)).mappings().all() return [_build_operation_report_read(dict(row)) for row in rows] @router.post("/miniapp-scrap-records", response_model=ScrapRecordRead) def create_miniapp_scrap_record(payload: MiniAppScrapRecordCreate, db: Session = Depends(get_db)) -> ScrapRecordRead: report = _sync_miniapp_item_to_erp(db, payload.source_item_id, required=True) if not report: raise HTTPException(status_code=400, detail="小程序报工无法同步为 ERP 报工") work_order = db.get(WorkOrder, report.work_order_id) if not work_order: raise HTTPException(status_code=404, detail="库存批次号不存在") source_row = _get_miniapp_report_item_row(db, payload.source_item_id) source_scrap_qty = _miniapp_effective_scrap_qty(source_row) scrap_qty = to_decimal(payload.scrap_qty) if to_decimal(payload.scrap_qty) > 0 else source_scrap_qty scrap_payload = ScrapRecordCreate( report_id=report.id, work_order_id=report.work_order_id, work_order_operation_id=report.work_order_operation_id, item_id=work_order.product_item_id, scrap_qty=float(scrap_qty), scrap_weight_kg=payload.scrap_weight_kg, scrap_reason=payload.scrap_reason, disposal_type="SCRAP", saleable_scrap_weight_kg=payload.saleable_scrap_weight_kg, scrap_cost_amount=payload.scrap_cost_amount, ) return create_scrap_record(scrap_payload, db) @router.post("/operation-reports", response_model=OperationReportRead) def create_operation_report( payload: OperationReportCreate, db: Session = Depends(get_db), ) -> OperationReportRead: work_order = db.get(WorkOrder, payload.work_order_id) if not work_order: raise HTTPException(status_code=404, detail="工单不存在") operation = db.get(WorkOrderOperation, payload.work_order_operation_id) if not operation or operation.work_order_id != payload.work_order_id: raise HTTPException(status_code=400, detail="工单工序不存在或不属于当前工单") employee = db.get(Employee, payload.employee_id) if not employee: raise HTTPException(status_code=404, detail="员工不存在") if payload.equipment_id: equipment = db.get(Equipment, payload.equipment_id) if not equipment: raise HTTPException(status_code=404, detail="设备不存在") if payload.end_time <= payload.start_time: raise HTTPException(status_code=400, detail="报工结束时间必须晚于开始时间") report_qty = to_decimal(payload.report_qty) good_qty = to_decimal(payload.good_qty) scrap_qty = to_decimal(payload.scrap_qty) rework_qty = to_decimal(payload.rework_qty) if report_qty <= 0: raise HTTPException(status_code=400, detail="报工数量必须大于0") if good_qty < 0 or scrap_qty < 0 or rework_qty < 0: raise HTTPException(status_code=400, detail="报工数量、合格数、报废数、返工数不能小于0") if good_qty + scrap_qty + rework_qty > report_qty: raise HTTPException(status_code=400, detail="合格数、报废数、返工数合计不能超过报工数量") route_operation = db.get(ProcessRouteOperation, operation.route_operation_id) if operation.route_operation_id else None ref_cycle_seconds = to_decimal(route_operation.std_cycle_seconds if route_operation else 0, "0.01") actual_cycle_seconds = _calculate_actual_cycle_seconds(payload.start_time, payload.end_time, report_qty) abnormal_reasons: list[str] = [] if report_qty > 0 and (scrap_qty / report_qty) > to_decimal(operation.allowed_scrap_rate, "0.0001"): abnormal_reasons.append("报废率超出工序允许值") if actual_cycle_seconds is not None and ref_cycle_seconds > 0 and Decimal(str(actual_cycle_seconds)) > ref_cycle_seconds: abnormal_reasons.append("实际节拍慢于参考节拍") abnormal = 1 if abnormal_reasons else 0 report = OperationReport( report_no=_build_operation_report_no(db, work_order, operation, employee), work_order_id=payload.work_order_id, work_order_operation_id=payload.work_order_operation_id, employee_id=payload.employee_id, equipment_id=payload.equipment_id, report_source=payload.report_source, shift_code=payload.shift_code, start_time=payload.start_time, end_time=payload.end_time, report_qty=report_qty, good_qty=good_qty, scrap_qty=scrap_qty, rework_qty=rework_qty, scrap_reason=payload.scrap_reason, downtime_minutes=to_decimal(payload.downtime_minutes, "0.01"), extra_cost_amount=to_decimal(payload.extra_cost_amount, "0.01"), is_abnormal=abnormal, approved_by=None, approved_at=None, remark=payload.remark, ) db.add(report) operation.report_qty = to_decimal(operation.report_qty) + report_qty operation.good_qty = to_decimal(operation.good_qty) + good_qty operation.scrap_qty = to_decimal(operation.scrap_qty) + scrap_qty operation.rework_qty = to_decimal(operation.rework_qty) + rework_qty if abnormal: operation.is_abnormal = 1 if operation.report_qty > 0 and (to_decimal(operation.scrap_qty) / to_decimal(operation.report_qty)) > to_decimal( operation.allowed_scrap_rate, "0.0001" ): operation.is_abnormal = 1 if to_decimal(operation.report_qty) >= to_decimal(operation.planned_qty): operation.status = "DONE" else: operation.status = "IN_PROGRESS" db.add(operation) if work_order.actual_start_time is None: work_order.actual_start_time = payload.start_time db.add(work_order) sync_work_order_status(db, work_order.id) db.commit() row = db.execute(get_operation_reports_query(limit=1).where(OperationReport.id == report.id)).mappings().first() if not row: raise HTTPException(status_code=500, detail="报工记录创建后读取失败") return _build_operation_report_read(dict(row)) @router.get("/scrap-records", response_model=list[ScrapRecordRead]) def list_scrap_records( limit: int = Query(default=200, ge=1, le=500), db: Session = Depends(get_db), ) -> list[ScrapRecordRead]: rows = db.execute(get_scrap_records_query(limit=limit)).mappings().all() return [ScrapRecordRead.model_validate(dict(row)) for row in rows] @router.post("/scrap-records", response_model=ScrapRecordRead) def create_scrap_record(payload: ScrapRecordCreate, db: Session = Depends(get_db)) -> ScrapRecordRead: report = db.get(OperationReport, payload.report_id) if not report: raise HTTPException(status_code=404, detail="报工记录不存在") if report.work_order_id != payload.work_order_id or report.work_order_operation_id != payload.work_order_operation_id: raise HTTPException(status_code=400, detail="处置记录与来源报工不匹配") work_order = db.get(WorkOrder, payload.work_order_id) if not work_order: raise HTTPException(status_code=404, detail="工单不存在") operation = db.get(WorkOrderOperation, payload.work_order_operation_id) if not operation or operation.work_order_id != payload.work_order_id: raise HTTPException(status_code=404, detail="工序不存在") item = db.get(Item, payload.item_id) if not item: raise HTTPException(status_code=404, detail="物料不存在") disposal_type = str(payload.disposal_type or "SCRAP").strip().upper() if disposal_type != "SCRAP": raise HTTPException(status_code=400, detail="生产过程只登记报废;客户退货返工请在发货概览模块处理") existing_active_record = _get_active_scrap_record(db, payload.report_id) if existing_active_record: _revert_scrap_record_effect(work_order, existing_active_record) existing_active_record.status = "OVERRIDDEN" db.add(existing_active_record) source_qty = to_decimal(report.rework_qty if disposal_type == "REWORK" and to_decimal(report.rework_qty) > 0 else report.scrap_qty) scrap_qty = to_decimal(payload.scrap_qty) if to_decimal(payload.scrap_qty) > 0 else source_qty if scrap_qty <= 0: raise HTTPException(status_code=400, detail="当前来源报工没有可处置的报废/返工数量") scrap_weight = to_decimal(payload.scrap_weight_kg) if scrap_weight <= 0: scrap_weight = scrap_qty * to_decimal(item.unit_weight_kg) saleable_scrap_weight = Decimal("0") scrap_cost_amount = Decimal("0") if disposal_type == "SCRAP": saleable_scrap_weight = ( to_decimal(payload.saleable_scrap_weight_kg) if to_decimal(payload.saleable_scrap_weight_kg) > 0 else scrap_weight ) scrap_cost_amount = to_decimal(payload.scrap_cost_amount, "0.01") if scrap_cost_amount <= 0 and saleable_scrap_weight > 0: scrap_cost_amount = to_decimal(_get_average_issued_weight_cost(db, payload.work_order_id) * saleable_scrap_weight, "0.01") else: scrap_cost_amount = to_decimal(scrap_qty * to_decimal(operation.std_labor_cost, "0.0001"), "0.01") record = ScrapRecord( scrap_no=_build_scrap_no(db, work_order, disposal_type), report_id=payload.report_id, work_order_id=payload.work_order_id, work_order_operation_id=payload.work_order_operation_id, item_id=payload.item_id, scrap_qty=scrap_qty, scrap_weight_kg=scrap_weight, scrap_reason=payload.scrap_reason or report.scrap_reason, disposal_type=disposal_type, saleable_scrap_weight_kg=saleable_scrap_weight, scrap_cost_amount=scrap_cost_amount, status="CONFIRMED", ) db.add(record) if disposal_type == "SCRAP": work_order.scrap_qty = to_decimal(work_order.scrap_qty) + scrap_qty db.add(work_order) sync_work_order_status(db, work_order.id) db.commit() row = db.execute(get_scrap_records_query(limit=1).where(ScrapRecord.id == record.id)).mappings().first() if not row: raise HTTPException(status_code=500, detail="报废/返工记录创建后读取失败") return ScrapRecordRead.model_validate(dict(row)) @router.get("/completion-receipts", response_model=list[CompletionReceiptRead]) def list_completion_receipts( limit: int = Query(default=100, ge=1, le=500), db: Session = Depends(get_db), ) -> list[CompletionReceiptRead]: rows = db.execute(get_completion_receipts_query(limit=limit)).mappings().all() return [CompletionReceiptRead.model_validate(dict(row)) for row in rows] @router.get("/completion-items", response_model=list[CompletionReceiptItemRead]) def list_completion_receipt_items( receipt_id: int | None = Query(default=None), limit: int = Query(default=200, ge=1, le=500), db: Session = Depends(get_db), ) -> list[CompletionReceiptItemRead]: rows = db.execute(get_completion_receipt_items_query(limit=limit, receipt_id=receipt_id)).mappings().all() return [CompletionReceiptItemRead.model_validate(dict(row)) for row in rows] def _build_work_order_source_material_summary(db: Session, work_order_id: int) -> dict[str, object | None]: rows = db.execute(get_work_order_material_issues_query(limit=500, work_order_id=work_order_id)).mappings().all() if not rows: return { "source_material_lot_id": None, "source_material_sub_batch_no": None, "source_material_summary": None, "source_material_weight_kg": Decimal("0"), "source_material_amount": Decimal("0"), "source_material_unit_cost": Decimal("0"), } first = rows[0] parts: list[str] = [] total_weight = Decimal("0") total_amount = Decimal("0") for row in rows: weight = to_decimal(row["issued_weight_kg"]) amount = to_decimal(row["amount"], "0.01") source_lot_no = row["source_lot_no"] or row["material_sub_batch_no"] parts.append(f"{source_lot_no} / {row['material_name']} / {weight}kg / ¥{amount}") if weight > 0: total_weight += weight total_amount += amount first_lot_no = first["source_lot_no"] or first["material_sub_batch_no"] material_unit_cost = (total_amount / total_weight) if total_weight > 0 else Decimal("0") return { "source_material_lot_id": first["source_lot_id"], "source_material_sub_batch_no": first_lot_no, "source_material_summary": ";".join(parts)[:1000], "source_material_weight_kg": to_decimal(total_weight, "0.000001"), "source_material_amount": to_decimal(total_amount, "0.01"), "source_material_unit_cost": to_decimal(material_unit_cost, "0.0001"), } def _is_rework_work_order(work_order: WorkOrder) -> bool: return str(work_order.work_order_type or "").upper() == "REWORK" def _build_rework_source_material_summary(db: Session, work_order_id: int) -> dict[str, object | None]: row = db.execute( select( ReturnDisposition.id.label("return_disposition_id"), ReturnItem.source_lot_id.label("source_lot_id"), ) .join(ReturnItem, ReturnItem.id == ReturnDisposition.return_item_id) .where(ReturnDisposition.rework_work_order_id == work_order_id) .limit(1) ).mappings().first() if not row: raise HTTPException(status_code=400, detail="该返工工单缺少退货返工来源,不能返工入库") finished_lot = db.get(StockLot, row["source_lot_id"]) if row["source_lot_id"] else None raw_lot = db.get(StockLot, finished_lot.source_material_lot_id) if finished_lot and finished_lot.source_material_lot_id else None source_material_lot_id = finished_lot.source_material_lot_id if finished_lot else None source_material_sub_batch_no = ( finished_lot.source_material_sub_batch_no if finished_lot and finished_lot.source_material_sub_batch_no else raw_lot.lot_no if raw_lot else None ) source_material_summary = ( finished_lot.source_material_summary if finished_lot and finished_lot.source_material_summary else raw_lot.lot_no if raw_lot else "退货返工来源" ) source_material_unit_cost = to_decimal(raw_lot.unit_cost if raw_lot else finished_lot.unit_cost if finished_lot else 0, "0.0001") return { "source_material_lot_id": source_material_lot_id, "source_material_sub_batch_no": source_material_sub_batch_no or "退货返工来源批次未指定", "source_material_summary": f"退货返工;{source_material_summary}", "source_material_weight_kg": Decimal("0"), "source_material_amount": Decimal("0"), "source_material_unit_cost": source_material_unit_cost, } def _get_work_order_gross_weight_per_piece(db: Session, work_order: WorkOrder, product: Item) -> Decimal: gross_weight = to_decimal( db.scalar( select(BomItem.gross_weight_per_piece_kg) .where(BomItem.bom_id == work_order.bom_id, BomItem.gross_weight_per_piece_kg > 0) .order_by(BomItem.seq_no, BomItem.id) .limit(1) ), "0.000001", ) if gross_weight <= 0: gross_weight = to_decimal(product.unit_weight_kg, "0.000001") return gross_weight def _calculate_finished_material_cost_per_piece( source_material: dict[str, object | None], gross_weight_per_piece: Decimal, ) -> Decimal: material_unit_cost = to_decimal(source_material.get("source_material_unit_cost"), "0.0001") if material_unit_cost <= 0 or gross_weight_per_piece <= 0: return Decimal("0") return to_decimal(material_unit_cost * gross_weight_per_piece, "0.0001") def _append_finished_work_order_settle_remark(work_order: WorkOrder, settle_remark: str | None) -> None: existing = str(work_order.remark or "").strip() parts = [part.strip() for part in existing.replace(";", ";").split(";") if part.strip()] if "生产入库已结单" not in parts: parts.append("生产入库已结单") remark = str(settle_remark or "").strip() if remark: parts.append(f"生产入库结单说明:{remark}") unique_parts: list[str] = [] for part in parts: if part not in unique_parts: unique_parts.append(part) work_order.remark = ";".join(unique_parts)[-255:] 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 _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 _settle_rework_work_order_if_ready( db: Session, work_order: WorkOrder, *, incoming_finished_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) + incoming_finished_qty + _rework_scrap_inbound_qty(db, work_order.id) 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() work_order.updated_at = 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) def _validate_finished_settle_request( payload: CompletionReceiptCreate, *, receipt_qty: Decimal, expected_receipt_qty: Decimal, ) -> None: if not payload.settle_work_order: return lower_limit = expected_receipt_qty * Decimal("0.85") upper_limit = expected_receipt_qty * INBOUND_TOLERANCE_MULTIPLIER if lower_limit <= receipt_qty <= upper_limit: return if not str(payload.settle_remark or "").strip(): raise HTTPException(status_code=400, detail="该工单结单入库成品数量超出标准成品数量上下15%,请填写结单说明") @router.post("/completion-receipts", response_model=CompletionReceiptRead) def create_completion_receipt( payload: CompletionReceiptCreate, context: AuthContext = Depends(require_authenticated_user), db: Session = Depends(get_db), ) -> CompletionReceiptRead: work_order = db.get(WorkOrder, payload.work_order_id) if not work_order: raise HTTPException(status_code=404, detail="工单不存在") if str(work_order.status or "").upper() == "SETTLED": raise HTTPException(status_code=400, detail="该生产工单已结单,不能继续成品入库") warehouse = db.get(Warehouse, payload.warehouse_id) if not warehouse: raise HTTPException(status_code=404, detail="仓库不存在") ensure_warehouses_unlocked(db, [payload.warehouse_id], "成品入库") if not payload.items: raise HTTPException(status_code=400, detail="成品入库至少需要一条明细") is_rework = _is_rework_work_order(work_order) limit_snapshot = get_work_order_limit_snapshot(db, payload.work_order_id) max_receivable_qty = to_decimal(limit_snapshot.max_finished_inbound_qty) if max_receivable_qty <= 0: detail = "返工工单当前没有可返工入库数量" if is_rework else "该工单当前没有可入库的完工数量" raise HTTPException(status_code=400, detail=detail) total_receipt_qty = Decimal("0") for item_payload in payload.items: product = db.get(Item, item_payload.product_item_id) if not product: raise HTTPException(status_code=404, detail=f"产品不存在: {item_payload.product_item_id}") if item_payload.product_item_id != work_order.product_item_id: raise HTTPException(status_code=400, detail="成品入库产品必须与工单产品一致") receipt_qty = to_decimal(item_payload.receipt_qty) if receipt_qty <= 0: raise HTTPException(status_code=400, detail="入库数量必须大于0") total_receipt_qty += receipt_qty if is_rework: if total_receipt_qty > max_receivable_qty: raise HTTPException(status_code=400, detail=f"返工入库数量不能超过当前可返工入库数量 {max_receivable_qty}") else: validate_inbound_with_tolerance("成品入库数量", total_receipt_qty, max_receivable_qty) if not is_rework: _validate_finished_settle_request( payload, receipt_qty=total_receipt_qty, expected_receipt_qty=max_receivable_qty, ) elif payload.settle_work_order: _settle_rework_work_order_if_ready( db, work_order, incoming_finished_qty=total_receipt_qty, settle_remark=payload.settle_remark, ) if is_rework: source_material = _build_rework_source_material_summary(db, payload.work_order_id) else: source_material = _build_work_order_source_material_summary(db, payload.work_order_id) if not source_material["source_material_sub_batch_no"]: raise HTTPException(status_code=400, detail="该工单没有原材料库存批次领用记录,不能进行成品入库") ensure_employee_has_permission( db, payload.receiver_employee_id, "MENU_INVENTORY_LEDGER", "接收人", required=False, ) completion = CompletionReceipt( receipt_no=_build_completion_receipt_no(db, work_order), work_order_id=payload.work_order_id, warehouse_id=payload.warehouse_id, receipt_time=payload.receipt_time or datetime.now(), receiver_employee_id=payload.receiver_employee_id, status="POSTED", remark=payload.remark, ) db.add(completion) db.flush() remaining_receivable_qty = max_receivable_qty for index, item_payload in enumerate(payload.items, start=1): product = db.get(Item, item_payload.product_item_id) if not product: raise HTTPException(status_code=404, detail=f"产品不存在: {item_payload.product_item_id}") if item_payload.product_item_id != work_order.product_item_id: raise HTTPException(status_code=400, detail="成品入库产品必须与工单产品一致") location = get_location_or_default(db, payload.warehouse_id, item_payload.location_id) receipt_qty = to_decimal(item_payload.receipt_qty) if receipt_qty <= 0: raise HTTPException(status_code=400, detail="入库数量必须大于0") gross_weight = _get_work_order_gross_weight_per_piece(db, work_order, product) if gross_weight <= 0: raise HTTPException(status_code=400, detail="产品需用清单缺少单件毛重,无法计算成品入库材料成本") receipt_weight = receipt_qty * to_decimal(product.unit_weight_kg) unit_cost = _calculate_finished_material_cost_per_piece(source_material, gross_weight) lot_no = _build_finished_lot_no(db, work_order, product, completion.receipt_time, index) receipt_item = CompletionReceiptItem( completion_receipt_id=completion.id, line_no=index, product_item_id=item_payload.product_item_id, location_id=location.id if location else None, lot_no=lot_no, source_material_lot_id=source_material["source_material_lot_id"], source_material_sub_batch_no=source_material["source_material_sub_batch_no"], source_material_summary=source_material["source_material_summary"], receipt_qty=receipt_qty, receipt_weight_kg=receipt_weight, unit_cost=unit_cost, status="POSTED", remark=item_payload.remark, ) db.add(receipt_item) db.flush() lot = StockLot( lot_no=lot_no, parent_lot_id=source_material["source_material_lot_id"], lot_role="FINISHED_FROM_RAW_BATCH", material_sub_batch_no=None, item_id=item_payload.product_item_id, warehouse_id=payload.warehouse_id, location_id=location.id if location else None, source_doc_type="REWORK_RECEIPT" if is_rework else "FG_RECEIPT", source_doc_id=completion.id, source_line_id=receipt_item.id, source_material_lot_id=source_material["source_material_lot_id"], source_material_sub_batch_no=source_material["source_material_sub_batch_no"], source_material_summary=source_material["source_material_summary"], inbound_qty=receipt_qty, inbound_weight_kg=receipt_weight, remaining_qty=receipt_qty, remaining_weight_kg=receipt_weight, locked_qty=to_decimal(0), locked_weight_kg=to_decimal(0), unit_cost=unit_cost, production_date=completion.receipt_time.date(), expire_date=None, quality_status="PASS", status="AVAILABLE", remark="返工入库;来源库存批次已记录" if is_rework else "完工入库;来源库存批次已记录", ) db.add(lot) db.flush() upsert_stock_balance( db, item_id=item_payload.product_item_id, warehouse_id=payload.warehouse_id, location_id=location.id if location else None, qty_delta=receipt_qty, weight_delta=receipt_weight, available_qty_delta=receipt_qty, available_weight_delta=receipt_weight, unit_cost=unit_cost, ) create_inventory_txn( db, txn_type="REWORK_FINISHED_IN" if is_rework else "FG_IN", item_id=item_payload.product_item_id, warehouse_id=payload.warehouse_id, location_id=location.id if location else None, lot_id=lot.id, qty_change=receipt_qty, weight_change=receipt_weight, unit_cost=unit_cost, source_doc_type="WORK_ORDER" if is_rework else "FG_RECEIPT", source_doc_id=work_order.id if is_rework else completion.id, source_line_id=receipt_item.id, biz_time=completion.receipt_time, operator_user_id=context.user.id, remark="返工入库" if is_rework else "完工入库", amount_basis="QTY", ) work_order.finished_qty = to_decimal(work_order.finished_qty) + receipt_qty db.add(work_order) remaining_receivable_qty -= receipt_qty if payload.settle_work_order: if not is_rework: work_order.status = "SETTLED" work_order.actual_end_time = work_order.actual_end_time or datetime.now() work_order.updated_at = datetime.now() _append_finished_work_order_settle_remark(work_order, payload.settle_remark) db.add(work_order) else: sync_work_order_status(db, work_order.id) db.commit() row = db.execute(get_completion_receipts_query(limit=1).where(CompletionReceipt.id == completion.id)).mappings().first() if not row: raise HTTPException(status_code=500, detail="完工入库单创建后读取失败") return CompletionReceiptRead.model_validate(dict(row))