import re from datetime import datetime from decimal import Decimal, InvalidOperation from io import BytesIO from typing import Any from urllib.parse import quote from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile from fastapi.responses import Response from openpyxl import Workbook, load_workbook from sqlalchemy.exc import IntegrityError from sqlalchemy import and_, delete, func, or_, select from sqlalchemy.orm import Session from app.db.session import get_db from app.models.master_data import Bom, BomItem, Item, Material, Product, StockBalance, Warehouse from app.models.miniapp import ( MiniAppAttendancePoint, MiniAppPersonAttendancePoint, MiniAppPersonRole, MiniAppPersonnel, MiniAppProduct, ) from app.models.operations import ( CompletionReceipt, CompletionReceiptItem, CostAllocation, Delivery, DeliveryItem, InventoryTxn, MaintenanceOrder, OperationReport, Process, ProcessRoute, ProcessRouteOperation, PurchaseOrder, PurchaseOrderItem, PurchaseReceipt, PurchaseReceiptItem, ReturnItem, ReturnOrder, ScrapRecord, StockLot, Supplier, WarehouseLocation, WorkCenter, WorkOrder, WorkOrderMaterial, WorkOrderOperation, ) from app.models.org import Department, Employee, Role, User, UserRole from app.models.planning import MaterialDemand from app.models.sales import SalesOrder, SalesOrderItem from app.schemas.database import ( BomRead, BomUpsert, DepartmentRead, EmployeeCreate, EmployeeRead, EmployeeUpdate, ItemRead, MaterialCreate, MaterialRead, MaterialUpdate, MiniAppPersonnelRead, MiniAppPersonnelUpsert, MiniAppMaterialWastePriceSync, MiniAppAttendancePointRead, MiniAppProductImportResult, MiniAppProductRead, MiniAppProductUpsert, ProcessRead, ProcessRouteRead, ProcessRouteUpsert, ProductCreate, ProductRead, ProductUpdate, WarehouseRead, ) from app.schemas.operations import WarehouseLocationRead, WorkCenterRead from app.schemas.system_permissions import EmployeeOption from app.schemas.domain import MiniAppField, ProductSummary from app.services.auth import require_authenticated_user from app.services.auth import next_employee_code from app.services.mock_data import get_mini_app_fields, get_product_overview_summary from app.services.operations import build_supplier_code from app.services.system_permissions import list_employee_options router = APIRouter(dependencies=[Depends(require_authenticated_user)]) MATERIAL_EXCEL_HEADERS = ["原材料编码", "原材料名称", "材质", "规格", "废料单价", "性能要求", "状态"] MATERIAL_EXCEL_REQUIRED_HEADERS = ["原材料名称", "材质", "规格"] MATERIAL_STATUS_LABELS = { "ACTIVE": "启用", "INACTIVE": "停用", } MINIAPP_ROLE_LABELS = { "worker": "员工", "admin": "管理员", "manager": "经理", } _miniapp_master_sync_signature: tuple[object, ...] | None = None MINIAPP_VALID_ROLES = set(MINIAPP_ROLE_LABELS) PRODUCT_IMPORT_HEADERS = { "考勤点": "attendance_point_name", "项目号": "project_no", "型材号": "profile_no", "产品名称": "product_name", "物料编码": "material_code", "物料名称": "material_name", "供应商": "supplier", "产品净重(kg)": "product_net_weight_kg", "产品毛重(kg)": "product_gross_weight_kg", "允许报废率": "scrap_loss_rate", "废料单价(元/kg)": "waste_price_yuan_per_kg", "工序": "process_name", "冲压方式": "stamping_method", "操作人数": "operator_count", "标准节拍": "standard_beat", } PRODUCT_EXCEL_HEADERS = [ "考勤点", "项目号", "型材号", "产品名称", "物料编码", "物料名称", "供应商", "工序", "冲压方式", "操作人数", "不包含物料转运节拍", "标准节拍", "产品净重(kg)", "产品毛重(kg)", "允许报废率", "废料单价(元/kg)", ] PRODUCT_IMPORT_AUX_FIELDS = ( ("product_net_weight_kg", "产品净重(kg)"), ("product_gross_weight_kg", "产品毛重(kg)"), ("scrap_loss_rate", "允许报废率"), ("waste_price_yuan_per_kg", "废料单价(元/kg)"), ) MINIAPP_CLEANING_STAMPING_METHOD = "清洗" MINIAPP_LEGACY_CLEANING_PROCESS_NAME = "清洗" MINIAPP_MISC_PROCESS_NAME = "杂活" MINIAPP_MISC_STAMPING_METHOD = "处理杂活" MINIAPP_SYNC_REMARK = "小程序产品清单同步生成" JIAHENG_WAREHOUSE_DEFINITIONS = ( { "warehouse_code": "WH-RM-01", "warehouse_name": "原材料库", "warehouse_type": "RAW", "location_code": "RM-A-01", "location_name": "原料主库位", "zone_name": "A区", "remark": "自家料、客供料等生产主材库存", }, { "warehouse_code": "WH-SEMI-01", "warehouse_name": "半成品库", "warehouse_type": "SEMI", "location_code": "SEMI-B-01", "location_name": "半成品暂存位", "zone_name": "B区", "remark": "加工到一半、尚未完成全部工艺的半成品暂存", }, { "warehouse_code": "WH-FG-01", "warehouse_name": "成品库", "warehouse_type": "FINISHED", "location_code": "FG-C-01", "location_name": "成品主库位", "zone_name": "C区", "remark": "完工入库后可发货的成品库存", }, { "warehouse_code": "WH-AUX-01", "warehouse_name": "辅料库", "warehouse_type": "AUX", "location_code": "AUX-D-01", "location_name": "辅料主库位", "zone_name": "D区", "remark": "手套、薄膜、纸箱、螺丝钉等生产消耗辅料库存", }, { "warehouse_code": "WH-SCRAP-01", "warehouse_name": "废料库", "warehouse_type": "SCRAP", "location_code": "SCR-E-01", "location_name": "废料暂存位", "zone_name": "E区", "remark": "生产废料、边角料、委外废料、报废品等废料库存", }, { "warehouse_code": "WH-RETURN-01", "warehouse_name": "退货库", "warehouse_type": "RETURN", "location_code": "RTN-F-01", "location_name": "退货暂存位", "zone_name": "F区", "remark": "客户退回且可返工产品的待处理库存", }, ) @router.get("/product-overview", response_model=ProductSummary) def product_overview() -> ProductSummary: return get_product_overview_summary() @router.get("/mini-app-fields", response_model=list[MiniAppField]) def mini_app_fields() -> list[MiniAppField]: return get_mini_app_fields() @router.get("/departments", response_model=list[DepartmentRead]) def list_departments( limit: int = Query(default=50, ge=1, le=200), db: Session = Depends(get_db), ) -> list[DepartmentRead]: rows = db.scalars(select(Department).order_by(Department.sort_no, Department.id).limit(limit)).all() return [DepartmentRead.model_validate(row) for row in rows] @router.get("/employees", response_model=list[EmployeeRead]) def list_employees( limit: int = Query(default=200, ge=1, le=500), db: Session = Depends(get_db), ) -> list[EmployeeRead]: active_people = _active_miniapp_people(db) _sync_miniapp_personnel_to_employees(db, people=active_people, deactivate_missing=True) db.commit() miniapp_phones = [_normalize_text(person.phone) for person in active_people if _normalize_text(person.phone)] if miniapp_phones: employees = db.scalars( select(Employee) .where(Employee.mobile.in_(miniapp_phones), Employee.status == "ACTIVE") .order_by(Employee.employee_name.asc(), Employee.id.desc()) .limit(limit) ).all() else: employees = [] return _build_employee_reads(db, employees) @router.get("/employee-options", response_model=list[EmployeeOption]) def list_business_employee_options( permission_code: str | None = Query(default=None), limit: int = Query(default=500, ge=1, le=500), db: Session = Depends(get_db), ) -> list[EmployeeOption]: return list_employee_options(db, permission_code=permission_code, limit=limit) @router.get("/miniapp-personnel", response_model=list[MiniAppPersonnelRead]) def list_miniapp_personnel( limit: int = Query(default=200, ge=1, le=500), db: Session = Depends(get_db), ) -> list[MiniAppPersonnelRead]: people = _active_miniapp_people(db, limit=limit) _sync_miniapp_personnel_to_employees(db, people=people, deactivate_missing=True) db.commit() return _build_miniapp_personnel_reads(db, people) @router.get("/miniapp-attendance-points", response_model=list[MiniAppAttendancePointRead]) def list_miniapp_attendance_points( include_inactive: bool = Query(default=False), db: Session = Depends(get_db), ) -> list[MiniAppAttendancePointRead]: stmt = select(MiniAppAttendancePoint).order_by(MiniAppAttendancePoint.is_active.desc(), MiniAppAttendancePoint.name.asc()) if not include_inactive: stmt = stmt.where(MiniAppAttendancePoint.is_active.is_(True)) rows = db.scalars(stmt).all() return [_miniapp_attendance_point_read(row) for row in rows] @router.post("/miniapp-personnel", response_model=MiniAppPersonnelRead) def create_miniapp_person(payload: MiniAppPersonnelUpsert, db: Session = Depends(get_db)) -> MiniAppPersonnelRead: phone = _normalize_phone(payload.phone) person = db.get(MiniAppPersonnel, phone) if person and _miniapp_person_has_roles(db, phone): raise HTTPException(status_code=400, detail="该手机号已在小程序人员清单中存在") if not person: person = MiniAppPersonnel(phone=phone) person.name = _normalize_text(payload.name) person.is_temporary = bool(payload.is_temporary) person.temporary_expires_at = payload.temporary_expires_at db.add(person) _replace_miniapp_person_roles(db, phone, payload.roles) _replace_miniapp_person_attendance_points(db, phone, payload.attendance_point_names) _sync_miniapp_personnel_to_employees(db, people=[person]) db.commit() db.refresh(person) return _build_miniapp_personnel_reads(db, [person])[0] @router.put("/miniapp-personnel/{phone}", response_model=MiniAppPersonnelRead) def update_miniapp_person(phone: str, payload: MiniAppPersonnelUpsert, db: Session = Depends(get_db)) -> MiniAppPersonnelRead: current_phone = _normalize_phone(phone) next_phone = _normalize_phone(payload.phone) person = db.get(MiniAppPersonnel, current_phone) if not person: raise HTTPException(status_code=404, detail="小程序人员不存在") if next_phone != current_phone and db.get(MiniAppPersonnel, next_phone): raise HTTPException(status_code=400, detail="新的手机号已被其他人员使用") if next_phone != current_phone: # 小程序多张业务表以 phone 做外键,手机号不做原地改键,避免历史报工断链。 raise HTTPException(status_code=400, detail="人员手机号是小程序身份主键,不能直接修改;请新增人员后停用旧人员") person.name = _normalize_text(payload.name) person.is_temporary = bool(payload.is_temporary) person.temporary_expires_at = payload.temporary_expires_at _replace_miniapp_person_roles(db, current_phone, payload.roles) _replace_miniapp_person_attendance_points(db, current_phone, payload.attendance_point_names) db.add(person) _sync_miniapp_personnel_to_employees(db, people=[person]) db.commit() db.refresh(person) return _build_miniapp_personnel_reads(db, [person])[0] @router.delete("/miniapp-personnel/{phone}") def delete_miniapp_person(phone: str, db: Session = Depends(get_db)) -> dict[str, str]: normalized_phone = _normalize_phone(phone) person = db.get(MiniAppPersonnel, normalized_phone) if not person: raise HTTPException(status_code=404, detail="小程序人员不存在") try: db.execute(delete(MiniAppPersonAttendancePoint).where(MiniAppPersonAttendancePoint.phone == normalized_phone)) db.execute(delete(MiniAppPersonRole).where(MiniAppPersonRole.phone == normalized_phone)) db.delete(person) db.commit() except IntegrityError as exc: db.rollback() raise HTTPException(status_code=400, detail="该人员已有小程序报工/通知等业务记录,不能删除") from exc return {"message": f"小程序人员 {person.name} 已删除"} @router.get("/miniapp-products", response_model=list[MiniAppProductRead]) def list_miniapp_products( limit: int = Query(default=500, ge=1, le=1000), db: Session = Depends(get_db), ) -> list[MiniAppProductRead]: rows = db.scalars( select(MiniAppProduct) .order_by(MiniAppProduct.updated_at.desc(), MiniAppProduct.attendance_point_name.asc()) .limit(limit) ).all() return [_miniapp_product_read(row) for row in rows] @router.get("/miniapp-products/export-template") def export_miniapp_products_template() -> Response: workbook = _build_miniapp_product_workbook([]) return _excel_response(workbook, "基础资料导入模版.xlsx") @router.get("/miniapp-products/export") def export_miniapp_products(db: Session = Depends(get_db)) -> Response: rows = db.scalars( select(MiniAppProduct).order_by( MiniAppProduct.attendance_point_name.asc(), MiniAppProduct.project_no.asc(), MiniAppProduct.product_name.asc(), MiniAppProduct.process_name.asc(), ) ).all() workbook = _build_miniapp_product_workbook(rows) return _excel_response(workbook, f"基础资料_{datetime.now().strftime('%Y%m%d%H%M%S')}.xlsx") @router.post("/miniapp-products/import", response_model=MiniAppProductImportResult) async def import_miniapp_products( file: UploadFile = File(...), db: Session = Depends(get_db), ) -> MiniAppProductImportResult: content = await file.read() try: result = _import_miniapp_product_excel(db, content) except ValueError as exc: db.rollback() raise HTTPException(status_code=400, detail=str(exc)) from exc return result @router.post("/miniapp-materials/sync") def sync_miniapp_materials( force: bool = Query(default=False), db: Session = Depends(get_db), ) -> dict[str, object]: global _miniapp_master_sync_signature current_signature = _get_miniapp_master_sync_signature(db) if not force and _miniapp_master_sync_signature == current_signature: return { "synced": False, "skipped_by_cache": True, "message": "小程序产品清单未变化,已跳过产品需规同步", "product_specs": { "matched": 0, "created": 0, "product_updated": 0, "bom_updated": 0, "route_updated": 0, "stale_deleted": 0, "stale_blocked": 0, "stale_deactivated": 0, }, } product_result = _sync_miniapp_product_specs_to_master_data(db) db.commit() _miniapp_master_sync_signature = _get_miniapp_master_sync_signature(db) return { "created": 0, "linked": 0, "supplier_updated": 0, "skipped": 0, "synced": True, "skipped_by_cache": False, "message": "原材料名录已改为独立维护,本次仅同步产品需规", "product_specs": product_result, } @router.post("/miniapp-material-waste-price/sync") def sync_miniapp_material_waste_price( payload: MiniAppMaterialWastePriceSync, db: Session = Depends(get_db), ) -> dict[str, object]: return {"updated": 0, "message": "原材料名录已改为独立维护,不再从小程序同步废料单价"} @router.post("/miniapp-products", response_model=MiniAppProductRead) def save_miniapp_product( payload: MiniAppProductUpsert, sync: bool = Query(default=True), db: Session = Depends(get_db), ) -> MiniAppProductRead: row = _upsert_miniapp_product(db, payload) if sync: _sync_miniapp_product_specs_to_master_data(db) db.commit() if sync: _mark_miniapp_master_sync_fresh(db) db.refresh(row) return _miniapp_product_read(row) @router.delete("/miniapp-products") def delete_miniapp_product( attendance_point_name: str = Query(min_length=1), project_no: str = Query(min_length=1), product_name: str = Query(min_length=1), device_no: str = Query(default=""), process_name: str = Query(default=""), db: Session = Depends(get_db), ) -> dict[str, str]: key = { "attendance_point_name": _normalize_text(attendance_point_name), "project_no": _normalize_text(project_no), "product_name": _normalize_text(product_name), "device_no": _normalize_text(device_no), "process_name": _normalize_text(process_name), } row = db.get(MiniAppProduct, key) if not row: raise HTTPException(status_code=404, detail="小程序产品清单记录不存在") label = f"{row.project_no} / {row.product_name} / {row.process_name or '-'}" db.delete(row) db.flush() _sync_miniapp_product_specs_to_master_data(db) db.commit() _mark_miniapp_master_sync_fresh(db) return {"message": f"小程序产品清单 {label} 已删除,ERP 对应需规已同步停用"} @router.post("/employees", response_model=EmployeeRead) def create_employee(payload: EmployeeCreate, db: Session = Depends(get_db)) -> EmployeeRead: department = db.get(Department, payload.dept_id) if not department: raise HTTPException(status_code=404, detail="所属部门不存在") employee = Employee( employee_code=next_employee_code(db), employee_name=payload.employee_name, dept_id=payload.dept_id, mobile=payload.mobile, gender=payload.gender, hire_date=payload.hire_date, job_title=payload.job_title, shift_code=payload.shift_code, manager_employee_id=payload.manager_employee_id, is_operator=int(payload.is_operator), is_workshop_staff=int(payload.is_workshop_staff), status=payload.status, remark=payload.remark, ) db.add(employee) db.commit() db.refresh(employee) return _build_employee_reads(db, [employee])[0] @router.put("/employees/{employee_id}", response_model=EmployeeRead) def update_employee(employee_id: int, payload: EmployeeUpdate, db: Session = Depends(get_db)) -> EmployeeRead: employee = db.get(Employee, employee_id) if not employee: raise HTTPException(status_code=404, detail="员工不存在") department = db.get(Department, payload.dept_id) if not department: raise HTTPException(status_code=404, detail="所属部门不存在") employee.employee_name = payload.employee_name employee.dept_id = payload.dept_id employee.mobile = payload.mobile employee.gender = payload.gender employee.hire_date = payload.hire_date employee.job_title = payload.job_title employee.shift_code = payload.shift_code employee.manager_employee_id = payload.manager_employee_id employee.is_operator = int(payload.is_operator) employee.is_workshop_staff = int(payload.is_workshop_staff) employee.status = payload.status employee.remark = payload.remark db.add(employee) db.commit() db.refresh(employee) return _build_employee_reads(db, [employee])[0] @router.delete("/employees/{employee_id}") def delete_employee(employee_id: int, db: Session = Depends(get_db)) -> dict[str, str]: employee = db.get(Employee, employee_id) if not employee: raise HTTPException(status_code=404, detail="员工不存在") _ensure_employee_can_delete(db, employee_id) employee_name = employee.employee_name db.delete(employee) db.commit() return {"message": f"员工 {employee_name} 已删除"} @router.get("/warehouses", response_model=list[WarehouseRead]) def list_warehouses( limit: int = Query(default=50, ge=1, le=200), db: Session = Depends(get_db), ) -> list[WarehouseRead]: _ensure_jiaheng_warehouses(db) rows = db.scalars(select(Warehouse).order_by(Warehouse.id.desc()).limit(limit)).all() return [WarehouseRead.model_validate(row) for row in rows] @router.get("/locations", response_model=list[WarehouseLocationRead]) def list_locations( warehouse_id: int | None = Query(default=None), limit: int = Query(default=100, ge=1, le=500), db: Session = Depends(get_db), ) -> list[WarehouseLocationRead]: _ensure_jiaheng_warehouses(db) stmt = ( select( WarehouseLocation.id.label("id"), WarehouseLocation.warehouse_id.label("warehouse_id"), Warehouse.warehouse_code.label("warehouse_code"), Warehouse.warehouse_name.label("warehouse_name"), WarehouseLocation.location_code.label("location_code"), WarehouseLocation.location_name.label("location_name"), WarehouseLocation.zone_name.label("zone_name"), WarehouseLocation.is_default.label("is_default"), WarehouseLocation.is_locked.label("is_locked"), WarehouseLocation.status.label("status"), ) .join(Warehouse, Warehouse.id == WarehouseLocation.warehouse_id) .order_by(WarehouseLocation.warehouse_id, WarehouseLocation.is_default.desc(), WarehouseLocation.id) .limit(limit) ) if warehouse_id: stmt = stmt.where(WarehouseLocation.warehouse_id == warehouse_id) rows = db.execute(stmt).mappings().all() return [WarehouseLocationRead.model_validate(dict(row)) for row in rows] @router.get("/work-centers", response_model=list[WorkCenterRead]) def list_work_centers( limit: int = Query(default=100, ge=1, le=500), db: Session = Depends(get_db), ) -> list[WorkCenterRead]: stmt = ( select( WorkCenter.id.label("id"), WorkCenter.center_code.label("center_code"), WorkCenter.center_name.label("center_name"), WorkCenter.dept_id.label("dept_id"), Department.dept_name.label("dept_name"), WorkCenter.center_type.label("center_type"), WorkCenter.shift_pattern.label("shift_pattern"), WorkCenter.capacity_hours_per_day.label("capacity_hours_per_day"), WorkCenter.status.label("status"), ) .join(Department, Department.id == WorkCenter.dept_id) .order_by(WorkCenter.id.desc()) .limit(limit) ) rows = db.execute(stmt).mappings().all() return [WorkCenterRead.model_validate(dict(row)) for row in rows] @router.get("/items", response_model=list[ItemRead]) def list_items( item_type: str | None = Query(default=None), limit: int = Query(default=50, ge=1, le=200), db: Session = Depends(get_db), ) -> list[ItemRead]: stmt = select(Item).order_by(Item.id.desc()).limit(limit) if item_type: stmt = stmt.where(Item.item_type == item_type) rows = db.scalars(stmt).all() return [ItemRead.model_validate(row) for row in rows] @router.get("/materials", response_model=list[MaterialRead]) def list_materials( limit: int = Query(default=50, ge=1, le=200), db: Session = Depends(get_db), ) -> list[MaterialRead]: stmt = _material_export_stmt().order_by(None).order_by(Material.id.desc()).limit(limit) rows = db.execute(stmt).mappings().all() return [MaterialRead.model_validate(_normalize_material_read_payload(dict(row))) for row in rows] @router.get("/materials/export") def export_materials(db: Session = Depends(get_db)) -> Response: rows = db.execute(_material_export_stmt()).mappings().all() workbook = _build_material_workbook(rows) return _excel_workbook_response(workbook, f"原材料名录_{datetime.now().strftime('%Y%m%d%H%M%S')}.xlsx", "materials.xlsx") @router.post("/materials/import") async def import_materials( file: UploadFile = File(...), db: Session = Depends(get_db), ) -> dict[str, object]: content = await file.read() try: result = _import_material_excel(db, content) except ValueError as exc: db.rollback() raise HTTPException(status_code=400, detail=str(exc)) from exc return result @router.post("/materials", response_model=MaterialRead) def create_material(payload: MaterialCreate, db: Session = Depends(get_db)) -> MaterialRead: payload = payload.model_copy(update={"item_code": _build_material_code(db, payload)}) _validate_material_payload(payload) existing = db.scalar(select(Item).where(Item.item_code == payload.item_code)) if existing: raise HTTPException(status_code=400, detail="原材料编码已存在") if payload.default_supplier_id and not db.get(Supplier, payload.default_supplier_id): raise HTTPException(status_code=404, detail="默认供应商不存在") item = Item( item_code=payload.item_code, item_name=payload.item_name, item_type="RAW_MATERIAL", specification=payload.specification, material_grade=payload.material_grade, unit_weight_kg=Decimal(str(payload.unit_weight_kg)), safety_stock_weight_kg=Decimal(str(payload.safety_stock_weight_kg)), scrap_sale_price=Decimal(str(payload.scrap_sale_price)), status=payload.status, ) db.add(item) db.flush() material = Material( item_id=item.id, default_supplier_id=payload.default_supplier_id, purchase_calc_mode="BY_WEIGHT", min_purchase_qty=Decimal(str(payload.min_purchase_qty)), purchase_multiple_qty=Decimal("0"), purchase_multiple_weight_kg=Decimal(str(payload.purchase_multiple_weight_kg)), lead_time_days=payload.lead_time_days, quality_rule=_material_remark_value(payload), ) db.add(material) db.commit() return _get_material_read(db, item.id) @router.put("/materials/{item_id}", response_model=MaterialRead) def update_material(item_id: int, payload: MaterialUpdate, db: Session = Depends(get_db)) -> MaterialRead: item = db.get(Item, item_id) if not item or item.item_type != "RAW_MATERIAL": raise HTTPException(status_code=404, detail="原材料不存在") payload = payload.model_copy(update={"item_code": item.item_code}) _validate_material_payload(payload) material = db.scalar(select(Material).where(Material.item_id == item_id)) if not material: raise HTTPException(status_code=404, detail="原材料扩展信息不存在") if payload.default_supplier_id and not db.get(Supplier, payload.default_supplier_id): raise HTTPException(status_code=404, detail="默认供应商不存在") item.item_name = payload.item_name item.specification = payload.specification item.material_grade = payload.material_grade item.unit_weight_kg = Decimal(str(payload.unit_weight_kg)) item.safety_stock_weight_kg = Decimal(str(payload.safety_stock_weight_kg)) item.scrap_sale_price = Decimal(str(payload.scrap_sale_price)) item.status = payload.status material.default_supplier_id = payload.default_supplier_id material.purchase_calc_mode = "BY_WEIGHT" material.min_purchase_qty = Decimal(str(payload.min_purchase_qty)) material.purchase_multiple_qty = Decimal("0") material.purchase_multiple_weight_kg = Decimal(str(payload.purchase_multiple_weight_kg)) material.lead_time_days = payload.lead_time_days material.quality_rule = _material_remark_value(payload) db.add(item) db.add(material) db.commit() return _get_material_read(db, item_id) @router.delete("/materials/{item_id}") def delete_material(item_id: int, db: Session = Depends(get_db)) -> dict[str, str]: item = db.get(Item, item_id) if not item or item.item_type != "RAW_MATERIAL": raise HTTPException(status_code=404, detail="原材料不存在") material = db.scalar(select(Material).where(Material.item_id == item_id)) if not material: raise HTTPException(status_code=404, detail="原材料扩展信息不存在") _ensure_material_can_delete(db, item_id) item_name = item.item_name db.delete(material) db.flush() db.delete(item) db.commit() return {"message": f"原材料 {item_name} 已删除"} @router.get("/processes", response_model=list[ProcessRead]) def list_processes( limit: int = Query(default=100, ge=1, le=500), db: Session = Depends(get_db), ) -> list[ProcessRead]: rows = db.execute( select( Process.id.label("process_id"), Process.process_code.label("process_code"), Process.process_name.label("process_name"), Process.process_type.label("process_type"), Process.is_report_required.label("is_report_required"), Process.is_quality_gate.label("is_quality_gate"), Process.status.label("status"), ) .order_by(Process.id) .limit(limit) ).mappings().all() return [ProcessRead.model_validate(dict(row)) for row in rows] @router.get("/boms", response_model=list[BomRead]) def list_boms( limit: int = Query(default=100, ge=1, le=500), db: Session = Depends(get_db), ) -> list[BomRead]: return _list_bom_reads(db, limit=limit) @router.post("/boms", response_model=BomRead) def create_bom(payload: BomUpsert, db: Session = Depends(get_db)) -> BomRead: payload = payload.model_copy(update={"bom_code": _build_bom_code(db, payload.product_item_id)}) _validate_bom_payload(db, payload) if payload.status == "ACTIVE" and not payload.is_default and not _has_active_default_bom(db, payload.product_item_id): raise HTTPException(status_code=400, detail="当前产品还没有默认BOM,首个生效版本必须设为默认") if _bom_code_version_exists(db, payload.bom_code, payload.version_no): raise HTTPException(status_code=400, detail="相同BOM编码和版本号已存在") if payload.is_default: _clear_default_bom(db, payload.product_item_id) bom = _create_versioned_master_record(db, lambda: _create_bom_record(db, payload)) _commit_versioned_master_changes(db) return _get_bom_read(db, bom.id) @router.put("/boms/{bom_id}", response_model=BomRead) def update_bom(bom_id: int, payload: BomUpsert, db: Session = Depends(get_db)) -> BomRead: bom = db.get(Bom, bom_id) if not bom: raise HTTPException(status_code=404, detail="BOM不存在") _ensure_active_master_record(bom.status, "已停用的BOM只读,不允许修改") payload = payload.model_copy(update={"bom_code": bom.bom_code}) _validate_bom_payload(db, payload) version_changed = _normalize_text(payload.version_no) != _normalize_text(bom.version_no) if version_changed: if _bom_code_version_exists(db, payload.bom_code, payload.version_no): raise HTTPException(status_code=400, detail="相同BOM编码和版本号已存在") if payload.status == "ACTIVE" and not payload.is_default and not _has_active_default_bom(db, payload.product_item_id): raise HTTPException(status_code=400, detail="当前产品至少需要保留一套默认BOM,请先建立新的默认版本") if payload.is_default: _clear_default_bom(db, payload.product_item_id) new_bom = _create_versioned_master_record(db, lambda: _create_bom_record(db, payload)) _commit_versioned_master_changes(db) return _get_bom_read(db, new_bom.id) if _bom_code_version_exists(db, payload.bom_code, payload.version_no, exclude_bom_id=bom_id): raise HTTPException(status_code=400, detail="相同BOM编码和版本号已存在") if payload.status == "ACTIVE" and not payload.is_default and not _has_active_default_bom( db, payload.product_item_id, exclude_bom_id=bom_id ): raise HTTPException(status_code=400, detail="当前产品至少需要保留一套默认BOM,请先建立新的默认版本") if _bom_has_usage(db, bom_id): raise HTTPException(status_code=400, detail="该BOM已被MRP或工单引用,请修改版本号后保存为新版本") if payload.is_default: _clear_default_bom(db, payload.product_item_id, exclude_bom_id=bom_id) bom.bom_code = _normalize_text(payload.bom_code) bom.product_item_id = payload.product_item_id bom.version_no = _normalize_text(payload.version_no) bom.is_default = int(payload.is_default) bom.status = payload.status bom.effective_date = payload.effective_date bom.expire_date = payload.expire_date bom.remark = payload.remark db.add(bom) db.execute(delete(BomItem).where(BomItem.bom_id == bom_id)) db.flush() for item in payload.items: db.add( BomItem( bom_id=bom.id, seq_no=item.seq_no, material_item_id=item.material_item_id, usage_type=_normalize_text(item.usage_type).upper(), qty_per_piece=Decimal(str(item.qty_per_piece)), net_weight_per_piece_kg=Decimal(str(item.net_weight_per_piece_kg)), gross_weight_per_piece_kg=Decimal(str(item.gross_weight_per_piece_kg)), loss_rate=Decimal(str(item.loss_rate)), issue_over_tolerance_rate=Decimal(str(item.issue_over_tolerance_rate)), remark=item.remark, ) ) _commit_versioned_master_changes(db) return _get_bom_read(db, bom_id) @router.delete("/boms/{bom_id}") def delete_bom(bom_id: int, db: Session = Depends(get_db)) -> dict[str, str]: bom = db.get(Bom, bom_id) if not bom: raise HTTPException(status_code=404, detail="BOM不存在") _ensure_active_master_record(bom.status, "已停用的BOM只读,不允许删除") _ensure_bom_can_delete(db, bom) bom_code = bom.bom_code db.execute(delete(BomItem).where(BomItem.bom_id == bom_id)) db.flush() db.delete(bom) db.commit() return {"message": f"BOM {bom_code} 已删除"} @router.get("/process-routes", response_model=list[ProcessRouteRead]) def list_process_routes( limit: int = Query(default=100, ge=1, le=500), db: Session = Depends(get_db), ) -> list[ProcessRouteRead]: return _list_process_route_reads(db, limit=limit) @router.post("/process-routes", response_model=ProcessRouteRead) def create_process_route(payload: ProcessRouteUpsert, db: Session = Depends(get_db)) -> ProcessRouteRead: payload = payload.model_copy(update={"route_code": _build_route_code(db, payload.product_item_id)}) _validate_process_route_payload(db, payload) if payload.status == "ACTIVE" and not payload.is_default and not _has_active_default_route(db, payload.product_item_id): raise HTTPException(status_code=400, detail="当前产品还没有默认工艺路线,首个生效版本必须设为默认") if _route_code_version_exists(db, payload.route_code, payload.version_no): raise HTTPException(status_code=400, detail="相同工艺路线编码和版本号已存在") if payload.is_default: _clear_default_route(db, payload.product_item_id) route = _create_versioned_master_record(db, lambda: _create_process_route_record(db, payload)) _commit_versioned_master_changes(db) return _get_process_route_read(db, route.id) @router.put("/process-routes/{route_id}", response_model=ProcessRouteRead) def update_process_route(route_id: int, payload: ProcessRouteUpsert, db: Session = Depends(get_db)) -> ProcessRouteRead: route = db.get(ProcessRoute, route_id) if not route: raise HTTPException(status_code=404, detail="工艺路线不存在") _ensure_active_master_record(route.status, "已停用的工艺路线只读,不允许修改") payload = payload.model_copy(update={"route_code": route.route_code}) _validate_process_route_payload(db, payload) version_changed = _normalize_text(payload.version_no) != _normalize_text(route.version_no) if version_changed: if _route_code_version_exists(db, payload.route_code, payload.version_no): raise HTTPException(status_code=400, detail="相同工艺路线编码和版本号已存在") if payload.status == "ACTIVE" and not payload.is_default and not _has_active_default_route(db, payload.product_item_id): raise HTTPException(status_code=400, detail="当前产品至少需要保留一套默认工艺路线,请先建立新的默认版本") if payload.is_default: _clear_default_route(db, payload.product_item_id) new_route = _create_versioned_master_record(db, lambda: _create_process_route_record(db, payload)) _commit_versioned_master_changes(db) return _get_process_route_read(db, new_route.id) if _route_code_version_exists(db, payload.route_code, payload.version_no, exclude_route_id=route_id): raise HTTPException(status_code=400, detail="相同工艺路线编码和版本号已存在") if payload.status == "ACTIVE" and not payload.is_default and not _has_active_default_route( db, payload.product_item_id, exclude_route_id=route_id ): raise HTTPException(status_code=400, detail="当前产品至少需要保留一套默认工艺路线,请先建立新的默认版本") if _route_has_usage(db, route_id): raise HTTPException(status_code=400, detail="该工艺路线已被工单引用,请修改版本号后保存为新版本") _ensure_route_payload_does_not_touch_inactive_operations(db, route_id, payload) if payload.is_default: _clear_default_route(db, payload.product_item_id, exclude_route_id=route_id) route.route_code = _normalize_text(payload.route_code) route.route_name = _normalize_text(payload.route_name) route.product_item_id = payload.product_item_id route.version_no = _normalize_text(payload.version_no) route.is_default = int(payload.is_default) route.status = payload.status route.effective_date = payload.effective_date route.expire_date = payload.expire_date route.remark = payload.remark db.add(route) db.execute( delete(ProcessRouteOperation).where( ProcessRouteOperation.route_id == route_id, ProcessRouteOperation.status != "INACTIVE", ) ) db.flush() for operation in payload.operations: db.add( ProcessRouteOperation( route_id=route.id, seq_no=operation.seq_no, process_id=operation.process_id, work_center_id=operation.work_center_id, operation_name=operation.operation_name, std_setup_minutes=Decimal(str(operation.std_setup_minutes)), std_cycle_seconds=Decimal(str(operation.std_cycle_seconds or 0)), std_labor_cost=Decimal("0"), std_machine_cost=Decimal("0"), std_outsource_cost=Decimal("0"), allowed_scrap_rate=Decimal(str(operation.allowed_scrap_rate)), report_required=int(operation.report_required), is_key_operation=int(operation.is_key_operation), status=operation.status, remark=operation.remark, ) ) _commit_versioned_master_changes(db) return _get_process_route_read(db, route_id) @router.delete("/process-routes/{route_id}") def delete_process_route(route_id: int, db: Session = Depends(get_db)) -> dict[str, str]: route = db.get(ProcessRoute, route_id) if not route: raise HTTPException(status_code=404, detail="工艺路线不存在") _ensure_active_master_record(route.status, "已停用的工艺路线只读,不允许删除") _ensure_route_can_delete(db, route) route_code = route.route_code db.execute(delete(ProcessRouteOperation).where(ProcessRouteOperation.route_id == route_id)) db.flush() db.delete(route) db.commit() return {"message": f"工艺路线 {route_code} 已删除"} @router.get("/products", response_model=list[ProductRead]) def list_products( limit: int = Query(default=50, ge=1, le=200), db: Session = Depends(get_db), ) -> list[ProductRead]: stmt = ( select( Product.item_id.label("item_id"), Item.item_code.label("item_code"), Item.item_name.label("item_name"), Item.specification.label("specification"), Product.drawing_no.label("drawing_no"), Product.product_family.label("product_family"), Product.version_no.label("version_no"), Item.unit_weight_kg.label("unit_weight_kg"), Product.allowed_scrap_rate.label("allowed_scrap_rate"), Product.standard_output_qty.label("standard_output_qty"), Product.quality_standard.label("quality_standard"), Item.safety_stock_weight_kg.label("safety_stock_weight_kg"), Item.status.label("status"), ) .join(Item, Item.id == Product.item_id) .order_by(Product.id.desc()) .limit(limit) ) rows = db.execute(stmt).mappings().all() return [ProductRead.model_validate(dict(row)) for row in rows] @router.post("/products", response_model=ProductRead) def create_product(payload: ProductCreate, db: Session = Depends(get_db)) -> ProductRead: payload = payload.model_copy(update={"item_code": _build_product_code(db, payload)}) _validate_product_payload(payload) if _item_code_used_by_non_product(db, payload.item_code): raise HTTPException(status_code=400, detail="该产品编码已被其他物料占用") if _product_code_version_exists(db, payload.item_code, payload.version_no): raise HTTPException(status_code=400, detail="相同产品编码和版本号已存在") item_id = _create_versioned_master_record(db, lambda: _create_product_record(db, payload)) _commit_versioned_master_changes(db) return _get_product_read(db, item_id) @router.put("/products/{item_id}", response_model=ProductRead) def update_product(item_id: int, payload: ProductUpdate, db: Session = Depends(get_db)) -> ProductRead: item = db.get(Item, item_id) if not item or item.item_type not in {"PRODUCT", "FINISHED_GOOD"}: raise HTTPException(status_code=404, detail="产品不存在") _ensure_active_master_record(item.status, "已停用的产品需规只读,不允许修改") payload = payload.model_copy(update={"item_code": item.item_code}) _validate_product_payload(payload) product = db.scalar(select(Product).where(Product.item_id == item_id)) if not product: raise HTTPException(status_code=404, detail="产品扩展信息不存在") if _item_code_used_by_non_product(db, payload.item_code, exclude_item_id=item_id): raise HTTPException(status_code=400, detail="该产品编码已被其他物料占用") version_changed = _normalize_text(payload.version_no) != _normalize_text(product.version_no) if version_changed: if _product_code_version_exists(db, payload.item_code, payload.version_no): raise HTTPException(status_code=400, detail="相同产品编码和版本号已存在") new_item_id = _create_versioned_master_record(db, lambda: _create_product_record(db, payload)) _commit_versioned_master_changes(db) return _get_product_read(db, new_item_id) if _product_code_version_exists(db, payload.item_code, payload.version_no, exclude_item_id=item_id): raise HTTPException(status_code=400, detail="相同产品编码和版本号已存在") if _product_has_usage(db, item_id): raise HTTPException(status_code=400, detail="该产品已被业务引用,请修改版本号后保存为新版本") item.item_code = _normalize_text(payload.item_code) item.item_name = _normalize_text(payload.item_name) item.specification = payload.specification item.unit_weight_kg = Decimal(str(payload.unit_weight_kg)) item.safety_stock_weight_kg = Decimal(str(payload.safety_stock_weight_kg)) item.status = payload.status product.drawing_no = payload.drawing_no product.product_family = payload.product_family product.version_no = _normalize_text(payload.version_no) product.standard_output_qty = Decimal(str(payload.standard_output_qty)) product.allowed_scrap_rate = Decimal(str(payload.allowed_scrap_rate)) product.quality_standard = payload.quality_standard db.add(item) db.add(product) _commit_versioned_master_changes(db) return _get_product_read(db, item_id) @router.delete("/products/{item_id}") def delete_product(item_id: int, db: Session = Depends(get_db)) -> dict[str, str]: item = db.get(Item, item_id) if not item or item.item_type not in {"PRODUCT", "FINISHED_GOOD"}: raise HTTPException(status_code=404, detail="产品不存在") _ensure_active_master_record(item.status, "已停用的产品需规只读,不允许删除") product = db.scalar(select(Product).where(Product.item_id == item_id)) if not product: raise HTTPException(status_code=404, detail="产品扩展信息不存在") _ensure_product_can_delete(db, item_id) item_name = item.item_name db.delete(product) db.flush() db.delete(item) db.commit() return {"message": f"产品 {item_name} 已删除"} @router.delete("/product-specs/{item_id}") def delete_product_spec(item_id: int, db: Session = Depends(get_db)) -> dict[str, str]: item = db.get(Item, item_id) if not item or item.item_type not in {"PRODUCT", "FINISHED_GOOD"}: raise HTTPException(status_code=404, detail="产品需规不存在") _ensure_active_master_record(item.status, "已停用的产品需规只读,不允许删除") product = db.scalar(select(Product).where(Product.item_id == item_id)) if not product: raise HTTPException(status_code=404, detail="产品扩展信息不存在") _ensure_product_spec_can_delete(db, item_id) item_name = item.item_name _delete_product_spec_records(db, item, product) db.commit() return {"message": f"产品需规 {item_name} 已删除"} def _normalize_phone(value: str | None) -> str: phone = _normalize_text(value) if not phone: raise HTTPException(status_code=400, detail="手机号不能为空") if len(phone) > 20: raise HTTPException(status_code=400, detail="手机号长度不能超过20位") return phone def _clean_miniapp_roles(roles: list[str] | None) -> list[str]: cleaned: list[str] = [] for role in roles or ["worker"]: normalized = _normalize_text(role) if normalized not in MINIAPP_VALID_ROLES: raise HTTPException(status_code=400, detail=f"小程序角色不支持:{normalized}") if normalized not in cleaned: cleaned.append(normalized) return cleaned or ["worker"] def _replace_miniapp_person_roles(db: Session, phone: str, roles: list[str] | None) -> None: db.execute(delete(MiniAppPersonRole).where(MiniAppPersonRole.phone == phone)) for role in _clean_miniapp_roles(roles): db.add(MiniAppPersonRole(phone=phone, role=role)) def _miniapp_person_has_roles(db: Session, phone: str) -> bool: return bool(db.scalar(select(func.count()).select_from(MiniAppPersonRole).where(MiniAppPersonRole.phone == phone))) def _miniapp_attendance_point_read(row: MiniAppAttendancePoint) -> MiniAppAttendancePointRead: return MiniAppAttendancePointRead( name=row.name, latitude=_optional_float(row.latitude), longitude=_optional_float(row.longitude), radius_meters=int(row.radius_meters or 500), remark=row.remark, is_active=bool(row.is_active), created_at=row.created_at, updated_at=row.updated_at, ) def _default_miniapp_attendance_point_names(db: Session) -> list[str]: names = [ name for name in db.scalars( select(MiniAppAttendancePoint.name) .where(MiniAppAttendancePoint.is_active.is_(True)) .order_by(MiniAppAttendancePoint.name.asc()) ).all() if _normalize_text(name) ] return names def _clean_miniapp_attendance_point_names(db: Session, point_names: list[str] | None) -> list[str]: cleaned = [] for name in point_names or _default_miniapp_attendance_point_names(db): point_name = _normalize_text(name) if point_name and point_name not in cleaned: cleaned.append(point_name) if not cleaned: return [] existing = set(db.scalars(select(MiniAppAttendancePoint.name).where(MiniAppAttendancePoint.name.in_(cleaned))).all()) missing = [name for name in cleaned if name not in existing] if missing: raise HTTPException(status_code=400, detail=f"小程序考勤点不存在:{'、'.join(missing)}") return cleaned def _replace_miniapp_person_attendance_points(db: Session, phone: str, point_names: list[str] | None) -> None: db.execute(delete(MiniAppPersonAttendancePoint).where(MiniAppPersonAttendancePoint.phone == phone)) for point_name in _clean_miniapp_attendance_point_names(db, point_names): db.add(MiniAppPersonAttendancePoint(phone=phone, attendance_point_name=point_name)) def _build_miniapp_personnel_reads(db: Session, people: list[MiniAppPersonnel]) -> list[MiniAppPersonnelRead]: if not people: return [] phones = [person.phone for person in people] role_rows = db.execute( select(MiniAppPersonRole.phone, MiniAppPersonRole.role) .where(MiniAppPersonRole.phone.in_(phones)) .order_by(MiniAppPersonRole.phone, MiniAppPersonRole.role) ).all() role_map: dict[str, list[str]] = {} for phone, role in role_rows: role_map.setdefault(phone, []) if role not in role_map[phone]: role_map[phone].append(role) point_rows = db.execute( select(MiniAppPersonAttendancePoint.phone, MiniAppPersonAttendancePoint.attendance_point_name) .where(MiniAppPersonAttendancePoint.phone.in_(phones)) .order_by(MiniAppPersonAttendancePoint.phone, MiniAppPersonAttendancePoint.attendance_point_name) ).all() point_map: dict[str, list[str]] = {} for phone, point_name in point_rows: point_map.setdefault(phone, []) if point_name not in point_map[phone]: point_map[phone].append(point_name) now = datetime.now() result: list[MiniAppPersonnelRead] = [] for person in people: roles = role_map.get(person.phone, []) role_names = [MINIAPP_ROLE_LABELS.get(role, role) for role in roles] result.append( MiniAppPersonnelRead( phone=person.phone, name=person.name, role=roles[0] if roles else None, role_name="、".join(role_names) if role_names else None, roles=roles, role_names=role_names, attendance_point_names=point_map.get(person.phone, []), is_temporary=bool(person.is_temporary), temporary_expires_at=person.temporary_expires_at, temporary_expired=bool(person.is_temporary and person.temporary_expires_at and person.temporary_expires_at <= now), created_at=person.created_at, updated_at=person.updated_at, ) ) return result def _active_miniapp_people(db: Session, *, limit: int | None = None) -> list[MiniAppPersonnel]: role_phones = select(MiniAppPersonRole.phone).distinct() stmt = ( select(MiniAppPersonnel) .where(MiniAppPersonnel.phone.in_(role_phones)) .order_by(MiniAppPersonnel.updated_at.desc(), MiniAppPersonnel.phone.asc()) ) if limit is not None: stmt = stmt.limit(limit) return db.scalars(stmt).all() def _deactivate_missing_miniapp_employees(db: Session, active_phones: set[str]) -> None: synced_employees = db.scalars( select(Employee).where( Employee.mobile.is_not(None), Employee.status == "ACTIVE", or_( Employee.remark == "由小程序人员清单同步生成", Employee.job_title.in_(["小程序人员", "小程序报工员工"]), ), ) ).all() for employee in synced_employees: phone = _normalize_text(employee.mobile) if phone and phone not in active_phones: employee.status = "INACTIVE" employee.remark = "小程序人员清单已删除或停用" db.add(employee) def _sync_miniapp_personnel_to_employees( db: Session, *, people: list[MiniAppPersonnel] | None = None, deactivate_missing: bool = False, ) -> None: miniapp_people = people if people is not None else _active_miniapp_people(db) active_phones = {_normalize_text(person.phone) for person in miniapp_people if _normalize_text(person.phone)} if deactivate_missing: _deactivate_missing_miniapp_employees(db, active_phones) if not miniapp_people: return department = db.scalar(select(Department).order_by(Department.id).limit(1)) if not department: raise HTTPException(status_code=400, detail="ERP 未配置部门,无法把小程序人员映射到业务员工档案") phones = [_normalize_text(person.phone) for person in miniapp_people if _normalize_text(person.phone)] existing_employees = db.scalars(select(Employee).where(Employee.mobile.in_(phones))).all() if phones else [] employee_by_phone = {_normalize_text(employee.mobile): employee for employee in existing_employees if _normalize_text(employee.mobile)} for person in miniapp_people: phone = _normalize_text(person.phone) if not phone: continue name = _normalize_text(person.name) or phone employee = employee_by_phone.get(phone) if not employee: employee = Employee( employee_code=next_employee_code(db), employee_name=name, dept_id=department.id, mobile=phone, 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() employee_by_phone[phone] = employee continue changed = False if employee.employee_name != name: employee.employee_name = name changed = True if employee.dept_id is None: employee.dept_id = department.id changed = True if employee.status != "ACTIVE": employee.status = "ACTIVE" changed = True if employee.job_title in {None, "", "小程序报工员工"}: employee.job_title = "小程序人员" changed = True if not employee.is_operator: employee.is_operator = 1 changed = True if not employee.is_workshop_staff: employee.is_workshop_staff = 1 changed = True if changed: db.add(employee) def _miniapp_product_key(payload: MiniAppProductUpsert, *, original: bool = False) -> dict[str, str]: if original: return { "attendance_point_name": _normalize_text(payload.original_attendance_point_name or payload.attendance_point_name), "project_no": _normalize_text(payload.original_project_no or payload.project_no), "product_name": _normalize_text(payload.original_product_name or payload.product_name), "device_no": _normalize_text(payload.original_device_no if payload.original_device_no is not None else payload.device_no), "process_name": _normalize_text( payload.original_process_name if payload.original_process_name is not None else payload.process_name ), } return { "attendance_point_name": _normalize_text(payload.attendance_point_name), "project_no": _normalize_text(payload.project_no), "product_name": _normalize_text(payload.product_name), "device_no": _normalize_text(payload.device_no), "process_name": _export_miniapp_process_name(payload.process_name), } def _export_miniapp_process_name(value: object | None) -> str: text = _normalize_text(value) if not text or text == MINIAPP_LEGACY_CLEANING_PROCESS_NAME: return "" if text == MINIAPP_MISC_PROCESS_NAME: return MINIAPP_MISC_PROCESS_NAME legacy_match = re.fullmatch(r"0*(\d+)序", text) if legacy_match: return str(int(legacy_match.group(1))) number_match = re.fullmatch(r"0*(\d+)", text) if number_match: return str(int(number_match.group(1))) return text def _validate_miniapp_process_name(value: object | None, stamping_method: object | None = None) -> str: raw_text = _normalize_text(value) stamping_text = _normalize_text(stamping_method) if raw_text == MINIAPP_LEGACY_CLEANING_PROCESS_NAME: raise ValueError("清洗请填写在冲压方式中,工序只能填写正整数") process_name = _export_miniapp_process_name(raw_text) if not process_name: raise ValueError("工序必须填写正整数") if process_name == MINIAPP_MISC_PROCESS_NAME: if stamping_text != MINIAPP_MISC_STAMPING_METHOD: raise ValueError("只有处理杂活的工序可以填写杂活") return process_name if stamping_text == MINIAPP_MISC_STAMPING_METHOD: raise ValueError("处理杂活的工序必须填写杂活") if not re.fullmatch(r"\d+", process_name) or int(process_name) <= 0: raise ValueError("工序必须填写正整数") return process_name def _optional_decimal(value: object | None) -> Decimal | None: if value is None: return None text = str(value).strip() if text == "": return None return Decimal(text) def _optional_float(value: object | None) -> float | None: if value is None: return None return float(value) def _decimal_or_zero(value: object | None, *, scale: Decimal | None = None) -> Decimal: decimal_value = _optional_decimal(value) or Decimal("0") return decimal_value.quantize(scale) if scale else decimal_value def _decimal_changed(current: object | None, next_value: object | None, *, scale: Decimal | None = None) -> bool: return _decimal_or_zero(current, scale=scale) != _decimal_or_zero(next_value, scale=scale) def _set_attr_if_changed(target, attr_name: str, next_value) -> bool: if getattr(target, attr_name) == next_value: return False setattr(target, attr_name, next_value) return True def _import_text(value: Any) -> str: return str(value or "").strip() def _import_optional_float(value: Any) -> float | None: if value is None or value == "": return None try: return float(value) except (TypeError, ValueError): return None def _import_number(value: Any, default: float = 0) -> float: parsed = _import_optional_float(value) return default if parsed is None else parsed def _normalized_import_value(value: Any) -> str: text = _import_text(value) if not text: return "" try: return str(Decimal(text).normalize()) except InvalidOperation: return text def _build_miniapp_product_workbook(rows: list[MiniAppProduct]) -> Workbook: workbook = Workbook() worksheet = workbook.active worksheet.title = "产品清单" worksheet.freeze_panes = "A2" worksheet.append(PRODUCT_EXCEL_HEADERS) for row in rows: worksheet.append( [ row.attendance_point_name, row.project_no, row.profile_no or "", row.product_name, row.material_code or "", row.material_name or "", row.supplier or "", _export_miniapp_process_name(row.process_name), row.stamping_method or "", _optional_float(row.operator_count) or 0, "", _optional_float(row.standard_beat) or 0, _optional_float(row.standard_workload) or 0, _optional_float(row.product_net_weight_kg) if row.product_net_weight_kg is not None else "", _optional_float(row.product_gross_weight_kg) if row.product_gross_weight_kg is not None else "", _optional_float(row.scrap_loss_rate) if row.scrap_loss_rate is not None else "", _optional_float(row.waste_price_yuan_per_kg) if row.waste_price_yuan_per_kg is not None else "", ] ) column_widths = [18, 14, 16, 28, 18, 26, 22, 14, 14, 12, 20, 12, 14, 16, 16, 14, 18] for index, width in enumerate(column_widths, start=1): worksheet.column_dimensions[worksheet.cell(row=1, column=index).column_letter].width = width return workbook def _excel_response(workbook: Workbook, filename: str) -> Response: output = BytesIO() workbook.save(output) encoded_filename = quote(filename) return Response( content=output.getvalue(), media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": f"attachment; filename=\"master-data.xlsx\"; filename*=UTF-8''{encoded_filename}"}, ) def _excel_workbook_response(workbook: Workbook, filename: str, ascii_filename: str = "master-data.xlsx") -> Response: output = BytesIO() workbook.save(output) encoded_filename = quote(filename) return Response( content=output.getvalue(), media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": f"attachment; filename=\"{ascii_filename}\"; filename*=UTF-8''{encoded_filename}"}, ) def _read_excel_rows(content: bytes) -> list[dict[str, Any]]: try: workbook = load_workbook(BytesIO(content), read_only=True, data_only=True) except Exception as exc: raise ValueError("Excel 文件读取失败,请确认上传的是小程序产品清单 xlsx 文件") from exc worksheet = workbook.active rows = list(worksheet.iter_rows(values_only=True)) if not rows: return [] headers = [_import_text(value) for value in rows[0]] mapped_rows: list[dict[str, Any]] = [] for values in rows[1:]: mapped_rows.append({headers[index]: value for index, value in enumerate(values) if index < len(headers)}) return mapped_rows def _material_export_stmt(): return ( select( Material.item_id.label("item_id"), Item.item_code.label("item_code"), Item.item_name.label("item_name"), Item.specification.label("specification"), Item.material_grade.label("material_grade"), Item.unit_weight_kg.label("unit_weight_kg"), Item.safety_stock_weight_kg.label("safety_stock_weight_kg"), Item.scrap_sale_price.label("scrap_sale_price"), Material.default_supplier_id.label("default_supplier_id"), Supplier.supplier_name.label("supplier_name"), Material.purchase_calc_mode.label("purchase_calc_mode"), Material.min_purchase_qty.label("min_purchase_qty"), Material.purchase_multiple_qty.label("purchase_multiple_qty"), Material.purchase_multiple_weight_kg.label("purchase_multiple_weight_kg"), Material.lead_time_days.label("lead_time_days"), Material.quality_rule.label("quality_rule"), Material.quality_rule.label("remark"), Item.status.label("status"), ) .join(Item, Item.id == Material.item_id) .outerjoin(Supplier, Supplier.id == Material.default_supplier_id) .order_by(Item.item_code.asc(), Item.id.asc()) ) def _material_status_label(value: str | None) -> str: return MATERIAL_STATUS_LABELS.get(_import_text(value).upper(), _import_text(value) or "启用") def _parse_material_status(value: object | None) -> str: text = _import_text(value) if not text: return "ACTIVE" normalized = text.upper() if text in {"启用", "有效", "正常"} or normalized == "ACTIVE": return "ACTIVE" if text in {"停用", "禁用", "无效"} or normalized == "INACTIVE": return "INACTIVE" raise ValueError(f"未知原材料状态:{text},请填写启用或停用") def _build_material_workbook(rows: list[Any]) -> Workbook: workbook = Workbook() worksheet = workbook.active worksheet.title = "原材料名录" worksheet.freeze_panes = "A2" worksheet.append(MATERIAL_EXCEL_HEADERS) for row in rows: payload = _normalize_material_read_payload(dict(row)) worksheet.append( [ payload.get("item_code") or "", payload.get("item_name") or "", payload.get("material_grade") or "", payload.get("specification") or "", float(payload.get("scrap_sale_price") or 0), payload.get("remark") or "", _material_status_label(payload.get("status")), ] ) for index, width in enumerate([18, 28, 16, 22, 14, 38, 12], start=1): worksheet.column_dimensions[worksheet.cell(row=1, column=index).column_letter].width = width return workbook def _read_material_excel_rows(content: bytes) -> list[dict[str, Any]]: try: workbook = load_workbook(BytesIO(content), read_only=True, data_only=True) except Exception as exc: raise ValueError("Excel 文件读取失败,请确认上传的是原材料名录 xlsx 文件") from exc worksheet = workbook.active rows = list(worksheet.iter_rows(values_only=True)) if not rows: return [] headers = [_import_text(value) for value in rows[0]] missing = [header for header in MATERIAL_EXCEL_REQUIRED_HEADERS if header not in headers] if missing: raise ValueError(f"Excel 缺少原材料名录必要列:{'、'.join(missing)}") return [{headers[index]: value for index, value in enumerate(row) if index < len(headers)} for row in rows[1:]] def _material_scrap_price(row: dict[str, Any]) -> Decimal: value = row.get("废料单价") if value is None: value = row.get("废料单价(元/kg)") text = _import_text(value) if not text: return Decimal("0") try: return Decimal(text.replace("¥", "").replace(",", "")).quantize(Decimal("0.0001")) except InvalidOperation as exc: raise ValueError(f"废料单价格式不正确:{text}") from exc def _find_material_item_by_natural_key(db: Session, *, item_name: str, material_grade: str, specification: str) -> Item | None: return db.scalar( select(Item) .join(Material, Material.item_id == Item.id) .where( Item.item_type == "RAW_MATERIAL", Item.item_name == item_name, Item.material_grade == material_grade, Item.specification == specification, ) .order_by(Item.id.asc()) .limit(1) ) def _import_material_excel(db: Session, content: bytes) -> dict[str, object]: rows = _read_material_excel_rows(content) if not rows: raise ValueError("Excel 中没有可导入的原材料数据") imported = 0 updated = 0 skipped = 0 errors: list[str] = [] for index, row in enumerate(rows, start=2): if not any(_import_text(value) for value in row.values()): continue item_name = _import_text(row.get("原材料名称")) material_grade = _import_text(row.get("材质")) specification = _import_text(row.get("规格")) if not item_name or not material_grade or not specification: skipped += 1 errors.append(f"第{index}行缺少原材料名称、材质或规格") continue try: status = _parse_material_status(row.get("状态")) scrap_sale_price = _material_scrap_price(row) except ValueError as exc: skipped += 1 errors.append(f"第{index}行{exc}") continue item_code = _import_text(row.get("原材料编码")) quality_rule = _import_text(row.get("性能要求")) or None item = db.scalar( select(Item) .where(Item.item_code == item_code, Item.item_type == "RAW_MATERIAL") .limit(1) ) if item_code else None if item is None: item = _find_material_item_by_natural_key( db, item_name=item_name, material_grade=material_grade, specification=specification, ) if item is None: code_for_create = item_code or _build_material_code(db) duplicate = db.scalar(select(Item).where(Item.item_code == code_for_create).limit(1)) if duplicate: skipped += 1 errors.append(f"第{index}行原材料编码 {code_for_create} 已被其他物料占用") continue item = Item( item_code=code_for_create, item_name=item_name, item_type="RAW_MATERIAL", specification=specification, material_grade=material_grade, unit_weight_kg=Decimal("1"), safety_stock_weight_kg=Decimal("0"), scrap_sale_price=scrap_sale_price, status=status, ) db.add(item) db.flush() db.add( Material( item_id=item.id, default_supplier_id=None, purchase_calc_mode="BY_WEIGHT", min_purchase_qty=Decimal("0"), purchase_multiple_qty=Decimal("0"), purchase_multiple_weight_kg=Decimal("0"), lead_time_days=0, quality_rule=quality_rule, ) ) db.flush() imported += 1 continue material = db.scalar(select(Material).where(Material.item_id == item.id).limit(1)) if material is None: material = Material( item_id=item.id, default_supplier_id=None, purchase_calc_mode="BY_WEIGHT", min_purchase_qty=Decimal("0"), purchase_multiple_qty=Decimal("0"), purchase_multiple_weight_kg=Decimal("0"), lead_time_days=0, ) db.add(material) item.item_name = item_name item.specification = specification item.material_grade = material_grade item.unit_weight_kg = item.unit_weight_kg or Decimal("1") item.safety_stock_weight_kg = item.safety_stock_weight_kg or Decimal("0") item.scrap_sale_price = scrap_sale_price item.status = status material.purchase_calc_mode = "BY_WEIGHT" material.purchase_multiple_qty = Decimal("0") material.quality_rule = quality_rule db.add(item) db.add(material) updated += 1 if imported == 0 and updated == 0 and skipped > 0: db.rollback() raise ValueError("Excel 中没有可导入的有效原材料数据") db.commit() return { "imported": imported, "updated": updated, "skipped": skipped, "errors": errors, "message": f"导入完成:新增 {imported} 条,更新 {updated} 条,跳过 {skipped} 条", } def _validate_product_import_headers(rows: list[dict[str, Any]]) -> None: if not rows: raise ValueError("Excel 中没有可导入的数据") present_headers = set().union(*(row.keys() for row in rows)) required_headers = {"项目号", "产品名称", "物料编码", "物料名称", "供应商", "工序"} missing = sorted(required_headers - present_headers) if missing: raise ValueError(f"Excel 缺少小程序产品清单必要列:{'、'.join(missing)}") def _validate_product_import_aux_consistency(rows: list[dict[str, Any]], default_point_name: str) -> None: aux_headers = {label for _, label in PRODUCT_IMPORT_AUX_FIELDS} if not any(header in row for row in rows for header in aux_headers): return grouped: dict[str, tuple[int, str, str, str]] = {} errors: list[str] = [] for index, raw in enumerate(rows, start=2): if not any(_import_text(value) for value in raw.values()): continue point_name = _import_text(raw.get("考勤点")) or default_point_name product_name = _import_text(raw.get("产品名称")) process_name = _import_text(raw.get("工序")) if not product_name: continue values = tuple(_normalized_import_value(raw.get(label)) for _, label in PRODUCT_IMPORT_AUX_FIELDS) raw_value = "|".join(values) text_value = ",".join( f"{label}={value or '空'}" for (_, label), value in zip(PRODUCT_IMPORT_AUX_FIELDS, values) ) group_key = f"{point_name}||{product_name}" previous = grouped.get(group_key) if previous is None: grouped[group_key] = (index, process_name, text_value, raw_value) continue if previous[3] != raw_value: errors.append( f"考勤点【{point_name}】产品【{product_name}】不同工序的ERP辅助字段不一致:" f"第{previous[0]}行({previous[1] or '未填写工序'})为【{previous[2]}】," f"第{index}行({process_name or '未填写工序'})为【{text_value}】" ) if errors: detail = ";".join(errors[:10]) if len(errors) > 10: detail += f";另有{len(errors) - 10}处不一致" raise ValueError(f"{detail}。本次导入已取消,数据库未写入。") def _default_miniapp_product_import_point_name(db: Session) -> str: return ( db.scalar( select(MiniAppAttendancePoint.name) .where(MiniAppAttendancePoint.is_active.is_(True)) .order_by(MiniAppAttendancePoint.name.asc()) .limit(1) ) or "宁波百华智能科技有限公司" ) def _import_miniapp_product_excel(db: Session, content: bytes) -> MiniAppProductImportResult: rows = _read_excel_rows(content) _validate_product_import_headers(rows) default_point_name = _default_miniapp_product_import_point_name(db) _validate_product_import_aux_consistency(rows, default_point_name) point_names = [ _import_text(raw.get("考勤点")) or default_point_name for raw in rows if any(_import_text(value) for value in raw.values()) ] _clean_miniapp_attendance_point_names(db, point_names) imported = 0 updated = 0 skipped = 0 errors: list[str] = [] last_project_no = "" for index, raw in enumerate(rows, start=2): if not any(_import_text(value) for value in raw.values()): continue data = {field: raw[header] for header, field in PRODUCT_IMPORT_HEADERS.items() if header in raw} attendance_point_name = _import_text(data.get("attendance_point_name")) or default_point_name project_no = _import_text(data.get("project_no")) or last_project_no product_name = _import_text(data.get("product_name")) raw_process_name = _import_text(data.get("process_name")) stamping_method = _import_text(data.get("stamping_method")) standard_beat = _import_number(data.get("standard_beat")) is_cleaning = stamping_method == MINIAPP_CLEANING_STAMPING_METHOD if project_no: last_project_no = project_no try: process_name = _validate_miniapp_process_name(raw_process_name, stamping_method) except ValueError as exc: skipped += 1 errors.append(f"第{index}行{exc}") continue if not project_no or not product_name or not process_name or (not is_cleaning and standard_beat <= 0): skipped += 1 errors.append(f"第{index}行缺少项目号、产品名称、工序或标准节拍(冲压方式为清洗时标准节拍可空)") continue payload = MiniAppProductUpsert( attendance_point_name=attendance_point_name, project_no=project_no, product_name=product_name, profile_no=_import_text(data.get("profile_no")) or None, material_code=_import_text(data.get("material_code")) or None, material_name=_import_text(data.get("material_name")) or None, supplier=_import_text(data.get("supplier")) or None, product_net_weight_kg=_import_optional_float(data.get("product_net_weight_kg")), product_gross_weight_kg=_import_optional_float(data.get("product_gross_weight_kg")), allowed_scrap_rate=_import_optional_float(data.get("scrap_loss_rate")), scrap_loss_rate=_import_optional_float(data.get("scrap_loss_rate")), waste_price_yuan_per_kg=_import_optional_float(data.get("waste_price_yuan_per_kg")), device_no="", process_name=process_name, stamping_method=stamping_method or None, operator_count=_import_number(data.get("operator_count"), 1) or 1, standard_beat=0 if is_cleaning else standard_beat, standard_workload=0, ) existing = db.get(MiniAppProduct, _miniapp_product_key(payload)) _upsert_miniapp_product(db, payload) if existing: updated += 1 else: imported += 1 product_result = _sync_miniapp_product_specs_to_master_data(db) db.commit() _mark_miniapp_master_sync_fresh(db) return MiniAppProductImportResult( imported=imported, updated=updated, skipped=skipped, errors=errors, material_sync={"created": 0, "linked": 0, "supplier_updated": 0, "skipped": 0}, product_specs=product_result, message=f"导入完成:新增 {imported} 条,更新 {updated} 条,跳过 {skipped} 条", ) def _sync_miniapp_waste_price_for_material( db: Session, *, material_code: str | None, material_name: str | None, waste_price: Decimal, ) -> int: conditions = [] code = _normalize_text(material_code) name = _normalize_text(material_name) if code: conditions.append(MiniAppProduct.material_code == code) if name: conditions.append(MiniAppProduct.material_name == name) if not conditions: return 0 rows = db.scalars(select(MiniAppProduct).where(or_(*conditions))).all() for row in rows: row.waste_price_yuan_per_kg = waste_price db.add(row) return len(rows) def _sync_miniapp_supplier_for_material( db: Session, *, material_code: str | None, material_name: str | None, supplier_name: str | None, ) -> int: conditions = [] code = _normalize_text(material_code) name = _normalize_text(material_name) if code: conditions.append(MiniAppProduct.material_code == code) if name: conditions.append(MiniAppProduct.material_name == name) if not conditions: return 0 synced_supplier = _normalize_text(supplier_name) or None changed = 0 rows = db.scalars(select(MiniAppProduct).where(or_(*conditions))).all() for row in rows: if (_normalize_text(row.supplier) or None) == synced_supplier: continue row.supplier = synced_supplier row.updated_at = datetime.now() db.add(row) changed += 1 return changed def _upsert_miniapp_product(db: Session, payload: MiniAppProductUpsert) -> MiniAppProduct: try: payload.process_name = _validate_miniapp_process_name(payload.process_name, payload.stamping_method) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc is_cleaning = _normalize_text(payload.stamping_method) == MINIAPP_CLEANING_STAMPING_METHOD is_misc = _normalize_text(payload.stamping_method) == MINIAPP_MISC_STAMPING_METHOD if not is_cleaning and not is_misc and float(payload.standard_beat or 0) <= 0: raise HTTPException(status_code=400, detail="非清洗工序必须填写标准节拍") if is_cleaning or is_misc: payload.standard_beat = 0 payload.standard_workload = 0 next_key = _miniapp_product_key(payload) if not next_key["attendance_point_name"] or not next_key["project_no"] or not next_key["product_name"]: raise HTTPException(status_code=400, detail="小程序产品清单必须填写考勤点、项目号和产品名称") _clean_miniapp_attendance_point_names(db, [next_key["attendance_point_name"]]) original_key = _miniapp_product_key(payload, original=True) row = db.get(MiniAppProduct, original_key) if row is not None and original_key != next_key: if db.get(MiniAppProduct, next_key): raise HTTPException(status_code=409, detail="小程序产品清单中已存在相同考勤点、项目号、产品、设备号和工序") db.delete(row) db.flush() row = None if row is None: row = db.get(MiniAppProduct, next_key) if row is None: row = MiniAppProduct(**next_key) db.add(row) row.profile_no = payload.profile_no row.material_code = payload.material_code row.material_name = payload.material_name row.supplier = payload.supplier row.product_net_weight_kg = _optional_decimal(payload.product_net_weight_kg) row.product_gross_weight_kg = _optional_decimal(payload.product_gross_weight_kg) row.scrap_loss_rate = _optional_decimal( payload.allowed_scrap_rate if payload.allowed_scrap_rate is not None else payload.scrap_loss_rate ) row.waste_price_yuan_per_kg = _optional_decimal(payload.waste_price_yuan_per_kg) row.stamping_method = payload.stamping_method row.operator_count = Decimal(str(payload.operator_count or 1)) row.standard_beat = Decimal(str(payload.standard_beat or 0)) row.standard_workload = Decimal("0") return row def _miniapp_product_read(row: MiniAppProduct) -> MiniAppProductRead: process_name = _export_miniapp_process_name(row.process_name) display_name = f"{row.project_no} / {row.product_name} / {process_name or '-'}" return MiniAppProductRead( attendance_point_name=row.attendance_point_name, project_no=row.project_no, product_name=row.product_name, profile_no=row.profile_no, material_code=row.material_code, material_name=row.material_name, supplier=row.supplier, product_net_weight_kg=_optional_float(row.product_net_weight_kg), product_gross_weight_kg=_optional_float(row.product_gross_weight_kg), allowed_scrap_rate=_optional_float(row.scrap_loss_rate), scrap_loss_rate=_optional_float(row.scrap_loss_rate), waste_price_yuan_per_kg=_optional_float(row.waste_price_yuan_per_kg), device_no=row.device_no, process_name=process_name, stamping_method=row.stamping_method, operator_count=float(row.operator_count or 0), standard_beat=float(row.standard_beat or 0), standard_workload=float(row.standard_workload or 0), display_name=display_name, created_at=row.created_at, updated_at=row.updated_at, ) def _get_miniapp_master_sync_signature(db: Session) -> tuple[object, ...]: row_count, point_count, last_updated_at, net_sum, gross_sum, scrap_sum, waste_price_sum, supplier_count, supplier_max = db.execute( select( func.count(MiniAppProduct.project_no), func.count(func.distinct(MiniAppProduct.attendance_point_name)), func.max(MiniAppProduct.updated_at), func.sum(MiniAppProduct.product_net_weight_kg), func.sum(MiniAppProduct.product_gross_weight_kg), func.sum(MiniAppProduct.scrap_loss_rate), func.sum(MiniAppProduct.waste_price_yuan_per_kg), func.count(func.distinct(MiniAppProduct.supplier)), func.max(MiniAppProduct.supplier), ) ).one() return ( int(row_count or 0), int(point_count or 0), last_updated_at.isoformat() if last_updated_at else "", str(_decimal_or_zero(net_sum, scale=Decimal("0.0001"))), str(_decimal_or_zero(gross_sum, scale=Decimal("0.0001"))), str(_decimal_or_zero(scrap_sum, scale=Decimal("0.000001"))), str(_decimal_or_zero(waste_price_sum, scale=Decimal("0.0001"))), int(supplier_count or 0), _normalize_text(supplier_max), ) def _mark_miniapp_master_sync_fresh(db: Session) -> None: global _miniapp_master_sync_signature _miniapp_master_sync_signature = _get_miniapp_master_sync_signature(db) def _get_or_create_miniapp_sync_supplier(db: Session) -> Supplier: supplier = db.scalar(select(Supplier).where(Supplier.supplier_name == "小程序物料同步供应商").limit(1)) if supplier: return supplier supplier = Supplier( supplier_code=build_supplier_code(db), supplier_name="小程序物料同步供应商", short_name="小程序同步", contact_name=None, contact_phone=None, address=None, lead_time_days=0, default_tax_rate=Decimal("0"), status="ACTIVE", remark="系统自动创建,用于承接小程序产品清单中的物料编码/物料名称。请在02-1中改成真实供应商。", ) db.add(supplier) db.flush() return supplier def _get_or_create_supplier_by_name( db: Session, supplier_name: str | None, supplier_cache: dict[str, Supplier], ) -> Supplier: name = _normalize_text(supplier_name) if not name: return _get_or_create_miniapp_sync_supplier(db) if name in supplier_cache: return supplier_cache[name] supplier = db.scalar( select(Supplier) .where(or_(Supplier.supplier_name == name, Supplier.short_name == name)) .order_by(Supplier.id.asc()) .limit(1) ) if not supplier: supplier = Supplier( supplier_code=build_supplier_code(db), supplier_name=name, short_name=name[:100], contact_name=None, contact_phone=None, address=None, lead_time_days=0, default_tax_rate=Decimal("0"), status="ACTIVE", remark="小程序产品清单供应商同步创建", ) db.add(supplier) db.flush() supplier_cache[name] = supplier return supplier def _sync_miniapp_materials_to_master_data(db: Session, *, commit: bool = True) -> dict[str, object]: miniapp_rows = db.execute( select( MiniAppProduct.material_code, MiniAppProduct.material_name, MiniAppProduct.supplier, MiniAppProduct.waste_price_yuan_per_kg, MiniAppProduct.updated_at, ) .where( MiniAppProduct.material_code.is_not(None), MiniAppProduct.material_code != "", ) .order_by(MiniAppProduct.material_code.asc(), MiniAppProduct.updated_at.desc()) ).mappings().all() material_records: dict[str, dict[str, object]] = {} for row in miniapp_rows: code = _normalize_text(row["material_code"]) if not code: continue record = material_records.setdefault( code, { "material_code": code, "material_name": "", "supplier": "", "supplier_names": set(), "waste_price_yuan_per_kg": None, }, ) material_name = _normalize_text(row["material_name"]) supplier_name = _normalize_text(row["supplier"]) waste_price = _optional_decimal(row["waste_price_yuan_per_kg"]) if material_name and not record["material_name"]: record["material_name"] = material_name if supplier_name: supplier_names = record["supplier_names"] supplier_names.add(supplier_name) if not record["supplier"]: record["supplier"] = supplier_name if waste_price is not None: current_price = record["waste_price_yuan_per_kg"] if current_price is None or waste_price > current_price: record["waste_price_yuan_per_kg"] = waste_price material_rows = list(material_records.values()) if not material_rows: return { "created": 0, "linked": 0, "supplier_updated": 0, "skipped": 0, "conflicts": [], "message": "小程序产品清单中暂无可同步物料", } supplier_cache: dict[str, Supplier] = {} material_codes = {_normalize_text(row["material_code"]) for row in material_rows if _normalize_text(row["material_code"])} material_names = {_normalize_text(row["material_name"]) for row in material_rows if _normalize_text(row["material_name"])} item_conditions = [] if material_codes: item_conditions.append(Item.item_code.in_(material_codes)) if material_names: item_conditions.append(Item.item_name.in_(material_names)) existing_items = db.scalars(select(Item).where(or_(*item_conditions))).all() if item_conditions else [] items_by_code = {_normalize_text(item.item_code): item for item in existing_items if _normalize_text(item.item_code)} items_by_name = {_normalize_text(item.item_name): item for item in existing_items if _normalize_text(item.item_name)} existing_materials = db.scalars( select(Material).where(Material.item_id.in_([item.id for item in existing_items])) ).all() if existing_items else [] materials_by_item_id = {material.item_id: material for material in existing_materials} created = 0 linked = 0 supplier_updated = 0 skipped = 0 conflicts: list[str] = [] for material_row in material_rows: code = _normalize_text(material_row["material_code"]) if not code: continue name = _normalize_text(material_row["material_name"]) or code waste_price = _optional_decimal(material_row["waste_price_yuan_per_kg"]) supplier_names = sorted(material_row["supplier_names"]) if len(supplier_names) > 1: conflicts.append(f"{code} 存在多个小程序供应商,已采用最新记录:{material_row['supplier']};全部值:{'、'.join(supplier_names)}") supplier = _get_or_create_supplier_by_name(db, _normalize_text(material_row["supplier"]), supplier_cache) item = items_by_code.get(code) if item and item.item_type != "RAW_MATERIAL": skipped += 1 conflicts.append(f"{code} 已被非原材料物料占用") continue if not item: item = Item( item_code=code, item_name=name, item_type="RAW_MATERIAL", specification=name, material_grade="未维护", unit_weight_kg=Decimal("1"), safety_stock_weight_kg=Decimal("0"), scrap_sale_price=waste_price if waste_price is not None else Decimal("0"), status="ACTIVE", ) db.add(item) db.flush() material = Material( item_id=item.id, default_supplier_id=supplier.id, purchase_calc_mode="BY_WEIGHT", min_purchase_qty=Decimal("0"), purchase_multiple_qty=Decimal("0"), purchase_multiple_weight_kg=Decimal("0"), lead_time_days=0, quality_rule="小程序产品清单同步,需在02-1补充真实规格、材质、重量和质检要求", ) db.add(material) items_by_code[code] = item items_by_name[name] = item materials_by_item_id[item.id] = material created += 1 continue material = materials_by_item_id.get(item.id) item_changed = False if waste_price is not None and _decimal_changed(item.scrap_sale_price, waste_price, scale=Decimal("0.0001")): item.scrap_sale_price = waste_price item_changed = True if name and item.item_name != name: item.item_name = name item_changed = True if item_changed: db.add(item) if not material: material = Material( item_id=item.id, default_supplier_id=supplier.id, purchase_calc_mode="BY_WEIGHT", min_purchase_qty=Decimal("0"), purchase_multiple_qty=Decimal("0"), purchase_multiple_weight_kg=Decimal("0"), lead_time_days=0, quality_rule="小程序产品清单同步,需在02-1补充真实规格、材质、重量和质检要求", ) db.add(material) materials_by_item_id[item.id] = material linked += 1 else: if material.default_supplier_id != supplier.id: material.default_supplier_id = supplier.id db.add(material) supplier_updated += 1 else: skipped += 1 if commit: db.commit() return { "created": created, "linked": linked, "supplier_updated": supplier_updated, "skipped": skipped, "conflicts": conflicts, "message": f"同步完成:新增原材料 {created} 条,补齐关联 {linked} 条,供应商更新 {supplier_updated} 条,已存在/跳过 {skipped} 条", } def _sync_miniapp_product_specs_to_master_data(db: Session) -> dict[str, int]: rows = db.scalars( select(MiniAppProduct).order_by(MiniAppProduct.attendance_point_name.asc(), MiniAppProduct.updated_at.desc()) ).all() context = _build_miniapp_product_sync_context(db, rows) matched = 0 created = 0 product_updated = 0 bom_updated = 0 route_updated = 0 stale_deleted = 0 stale_blocked = 0 stale_deactivated = 0 for group_rows in _group_miniapp_product_rows(rows): miniapp_product = group_rows[0] product_row = _find_product_for_miniapp_product_in_context(context, miniapp_product) if product_row: product, item = product_row matched += 1 else: product, item = _create_product_from_miniapp_group(db, group_rows) if not product or not item: continue _register_product_in_miniapp_sync_context(context, product, item) created += 1 matched += 1 if _sync_miniapp_product_header_to_erp(db, product, item, group_rows): product_updated += 1 if _sync_miniapp_bom_to_erp_fast(db, item, group_rows, context): bom_updated += 1 if _sync_miniapp_route_to_erp_fast(db, item, group_rows, context): route_updated += 1 stale_result = _deactivate_stale_miniapp_product_specs_from_master_data(db, rows) stale_deactivated = stale_result["deactivated"] return { "matched": matched, "created": created, "product_updated": product_updated, "bom_updated": bom_updated, "route_updated": route_updated, "stale_deleted": stale_deleted, "stale_blocked": stale_blocked, "stale_deactivated": stale_deactivated, } def _build_miniapp_product_sync_context(db: Session, rows: list[MiniAppProduct]) -> dict[str, object]: product_names = {_normalize_text(row.product_name) for row in rows if _normalize_text(row.product_name)} project_nos = {_normalize_text(row.project_no) for row in rows if _normalize_text(row.project_no)} material_codes = {_normalize_text(row.material_code) for row in rows if _normalize_text(row.material_code)} material_names = {_normalize_text(row.material_name) for row in rows if _normalize_text(row.material_name)} process_names = {_export_miniapp_process_name(row.process_name) for row in rows if _export_miniapp_process_name(row.process_name)} product_conditions = [] if product_names: product_conditions.append(Item.item_name.in_(product_names)) if project_nos: product_conditions.append(Item.item_code.in_(project_nos)) product_pairs = db.execute( select(Product, Item) .join(Item, Item.id == Product.item_id) .where(Item.item_type.in_(["PRODUCT", "FINISHED_GOOD"]), or_(*product_conditions)) ).all() if product_conditions else [] products_by_name: dict[str, tuple[Product, Item]] = {} products_by_code: dict[str, tuple[Product, Item]] = {} product_item_ids: list[int] = [] for product, item in product_pairs: pair = (product, item) item_name = _normalize_text(item.item_name) item_code = _normalize_text(item.item_code) if item_name and item_name not in products_by_name: products_by_name[item_name] = pair if item_code and item_code not in products_by_code: products_by_code[item_code] = pair product_item_ids.append(item.id) material_conditions = [] if material_codes: material_conditions.append(Item.item_code.in_(material_codes)) if material_names: material_conditions.append(Item.item_name.in_(material_names)) material_items = db.scalars( select(Item).where(Item.item_type == "RAW_MATERIAL", or_(*material_conditions)) ).all() if material_conditions else [] materials_by_code = {_normalize_text(item.item_code): item for item in material_items if _normalize_text(item.item_code)} materials_by_name = {_normalize_text(item.item_name): item for item in material_items if _normalize_text(item.item_name)} boms_by_product_id: dict[int, Bom] = {} bom_items_by_bom_id: dict[int, BomItem] = {} routes_by_product_id: dict[int, ProcessRoute] = {} route_ops_by_route_id: dict[int, list[ProcessRouteOperation]] = {} used_route_ids: set[int] = set() if product_item_ids: bom_rows = db.scalars( select(Bom) .where(Bom.product_item_id.in_(product_item_ids)) .order_by(Bom.product_item_id, Bom.is_default.desc(), Bom.id.desc()) ).all() for bom in bom_rows: boms_by_product_id.setdefault(bom.product_item_id, bom) bom_ids = [bom.id for bom in boms_by_product_id.values()] if bom_ids: bom_item_rows = db.scalars( select(BomItem) .where(BomItem.bom_id.in_(bom_ids)) .order_by(BomItem.bom_id, BomItem.seq_no.asc(), BomItem.id.asc()) ).all() for bom_item in bom_item_rows: bom_items_by_bom_id.setdefault(bom_item.bom_id, bom_item) route_rows = db.scalars( select(ProcessRoute) .where(ProcessRoute.product_item_id.in_(product_item_ids)) .order_by(ProcessRoute.product_item_id, ProcessRoute.is_default.desc(), ProcessRoute.id.desc()) ).all() for route in route_rows: routes_by_product_id.setdefault(route.product_item_id, route) route_ids = [route.id for route in routes_by_product_id.values()] if route_ids: route_op_rows = db.scalars( select(ProcessRouteOperation) .where(ProcessRouteOperation.route_id.in_(route_ids)) .order_by(ProcessRouteOperation.route_id, ProcessRouteOperation.seq_no.asc(), ProcessRouteOperation.id.asc()) ).all() for operation in route_op_rows: route_ops_by_route_id.setdefault(operation.route_id, []).append(operation) used_route_ids = set( db.scalars(select(WorkOrder.route_id).where(WorkOrder.route_id.in_(route_ids))).all() ) process_rows = db.scalars( select(Process).where(Process.process_name.in_(process_names)) ).all() if process_names else [] processes_by_name = {_normalize_text(process.process_name): process for process in process_rows} first_process = db.scalar(select(Process).order_by(Process.id.asc()).limit(1)) default_work_center = db.scalar(select(WorkCenter).order_by(WorkCenter.id.asc()).limit(1)) return { "products_by_name": products_by_name, "products_by_code": products_by_code, "materials_by_code": materials_by_code, "materials_by_name": materials_by_name, "boms_by_product_id": boms_by_product_id, "bom_items_by_bom_id": bom_items_by_bom_id, "routes_by_product_id": routes_by_product_id, "route_ops_by_route_id": route_ops_by_route_id, "used_route_ids": used_route_ids, "processes_by_name": processes_by_name, "first_process": first_process, "default_work_center": default_work_center, } def _find_product_for_miniapp_product_in_context( context: dict[str, object], miniapp_product: MiniAppProduct, ) -> tuple[Product, Item] | None: product_name = _normalize_text(miniapp_product.product_name) project_no = _normalize_text(miniapp_product.project_no) products_by_name = context["products_by_name"] products_by_code = context["products_by_code"] return products_by_name.get(product_name) or products_by_code.get(project_no) def _register_product_in_miniapp_sync_context(context: dict[str, object], product: Product, item: Item) -> None: products_by_name = context["products_by_name"] products_by_code = context["products_by_code"] item_name = _normalize_text(item.item_name) item_code = _normalize_text(item.item_code) if item_name: products_by_name[item_name] = (product, item) if item_code: products_by_code[item_code] = (product, item) def _deactivate_stale_miniapp_product_specs_from_master_data(db: Session, rows: list[MiniAppProduct]) -> dict[str, int]: active_product_names = {_normalize_text(row.product_name) for row in rows if _normalize_text(row.product_name)} active_project_nos = {_normalize_text(row.project_no) for row in rows if _normalize_text(row.project_no)} deactivated = 0 stale_pairs = db.execute( select(Product, Item) .join(Item, Item.id == Product.item_id) .join(ProcessRoute, ProcessRoute.product_item_id == Item.id) .outerjoin(ProcessRouteOperation, ProcessRouteOperation.route_id == ProcessRoute.id) .where( Item.item_type.in_(["PRODUCT", "FINISHED_GOOD"]), or_( ProcessRoute.remark == MINIAPP_SYNC_REMARK, ProcessRouteOperation.remark == MINIAPP_SYNC_REMARK, ), ~Item.item_name.in_(active_product_names or {""}), ~Item.item_code.in_(active_project_nos or {""}), ) .distinct() ).all() for product, item in stale_pairs: if _deactivate_product_spec_records(db, item): deactivated += 1 return {"deactivated": deactivated} def _deactivate_product_spec_records(db: Session, item: Item) -> bool: changed = False if item.status != "INACTIVE": item.status = "INACTIVE" db.add(item) changed = True boms = db.scalars(select(Bom).where(Bom.product_item_id == item.id)).all() for bom in boms: if bom.status != "INACTIVE": bom.status = "INACTIVE" db.add(bom) changed = True routes = db.scalars(select(ProcessRoute).where(ProcessRoute.product_item_id == item.id)).all() route_ids = [route.id for route in routes] for route in routes: if route.status != "INACTIVE": route.status = "INACTIVE" db.add(route) changed = True if route_ids: operations = db.scalars( select(ProcessRouteOperation).where(ProcessRouteOperation.route_id.in_(route_ids)) ).all() for operation in operations: if operation.status != "INACTIVE": operation.status = "INACTIVE" db.add(operation) changed = True return changed def _delete_product_spec_records(db: Session, item: Item, product: Product) -> None: route_ids = db.scalars(select(ProcessRoute.id).where(ProcessRoute.product_item_id == item.id)).all() if route_ids: db.execute(delete(ProcessRouteOperation).where(ProcessRouteOperation.route_id.in_(route_ids))) db.execute(delete(ProcessRoute).where(ProcessRoute.id.in_(route_ids))) bom_ids = db.scalars(select(Bom.id).where(Bom.product_item_id == item.id)).all() if bom_ids: db.execute(delete(BomItem).where(BomItem.bom_id.in_(bom_ids))) db.execute(delete(Bom).where(Bom.id.in_(bom_ids))) db.delete(product) db.flush() db.delete(item) def _group_miniapp_product_rows(rows: list[MiniAppProduct]) -> list[list[MiniAppProduct]]: groups: dict[tuple[str, str, str], list[MiniAppProduct]] = {} for row in rows: key = (_normalize_text(row.attendance_point_name), _normalize_text(row.project_no), _normalize_text(row.product_name)) if not key[0] or not key[1] or not key[2]: continue groups.setdefault(key, []).append(row) grouped_rows: list[list[MiniAppProduct]] = [] for group in groups.values(): deduped: dict[str, MiniAppProduct] = {} for row in _sort_miniapp_process_rows(group): process_name = _export_miniapp_process_name(row.process_name) if not process_name: continue existing = deduped.get(process_name) if existing is None or _normalize_text(row.process_name) == process_name: deduped[process_name] = row if deduped: grouped_rows.append(list(deduped.values())) return grouped_rows def _sort_miniapp_process_rows(rows: list[MiniAppProduct]) -> list[MiniAppProduct]: def sort_key(row: MiniAppProduct) -> tuple[int, str]: process_name = _export_miniapp_process_name(row.process_name) match = re.search(r"\d+", process_name) return (int(match.group(0)) if match else 9999, process_name) return sorted(rows, key=sort_key) def _first_decimal(rows: list[MiniAppProduct], attr_name: str) -> Decimal | None: for row in rows: value = _optional_decimal(getattr(row, attr_name, None)) if value is not None: return value return None def _first_text(rows: list[MiniAppProduct], attr_name: str) -> str: for row in rows: value = _normalize_text(getattr(row, attr_name, None)) if value: return value return "" def _create_product_from_miniapp_group(db: Session, rows: list[MiniAppProduct]) -> tuple[Product | None, Item | None]: first = rows[0] product_name = _normalize_text(first.product_name) if not product_name: return None, None net_weight = _first_decimal(rows, "product_net_weight_kg") or Decimal("1") allowed_scrap_rate = _first_decimal(rows, "scrap_loss_rate") or Decimal("0") item = Item( item_code=_build_product_code(db), item_name=product_name, item_type="FINISHED_GOOD", specification=None, material_grade=None, unit_weight_kg=net_weight, safety_stock_weight_kg=Decimal("0"), status="ACTIVE", ) db.add(item) db.flush() product = Product( item_id=item.id, drawing_no=_normalize_text(first.project_no) or None, product_family=_normalize_text(first.project_no) or None, version_no="V1.0", standard_output_qty=Decimal("0"), allowed_scrap_rate=allowed_scrap_rate, quality_standard="小程序产品清单同步生成", ) db.add(product) db.flush() return product, item def _sync_miniapp_product_header_to_erp(db: Session, product: Product, item: Item, rows: list[MiniAppProduct]) -> bool: changed = False net_weight = _first_decimal(rows, "product_net_weight_kg") allowed_scrap_rate = _first_decimal(rows, "scrap_loss_rate") project_no = _normalize_text(rows[0].project_no) if net_weight is not None and _decimal_changed(item.unit_weight_kg, net_weight, scale=Decimal("0.000001")): item.unit_weight_kg = net_weight changed = True if allowed_scrap_rate is not None and _decimal_changed(product.allowed_scrap_rate, allowed_scrap_rate, scale=Decimal("0.0001")): product.allowed_scrap_rate = allowed_scrap_rate changed = True if project_no and product.drawing_no != project_no: product.drawing_no = project_no changed = True if project_no and product.product_family != project_no: product.product_family = project_no changed = True if item.status != "ACTIVE": item.status = "ACTIVE" changed = True if changed: db.add(item) db.add(product) return changed def _sync_miniapp_bom_to_erp(db: Session, product_item: Item, rows: list[MiniAppProduct]) -> bool: material_item = _find_material_for_miniapp_rows(db, rows) if not material_item: return False net_weight = _first_decimal(rows, "product_net_weight_kg") gross_weight = _first_decimal(rows, "product_gross_weight_kg") if net_weight is None and gross_weight is None: return False bom = db.scalar( select(Bom) .where(Bom.product_item_id == product_item.id) .order_by(Bom.is_default.desc(), Bom.id.desc()) .limit(1) ) if not bom: bom = Bom( bom_code=_build_bom_code(db, product_item.id), product_item_id=product_item.id, version_no="V1.0", is_default=1, status="ACTIVE", effective_date=None, expire_date=None, remark="小程序产品清单同步生成", ) db.add(bom) db.flush() elif bom.status != "ACTIVE": bom.status = "ACTIVE" db.add(bom) bom_item = db.scalar( select(BomItem) .where(BomItem.bom_id == bom.id) .order_by(BomItem.seq_no.asc(), BomItem.id.asc()) .limit(1) ) if not bom_item: bom_item = BomItem( bom_id=bom.id, seq_no=1, material_item_id=material_item.id, usage_type="WEIGHT", qty_per_piece=Decimal("1"), net_weight_per_piece_kg=Decimal("0"), gross_weight_per_piece_kg=Decimal("0"), loss_rate=Decimal("0"), issue_over_tolerance_rate=Decimal("0"), remark="小程序产品清单同步生成", ) db.add(bom_item) effective_net_weight = net_weight if net_weight is not None else _optional_decimal(bom_item.net_weight_per_piece_kg) effective_gross_weight = gross_weight if gross_weight is not None else _optional_decimal(bom_item.gross_weight_per_piece_kg) bom_item.material_item_id = material_item.id if net_weight is not None: bom_item.net_weight_per_piece_kg = net_weight if gross_weight is not None: bom_item.gross_weight_per_piece_kg = gross_weight if effective_net_weight is not None and effective_gross_weight is not None: bom_item.loss_rate = _calculate_loss_rate(effective_net_weight, effective_gross_weight) db.add(bom) db.add(bom_item) return True def _sync_miniapp_bom_to_erp_fast( db: Session, product_item: Item, rows: list[MiniAppProduct], context: dict[str, object], ) -> bool: material_item = _find_material_for_miniapp_rows_in_context(context, rows) if not material_item: return False net_weight = _first_decimal(rows, "product_net_weight_kg") gross_weight = _first_decimal(rows, "product_gross_weight_kg") if net_weight is None and gross_weight is None: return False boms_by_product_id = context["boms_by_product_id"] bom_items_by_bom_id = context["bom_items_by_bom_id"] bom = boms_by_product_id.get(product_item.id) changed = False if not bom: bom = Bom( bom_code=_build_bom_code(db, product_item.id), product_item_id=product_item.id, version_no="V1.0", is_default=1, status="ACTIVE", effective_date=None, expire_date=None, remark="小程序产品清单同步生成", ) db.add(bom) db.flush() boms_by_product_id[product_item.id] = bom changed = True elif bom.status != "ACTIVE": bom.status = "ACTIVE" changed = True bom_item = bom_items_by_bom_id.get(bom.id) if not bom_item: bom_item = BomItem( bom_id=bom.id, seq_no=1, material_item_id=material_item.id, usage_type="WEIGHT", qty_per_piece=Decimal("1"), net_weight_per_piece_kg=Decimal("0"), gross_weight_per_piece_kg=Decimal("0"), loss_rate=Decimal("0"), issue_over_tolerance_rate=Decimal("0"), remark="小程序产品清单同步生成", ) db.add(bom_item) bom_items_by_bom_id[bom.id] = bom_item changed = True effective_net_weight = net_weight if net_weight is not None else _optional_decimal(bom_item.net_weight_per_piece_kg) effective_gross_weight = gross_weight if gross_weight is not None else _optional_decimal(bom_item.gross_weight_per_piece_kg) if bom_item.material_item_id != material_item.id: bom_item.material_item_id = material_item.id changed = True if net_weight is not None and _decimal_changed(bom_item.net_weight_per_piece_kg, net_weight, scale=Decimal("0.000001")): bom_item.net_weight_per_piece_kg = net_weight changed = True if gross_weight is not None and _decimal_changed(bom_item.gross_weight_per_piece_kg, gross_weight, scale=Decimal("0.000001")): bom_item.gross_weight_per_piece_kg = gross_weight changed = True if effective_net_weight is not None and effective_gross_weight is not None: loss_rate = _calculate_loss_rate(effective_net_weight, effective_gross_weight) if _decimal_changed(bom_item.loss_rate, loss_rate, scale=Decimal("0.0001")): bom_item.loss_rate = loss_rate changed = True if changed: db.add(bom) db.add(bom_item) return changed def _find_material_for_miniapp_rows(db: Session, rows: list[MiniAppProduct]) -> Item | None: material_code = _first_text(rows, "material_code") material_name = _first_text(rows, "material_name") if material_code: item = db.scalar( select(Item) .where(Item.item_type == "RAW_MATERIAL", Item.item_code == material_code) .limit(1) ) if item: return item if material_name: return db.scalar( select(Item) .where(Item.item_type == "RAW_MATERIAL", Item.item_name == material_name) .limit(1) ) return None def _find_material_for_miniapp_rows_in_context(context: dict[str, object], rows: list[MiniAppProduct]) -> Item | None: material_code = _first_text(rows, "material_code") material_name = _first_text(rows, "material_name") materials_by_code = context["materials_by_code"] materials_by_name = context["materials_by_name"] return materials_by_code.get(material_code) or materials_by_name.get(material_name) def _sync_miniapp_route_to_erp(db: Session, product_item: Item, rows: list[MiniAppProduct]) -> bool: process_rows = [row for row in rows if _export_miniapp_process_name(row.process_name)] if not process_rows: return False default_work_center = db.scalar(select(WorkCenter).order_by(WorkCenter.id.asc()).limit(1)) first_process = db.scalar(select(Process).order_by(Process.id.asc()).limit(1)) if not default_work_center or not first_process: return False route = db.scalar( select(ProcessRoute) .where(ProcessRoute.product_item_id == product_item.id) .order_by(ProcessRoute.is_default.desc(), ProcessRoute.id.desc()) .limit(1) ) allowed_scrap_rate = _first_decimal(rows, "scrap_loss_rate") or Decimal("0") if not route: route = ProcessRoute( route_code=_build_route_code(db, product_item.id), route_name=f"{product_item.item_name} 默认工艺路线", product_item_id=product_item.id, version_no="V1.0", is_default=1, status="ACTIVE", effective_date=None, expire_date=None, remark="小程序产品清单同步生成", ) db.add(route) db.flush() elif route.status != "ACTIVE": route.status = "ACTIVE" db.add(route) processes_by_name = { _normalize_text(process.process_name): process for process in db.scalars(select(Process)).all() } desired_ops = [] for index, miniapp_row in enumerate(process_rows, start=1): process_name = _export_miniapp_process_name(miniapp_row.process_name) process = processes_by_name.get(process_name) or first_process desired_ops.append( { "seq_no": index, "process_id": process.id, "work_center_id": default_work_center.id, "operation_name": process_name, "std_cycle_seconds": _decimal_or_zero(miniapp_row.standard_beat, scale=Decimal("0.01")), "allowed_scrap_rate": _decimal_or_zero(allowed_scrap_rate, scale=Decimal("0.0001")), } ) existing_ops = db.scalars( select(ProcessRouteOperation) .where(ProcessRouteOperation.route_id == route.id) .order_by(ProcessRouteOperation.seq_no.asc(), ProcessRouteOperation.id.asc()) ).all() op_changed = _sync_miniapp_route_operations(db, route, existing_ops, desired_ops) work_order_changed = _sync_work_order_operations_for_route(db, route.id, desired_ops) if _route_has_usage(db, route.id) else False return op_changed or work_order_changed def _sync_miniapp_route_to_erp_fast( db: Session, product_item: Item, rows: list[MiniAppProduct], context: dict[str, object], ) -> bool: process_rows = [row for row in rows if _export_miniapp_process_name(row.process_name)] if not process_rows: return False default_work_center = context["default_work_center"] first_process = context["first_process"] if not default_work_center or not first_process: return False routes_by_product_id = context["routes_by_product_id"] route_ops_by_route_id = context["route_ops_by_route_id"] used_route_ids = context["used_route_ids"] processes_by_name = context["processes_by_name"] route = routes_by_product_id.get(product_item.id) changed = False if not route: route = ProcessRoute( route_code=_build_route_code(db, product_item.id), route_name=f"{product_item.item_name} 默认工艺路线", product_item_id=product_item.id, version_no="V1.0", is_default=1, status="ACTIVE", effective_date=None, expire_date=None, remark="小程序产品清单同步生成", ) db.add(route) db.flush() routes_by_product_id[product_item.id] = route route_ops_by_route_id[route.id] = [] changed = True allowed_scrap_rate = _first_decimal(rows, "scrap_loss_rate") or Decimal("0") existing_ops = route_ops_by_route_id.get(route.id, []) desired_ops = [] for index, miniapp_row in enumerate(process_rows, start=1): process_name = _export_miniapp_process_name(miniapp_row.process_name) process = processes_by_name.get(process_name) or first_process desired_ops.append( { "seq_no": index, "process_id": process.id, "work_center_id": default_work_center.id, "operation_name": process_name, "std_cycle_seconds": _decimal_or_zero(miniapp_row.standard_beat, scale=Decimal("0.01")), "allowed_scrap_rate": _decimal_or_zero(allowed_scrap_rate, scale=Decimal("0.0001")), } ) if route.status != "ACTIVE": route.status = "ACTIVE" changed = True if route.remark != MINIAPP_SYNC_REMARK: route.remark = MINIAPP_SYNC_REMARK changed = True op_changed = _sync_miniapp_route_operations(db, route, existing_ops, desired_ops) route_ops_by_route_id[route.id] = sorted( route_ops_by_route_id.get(route.id, existing_ops), key=lambda operation: (operation.seq_no, operation.id or 0), ) work_order_changed = _sync_work_order_operations_for_route(db, route.id, desired_ops) if route.id in used_route_ids else False if changed: db.add(route) return changed or op_changed or work_order_changed def _sync_miniapp_route_operations( db: Session, route: ProcessRoute, existing_ops: list[ProcessRouteOperation], desired_ops: list[dict[str, object]], ) -> bool: changed = False used_operation_ids: set[int] = set() existing_by_name: dict[str, ProcessRouteOperation] = {} for operation in existing_ops: operation_name = _normalize_text(operation.operation_name) if operation_name and operation_name not in existing_by_name: existing_by_name[operation_name] = operation matched_existing = { existing_by_name[_normalize_text(desired["operation_name"])].id for desired in desired_ops if _normalize_text(desired["operation_name"]) in existing_by_name and existing_by_name[_normalize_text(desired["operation_name"])].id } desired_seq_by_existing_id = { existing_by_name[_normalize_text(desired["operation_name"])].id: int(desired["seq_no"]) for desired in desired_ops if _normalize_text(desired["operation_name"]) in existing_by_name and existing_by_name[_normalize_text(desired["operation_name"])].id } occupied_by_other = { operation.seq_no: operation.id for operation in existing_ops if operation.id } needs_temporary_resequence = any( _normalize_text(desired["operation_name"]) not in existing_by_name or ( existing_by_name[_normalize_text(desired["operation_name"])].id and occupied_by_other.get(int(desired["seq_no"])) not in {None, existing_by_name[_normalize_text(desired["operation_name"])].id} ) for desired in desired_ops ) needs_temporary_resequence = needs_temporary_resequence or any( operation.id not in matched_existing and operation.seq_no <= len(desired_ops) for operation in existing_ops if operation.id ) needs_temporary_resequence = needs_temporary_resequence or any( operation_id and occupied_by_other.get(seq_no) not in {None, operation_id} for operation_id, seq_no in desired_seq_by_existing_id.items() ) if needs_temporary_resequence and existing_ops: temporary_seq = max( [int(operation.seq_no or 0) for operation in existing_ops] + [len(desired_ops)] ) + 1000 for index, operation in enumerate(existing_ops): operation.seq_no = temporary_seq + index db.add(operation) db.flush() changed = True for desired in desired_ops: operation_name = _normalize_text(desired["operation_name"]) operation = existing_by_name.get(operation_name) if not operation: operation = ProcessRouteOperation( route_id=route.id, seq_no=int(desired["seq_no"]), process_id=int(desired["process_id"]), work_center_id=int(desired["work_center_id"]), operation_name=operation_name, std_setup_minutes=Decimal("0"), std_cycle_seconds=desired["std_cycle_seconds"], std_labor_cost=Decimal("0"), std_machine_cost=Decimal("0"), std_outsource_cost=Decimal("0"), allowed_scrap_rate=desired["allowed_scrap_rate"], report_required=1, is_key_operation=0, status="ACTIVE", remark=MINIAPP_SYNC_REMARK, ) db.add(operation) db.flush() existing_ops.append(operation) changed = True if operation.id: used_operation_ids.add(operation.id) operation_changed = False if operation.seq_no != desired["seq_no"]: operation.seq_no = int(desired["seq_no"]) operation_changed = True if operation.process_id != desired["process_id"]: operation.process_id = int(desired["process_id"]) operation_changed = True if operation.work_center_id != desired["work_center_id"]: operation.work_center_id = int(desired["work_center_id"]) operation_changed = True if _normalize_text(operation.operation_name) != operation_name: operation.operation_name = operation_name operation_changed = True if _decimal_changed(operation.std_cycle_seconds, desired["std_cycle_seconds"], scale=Decimal("0.01")): operation.std_cycle_seconds = desired["std_cycle_seconds"] operation_changed = True if _decimal_changed(operation.allowed_scrap_rate, desired["allowed_scrap_rate"], scale=Decimal("0.0001")): operation.allowed_scrap_rate = desired["allowed_scrap_rate"] operation_changed = True if operation.status != "ACTIVE": operation.status = "ACTIVE" operation_changed = True if operation.remark != MINIAPP_SYNC_REMARK: operation.remark = MINIAPP_SYNC_REMARK operation_changed = True if operation_changed: db.add(operation) changed = True inactive_seq = len(desired_ops) + 1 for operation in existing_ops: if operation.id in used_operation_ids: continue if _normalize_text(operation.remark) != MINIAPP_SYNC_REMARK: if operation.status == "INACTIVE": operation.status = "ACTIVE" db.add(operation) changed = True continue operation_changed = False if operation.status != "INACTIVE": operation.status = "INACTIVE" operation_changed = True if operation.seq_no < inactive_seq: operation.seq_no = inactive_seq inactive_seq += 1 operation_changed = True else: inactive_seq = max(inactive_seq, int(operation.seq_no or 0) + 1) if operation_changed: db.add(operation) changed = True return changed def _sync_work_order_operations_for_route(db: Session, route_id: int, desired_ops: list[dict[str, object]]) -> bool: desired_by_seq = {int(item["seq_no"]): item for item in desired_ops} if not desired_by_seq: return False changed = False work_order_ops = db.scalars( select(WorkOrderOperation) .join(WorkOrder, WorkOrder.id == WorkOrderOperation.work_order_id) .where( WorkOrder.route_id == route_id, WorkOrderOperation.seq_no.in_(desired_by_seq.keys()), ) ).all() for operation in work_order_ops: desired = desired_by_seq.get(int(operation.seq_no)) if not desired: continue if _normalize_text(operation.operation_name) != desired["operation_name"]: operation.operation_name = str(desired["operation_name"]) changed = True if operation.work_center_id != desired["work_center_id"]: operation.work_center_id = int(desired["work_center_id"]) changed = True if _decimal_changed(operation.allowed_scrap_rate, desired["allowed_scrap_rate"], scale=Decimal("0.0001")): operation.allowed_scrap_rate = desired["allowed_scrap_rate"] changed = True if changed: db.add(operation) return changed def _miniapp_route_operations_match( existing_ops: list[ProcessRouteOperation], desired_ops: list[dict[str, object]], ) -> bool: if len(existing_ops) != len(desired_ops): return False for existing, desired in zip(existing_ops, desired_ops): if existing.seq_no != desired["seq_no"]: return False if existing.process_id != desired["process_id"]: return False if existing.work_center_id != desired["work_center_id"]: return False if _normalize_text(existing.operation_name) != desired["operation_name"]: return False if _decimal_changed(existing.std_cycle_seconds, desired["std_cycle_seconds"], scale=Decimal("0.01")): return False if _decimal_changed(existing.allowed_scrap_rate, desired["allowed_scrap_rate"], scale=Decimal("0.0001")): return False return True def _find_product_for_miniapp_product(db: Session, miniapp_product: MiniAppProduct) -> tuple[Product, Item] | None: product_name = _normalize_text(miniapp_product.product_name) project_no = _normalize_text(miniapp_product.project_no) conditions = [] if product_name: conditions.append(Item.item_name == product_name) if project_no: conditions.append(Item.item_code == project_no) if not conditions: return None row = db.execute( select(Product, Item) .join(Item, Item.id == Product.item_id) .where(Item.item_type.in_(["PRODUCT", "FINISHED_GOOD"]), or_(*conditions)) .order_by(Product.id.desc()) .limit(1) ).first() if not row: return None return row[0], row[1] def _calculate_loss_rate(net_weight: Decimal, gross_weight: Decimal) -> Decimal: if gross_weight <= 0: return Decimal("0") loss_weight = gross_weight - net_weight if loss_weight <= 0: return Decimal("0") return loss_weight / gross_weight def _build_employee_reads(db: Session, employees: list[Employee]) -> list[EmployeeRead]: if not employees: return [] dept_map = { row.id: row.dept_name for row in db.scalars(select(Department).where(Department.id.in_({employee.dept_id for employee in employees}))).all() } employee_ids = [employee.id for employee in employees] user_rows = db.execute( select(User.employee_id, User.username, Role.role_name) .select_from(User) .outerjoin(UserRole, UserRole.user_id == User.id) .outerjoin(Role, Role.id == UserRole.role_id) .where(User.employee_id.in_(employee_ids)) .order_by(User.employee_id, Role.id) ).all() user_map: dict[int, str | None] = {} role_map: dict[int, list[str]] = {} for employee_id, username, role_name in user_rows: if employee_id is None: continue if employee_id not in user_map: user_map[employee_id] = username if role_name: role_map.setdefault(employee_id, []) if role_name not in role_map[employee_id]: role_map[employee_id].append(role_name) return [ EmployeeRead( id=employee.id, employee_code=employee.employee_code, employee_name=employee.employee_name, dept_id=employee.dept_id, dept_name=dept_map.get(employee.dept_id), mobile=employee.mobile, gender=employee.gender, hire_date=employee.hire_date, job_title=employee.job_title, shift_code=employee.shift_code, is_operator=bool(employee.is_operator), is_workshop_staff=bool(employee.is_workshop_staff), status=employee.status, remark=employee.remark, username=user_map.get(employee.id), role_names=role_map.get(employee.id, []), ) for employee in employees ] def _get_product_read(db: Session, item_id: int) -> ProductRead: row = db.execute( select( Product.item_id.label("item_id"), Item.item_code.label("item_code"), Item.item_name.label("item_name"), Item.specification.label("specification"), Product.drawing_no.label("drawing_no"), Product.product_family.label("product_family"), Product.version_no.label("version_no"), Item.unit_weight_kg.label("unit_weight_kg"), Product.allowed_scrap_rate.label("allowed_scrap_rate"), Product.standard_output_qty.label("standard_output_qty"), Product.quality_standard.label("quality_standard"), Item.safety_stock_weight_kg.label("safety_stock_weight_kg"), Item.status.label("status"), ) .join(Item, Item.id == Product.item_id) .where(Product.item_id == item_id) ).mappings().first() if not row: raise HTTPException(status_code=404, detail="产品不存在") return ProductRead.model_validate(dict(row)) def _get_material_read(db: Session, item_id: int) -> MaterialRead: row = db.execute( select( Material.item_id.label("item_id"), Item.item_code.label("item_code"), Item.item_name.label("item_name"), Item.specification.label("specification"), Item.material_grade.label("material_grade"), Item.unit_weight_kg.label("unit_weight_kg"), Item.safety_stock_weight_kg.label("safety_stock_weight_kg"), Item.scrap_sale_price.label("scrap_sale_price"), Material.default_supplier_id.label("default_supplier_id"), Supplier.supplier_name.label("supplier_name"), Material.purchase_calc_mode.label("purchase_calc_mode"), Material.min_purchase_qty.label("min_purchase_qty"), Material.purchase_multiple_qty.label("purchase_multiple_qty"), Material.purchase_multiple_weight_kg.label("purchase_multiple_weight_kg"), Material.lead_time_days.label("lead_time_days"), Material.quality_rule.label("quality_rule"), Material.quality_rule.label("remark"), Item.status.label("status"), ) .join(Item, Item.id == Material.item_id) .outerjoin(Supplier, Supplier.id == Material.default_supplier_id) .where(Material.item_id == item_id) ).mappings().first() if not row: raise HTTPException(status_code=404, detail="原材料不存在") return MaterialRead.model_validate(_normalize_material_read_payload(dict(row))) def _ensure_jiaheng_warehouses(db: Session) -> None: existing_by_type = { str(row.warehouse_type or "").upper(): row for row in db.scalars(select(Warehouse).where(Warehouse.warehouse_type.in_(["RAW", "SEMI", "FINISHED", "AUX", "SCRAP", "RETURN"]))).all() } existing_by_code = { str(row.warehouse_code or "").upper(): row for row in db.scalars(select(Warehouse).where(Warehouse.warehouse_code.in_([item["warehouse_code"] for item in JIAHENG_WAREHOUSE_DEFINITIONS]))).all() } changed = False now = datetime.now() for definition in JIAHENG_WAREHOUSE_DEFINITIONS: warehouse_type = definition["warehouse_type"] warehouse = existing_by_code.get(definition["warehouse_code"]) or existing_by_type.get(warehouse_type) if not warehouse: warehouse = Warehouse( warehouse_code=definition["warehouse_code"], warehouse_name=definition["warehouse_name"], warehouse_type=warehouse_type, manager_employee_id=None, status="ACTIVE", remark=definition["remark"], created_at=now, updated_at=now, ) db.add(warehouse) db.flush() changed = True else: warehouse_changed = False desired_values = { "warehouse_code": definition["warehouse_code"], "warehouse_name": definition["warehouse_name"], "warehouse_type": warehouse_type, "remark": definition["remark"], "status": "ACTIVE", } for field, value in desired_values.items(): if getattr(warehouse, field) != value: setattr(warehouse, field, value) warehouse_changed = True if warehouse_changed: warehouse.updated_at = now db.add(warehouse) changed = True default_location = db.scalar( select(WarehouseLocation).where( WarehouseLocation.warehouse_id == warehouse.id, WarehouseLocation.location_code == definition["location_code"], ) ) if not default_location: db.add( WarehouseLocation( warehouse_id=warehouse.id, location_code=definition["location_code"], location_name=definition["location_name"], zone_name=definition["zone_name"], is_default=1, is_locked=0, status="ACTIVE", remark=definition["remark"], created_at=now, updated_at=now, ) ) changed = True if changed: db.commit() def _normalize_material_read_payload(payload: dict) -> dict: normalized = dict(payload) normalized["purchase_calc_mode"] = "BY_WEIGHT" normalized["purchase_multiple_qty"] = 0 normalized["scrap_sale_price"] = normalized.get("scrap_sale_price") or 0 normalized["remark"] = normalized.get("remark") or normalized.get("quality_rule") normalized["quality_rule"] = normalized.get("quality_rule") or normalized.get("remark") return normalized def _material_remark_value(payload: MaterialCreate | MaterialUpdate) -> str | None: return payload.remark if payload.remark is not None else payload.quality_rule def _normalize_text(value: str | None) -> str: return str(value or "").strip() _CHINESE_CODE_INITIALS = { "产": "C", "品": "P", "原": "Y", "料": "L", "本": "B", "体": "T", "垃": "L", "圾": "J", "桶": "T", "垫": "D", "电": "D", "脑": "N", "支": "Z", "架": "J", "钢": "G", "板": "B", "卷": "J", "铝": "L", "合": "H", "金": "J", "铁": "T", "铜": "T", "不": "B", "锈": "X", "冷": "L", "热": "R", "轧": "Z", "镀": "D", "锌": "X", "冲": "C", "压": "Y", "件": "J", "片": "P", "管": "G", "棒": "B", "圆": "Y", "方": "F", "型": "X", "材": "C", "外": "W", "壳": "K", "底": "D", "座": "Z", "盖": "G", "盒": "H", "箱": "X", "门": "M", "锁": "S", "扣": "K", "连": "L", "接": "J", } def _normalize_code_token(value: str | None, *, max_length: int = 24) -> str: text = _normalize_text(value).upper() if not text: return "" chunks: list[str] = [] current = "" current_kind = "" def flush_current() -> None: nonlocal current, current_kind if current: chunks.append(current) current = "" current_kind = "" for char in text.replace("×", "X").replace("*", "X"): if char.isascii() and (char.isalnum() or char == "."): kind = "LATIN" token_char = char elif "\u4e00" <= char <= "\u9fff": mapped = _CHINESE_CODE_INITIALS.get(char) if mapped: kind = "CJK" token_char = mapped else: kind = "CJK_RAW" token_char = char else: flush_current() continue if current_kind and current_kind != kind: flush_current() current_kind = kind current += token_char flush_current() return "-".join(chunks)[:max_length].strip("-.") def _build_code_prefix(*parts: str, max_length: int = 42) -> str: tokens = [] for part in parts: token = _normalize_code_token(part) if token: tokens.append(token) if not tokens: return "" prefix = "-".join(tokens) return prefix[:max_length].strip("-.") def _next_code_sequence(db: Session, model, column, prefix: str) -> int: if not prefix: return 1 rows = db.scalars(select(column).where(column.like(f"{prefix}-%"))).all() max_no = 0 for code in rows: code_text = _normalize_text(code) if not code_text.startswith(f"{prefix}-"): continue tail = code_text.rsplit("-", 1)[-1] if tail.isdigit(): max_no = max(max_no, int(tail)) return max_no + 1 def _build_sequenced_code(db: Session, model, column, prefix: str) -> str: safe_prefix = prefix[:42].strip("-.") or "AUTO" next_no = _next_code_sequence(db, model, column, safe_prefix) return f"{safe_prefix}-{next_no:03d}" def _build_product_code(db: Session, payload: ProductCreate | ProductUpdate | None = None) -> str: prefix = "产品需规" pattern = re.compile(rf"{re.escape(prefix)}(\d{{5}})") max_no = 0 rows = db.scalars(select(Item.item_code).where(Item.item_type.in_(["PRODUCT", "FINISHED_GOOD"]))).all() for code in rows: match = pattern.fullmatch(_normalize_text(code)) if match: max_no = max(max_no, int(match.group(1))) next_no = max_no + 1 while True: candidate = f"{prefix}{next_no:05d}" if not db.scalar(select(Item.id).where(Item.item_code == candidate).limit(1)): return candidate next_no += 1 def _build_material_code(db: Session, payload: MaterialCreate | MaterialUpdate | None = None) -> str: prefix = "原材料" pattern = re.compile(rf"{re.escape(prefix)}(\d{{5}})") max_no = 0 rows = db.scalars(select(Item.item_code).where(Item.item_type == "RAW_MATERIAL")).all() for code in rows: match = pattern.fullmatch(_normalize_text(code)) if match: max_no = max(max_no, int(match.group(1))) next_no = max_no + 1 while True: candidate = f"{prefix}{next_no:05d}" if not db.scalar(select(Item.id).where(Item.item_code == candidate).limit(1)): return candidate next_no += 1 def _build_bom_code(db: Session, product_item_id: int) -> str: product_item = db.get(Item, product_item_id) if not product_item: raise HTTPException(status_code=404, detail="产品不存在") existing_code = db.scalar( select(Bom.bom_code) .where(Bom.product_item_id == product_item_id) .order_by(Bom.is_default.desc(), Bom.id.desc()) .limit(1) ) if existing_code: return _normalize_text(existing_code) product_code = _normalize_code_token(product_item.item_code, max_length=45) return f"BOM-{product_code}"[:50].strip("-.") def _build_route_code(db: Session, product_item_id: int) -> str: product_item = db.get(Item, product_item_id) if not product_item: raise HTTPException(status_code=404, detail="产品不存在") existing_code = db.scalar( select(ProcessRoute.route_code) .where(ProcessRoute.product_item_id == product_item_id) .order_by(ProcessRoute.is_default.desc(), ProcessRoute.id.desc()) .limit(1) ) if existing_code: return _normalize_text(existing_code) product_code = _normalize_code_token(product_item.item_code, max_length=42) return f"ROUTE-{product_code}"[:50].strip("-.") def _bom_code_version_exists( db: Session, bom_code: str, version_no: str, *, exclude_bom_id: int | None = None, ) -> bool: stmt = select(Bom.id).where(Bom.bom_code == _normalize_text(bom_code), Bom.version_no == _normalize_text(version_no)) if exclude_bom_id is not None: stmt = stmt.where(Bom.id != exclude_bom_id) return db.scalar(stmt) is not None def _route_code_version_exists( db: Session, route_code: str, version_no: str, *, exclude_route_id: int | None = None, ) -> bool: stmt = select(ProcessRoute.id).where( ProcessRoute.route_code == _normalize_text(route_code), ProcessRoute.version_no == _normalize_text(version_no), ) if exclude_route_id is not None: stmt = stmt.where(ProcessRoute.id != exclude_route_id) return db.scalar(stmt) is not None def _product_code_version_exists( db: Session, item_code: str, version_no: str, *, exclude_item_id: int | None = None, ) -> bool: stmt = ( select(Product.item_id) .join(Item, Item.id == Product.item_id) .where(Item.item_code == _normalize_text(item_code), Product.version_no == _normalize_text(version_no)) ) if exclude_item_id is not None: stmt = stmt.where(Product.item_id != exclude_item_id) return db.scalar(stmt) is not None def _item_code_used_by_non_product(db: Session, item_code: str, *, exclude_item_id: int | None = None) -> bool: stmt = select(Item.id).where( Item.item_code == _normalize_text(item_code), Item.item_type.notin_(("PRODUCT", "FINISHED_GOOD")), ) if exclude_item_id is not None: stmt = stmt.where(Item.id != exclude_item_id) return db.scalar(stmt) is not None def _product_has_usage(db: Session, item_id: int) -> bool: references = [ db.scalar(select(func.count(SalesOrderItem.id)).where(SalesOrderItem.product_item_id == item_id)) or 0, db.scalar(select(func.count(MaterialDemand.id)).where(MaterialDemand.product_item_id == item_id)) or 0, db.scalar(select(func.count(WorkOrder.id)).where(WorkOrder.product_item_id == item_id)) or 0, db.scalar(select(func.count(CompletionReceiptItem.id)).where(CompletionReceiptItem.product_item_id == item_id)) or 0, db.scalar(select(func.count(DeliveryItem.id)).where(DeliveryItem.product_item_id == item_id)) or 0, db.scalar(select(func.count(ReturnItem.id)).where(ReturnItem.product_item_id == item_id)) or 0, ] return any(count > 0 for count in references) def _create_bom_record(db: Session, payload: BomUpsert) -> Bom: bom = Bom( bom_code=_normalize_text(payload.bom_code), product_item_id=payload.product_item_id, version_no=_normalize_text(payload.version_no), is_default=int(payload.is_default), status=payload.status, effective_date=payload.effective_date, expire_date=payload.expire_date, remark=payload.remark, ) db.add(bom) db.flush() for item in payload.items: db.add( BomItem( bom_id=bom.id, seq_no=item.seq_no, material_item_id=item.material_item_id, usage_type=_normalize_text(item.usage_type).upper(), qty_per_piece=Decimal(str(item.qty_per_piece)), net_weight_per_piece_kg=Decimal(str(item.net_weight_per_piece_kg)), gross_weight_per_piece_kg=Decimal(str(item.gross_weight_per_piece_kg)), loss_rate=Decimal(str(item.loss_rate)), issue_over_tolerance_rate=Decimal(str(item.issue_over_tolerance_rate)), remark=item.remark, ) ) db.flush() return bom def _create_process_route_record(db: Session, payload: ProcessRouteUpsert) -> ProcessRoute: route = ProcessRoute( route_code=_normalize_text(payload.route_code), route_name=_normalize_text(payload.route_name), product_item_id=payload.product_item_id, version_no=_normalize_text(payload.version_no), is_default=int(payload.is_default), status=payload.status, effective_date=payload.effective_date, expire_date=payload.expire_date, remark=payload.remark, ) db.add(route) db.flush() for operation in payload.operations: db.add( ProcessRouteOperation( route_id=route.id, seq_no=operation.seq_no, process_id=operation.process_id, work_center_id=operation.work_center_id, operation_name=_normalize_text(operation.operation_name), std_setup_minutes=Decimal(str(operation.std_setup_minutes)), std_cycle_seconds=Decimal(str(operation.std_cycle_seconds or 0)), std_labor_cost=Decimal("0"), std_machine_cost=Decimal("0"), std_outsource_cost=Decimal("0"), allowed_scrap_rate=Decimal(str(operation.allowed_scrap_rate)), report_required=int(operation.report_required), is_key_operation=int(operation.is_key_operation), status=operation.status, remark=operation.remark, ) ) db.flush() return route def _create_product_record(db: Session, payload: ProductCreate | ProductUpdate) -> int: item = Item( item_code=_normalize_text(payload.item_code), item_name=_normalize_text(payload.item_name), item_type="FINISHED_GOOD", specification=payload.specification, material_grade=None, unit_weight_kg=Decimal(str(payload.unit_weight_kg)), safety_stock_weight_kg=Decimal(str(payload.safety_stock_weight_kg)), status=payload.status, ) db.add(item) db.flush() db.add( Product( item_id=item.id, drawing_no=payload.drawing_no, product_family=payload.product_family, version_no=_normalize_text(payload.version_no), standard_output_qty=Decimal(str(payload.standard_output_qty)), allowed_scrap_rate=Decimal(str(payload.allowed_scrap_rate)), quality_standard=payload.quality_standard, ) ) db.flush() return item.id def _commit_versioned_master_changes(db: Session) -> None: try: db.commit() except IntegrityError as exc: db.rollback() _raise_versioned_master_integrity_error(exc) def _create_versioned_master_record(db: Session, creator): try: return creator() except IntegrityError as exc: db.rollback() _raise_versioned_master_integrity_error(exc) def _raise_versioned_master_integrity_error(exc: IntegrityError) -> None: error_text = str(exc.orig or exc) if any(token in error_text for token in ("item_code", "bom_code", "route_code", "Duplicate entry")): raise HTTPException( status_code=400, detail="数据库版本化约束尚未更新,请先执行 backend/sql/versioned_master_constraints_patch.sql", ) from exc raise exc def _validate_material_payload(payload: MaterialCreate | MaterialUpdate) -> None: _ensure_required_text(payload.item_name, "原材料名称") _ensure_required_text(payload.specification, "原材料规格") _ensure_required_text(payload.material_grade, "材质牌号") if payload.default_supplier_id is not None and payload.default_supplier_id <= 0: raise HTTPException(status_code=400, detail="默认供应商ID不合法") if payload.unit_weight_kg <= 0: raise HTTPException(status_code=400, detail="原材料重量基准必须大于0") if payload.safety_stock_weight_kg < 0: raise HTTPException(status_code=400, detail="安全库存重量不能小于0") if payload.scrap_sale_price < 0: raise HTTPException(status_code=400, detail="废料单价不能小于0") if payload.min_purchase_qty < 0: raise HTTPException(status_code=400, detail="最小采购重量不能小于0") if payload.purchase_multiple_qty < 0: raise HTTPException(status_code=400, detail="采购倍数不能小于0") if payload.purchase_multiple_weight_kg < 0: raise HTTPException(status_code=400, detail="采购倍数重量不能小于0") if payload.lead_time_days < 0: raise HTTPException(status_code=400, detail="采购提前期不能小于0") def _validate_product_payload(payload: ProductCreate | ProductUpdate) -> None: _ensure_required_text(payload.item_name, "产品名称") _ensure_required_text(payload.version_no, "版本号") if payload.unit_weight_kg <= 0: raise HTTPException(status_code=400, detail="产品单重必须大于0") if payload.allowed_scrap_rate < 0: raise HTTPException(status_code=400, detail="允许报废率不能小于0") if payload.standard_output_qty < 0: raise HTTPException(status_code=400, detail="标准产出数量不能小于0") if payload.safety_stock_weight_kg < 0: raise HTTPException(status_code=400, detail="安全库存重量不能小于0") def _validate_bom_payload(db: Session, payload: BomUpsert) -> None: _ensure_required_text(payload.version_no, "BOM版本号") if payload.effective_date and payload.expire_date and payload.effective_date > payload.expire_date: raise HTTPException(status_code=400, detail="BOM失效日期不能早于生效日期") product = db.scalar(select(Product).where(Product.item_id == payload.product_item_id)) if not product: raise HTTPException(status_code=404, detail="产品不存在") if not payload.items: raise HTTPException(status_code=400, detail="BOM至少需要一条明细") seq_set: set[int] = set() for item in payload.items: if item.seq_no <= 0: raise HTTPException(status_code=400, detail="BOM明细序号必须大于0") if item.seq_no in seq_set: raise HTTPException(status_code=400, detail="BOM明细序号不能重复") seq_set.add(item.seq_no) usage_type = (item.usage_type or "").strip().upper() if usage_type != "WEIGHT": raise HTTPException(status_code=400, detail="BOM明细原材料用量只支持按重量维护") if item.qty_per_piece < 0: raise HTTPException(status_code=400, detail=f"BOM序号 {item.seq_no} 的单件用量不能小于0") if item.net_weight_per_piece_kg < 0: raise HTTPException(status_code=400, detail=f"BOM序号 {item.seq_no} 的单件净重不能小于0") if item.gross_weight_per_piece_kg < 0: raise HTTPException(status_code=400, detail=f"BOM序号 {item.seq_no} 的单件毛重不能小于0") if item.loss_rate < 0: raise HTTPException(status_code=400, detail=f"BOM序号 {item.seq_no} 的损耗率不能小于0") if item.issue_over_tolerance_rate < 0: raise HTTPException(status_code=400, detail=f"BOM序号 {item.seq_no} 的超发容差率不能小于0") if usage_type == "WEIGHT" and item.gross_weight_per_piece_kg <= 0: raise HTTPException(status_code=400, detail=f"BOM序号 {item.seq_no} 的单件毛重必须大于0") if ( item.net_weight_per_piece_kg > 0 and item.gross_weight_per_piece_kg > 0 and item.gross_weight_per_piece_kg < item.net_weight_per_piece_kg ): raise HTTPException(status_code=400, detail=f"BOM序号 {item.seq_no} 的单件毛重不能小于单件净重") material = db.scalar(select(Material).where(Material.item_id == item.material_item_id)) if not material: raise HTTPException(status_code=404, detail=f"原材料ID {item.material_item_id} 不存在") def _clear_default_bom(db: Session, product_item_id: int, exclude_bom_id: int | None = None) -> None: existing_rows = db.scalars(select(Bom).where(Bom.product_item_id == product_item_id)).all() for row in existing_rows: if exclude_bom_id and row.id == exclude_bom_id: continue row.is_default = 0 db.add(row) def _has_active_default_bom(db: Session, product_item_id: int, exclude_bom_id: int | None = None) -> bool: stmt = select(func.count(Bom.id)).where( Bom.product_item_id == product_item_id, Bom.status == "ACTIVE", Bom.is_default == 1, ) if exclude_bom_id is not None: stmt = stmt.where(Bom.id != exclude_bom_id) return bool(db.scalar(stmt) or 0) def _bom_has_usage(db: Session, bom_id: int) -> bool: bom_item_ids = db.scalars(select(BomItem.id).where(BomItem.bom_id == bom_id)).all() mrp_count = 0 material_count = 0 if bom_item_ids: mrp_count = db.scalar(select(func.count(MaterialDemand.id)).where(MaterialDemand.bom_item_id.in_(bom_item_ids))) or 0 material_count = ( db.scalar(select(func.count(WorkOrderMaterial.id)).where(WorkOrderMaterial.bom_item_id.in_(bom_item_ids))) or 0 ) work_order_count = db.scalar(select(func.count(WorkOrder.id)).where(WorkOrder.bom_id == bom_id)) or 0 return bool(mrp_count or work_order_count or material_count) def _list_bom_reads(db: Session, limit: int) -> list[BomRead]: rows = db.execute( select( Bom.id.label("bom_id"), Bom.bom_code.label("bom_code"), Bom.product_item_id.label("product_item_id"), Item.item_code.label("product_code"), Item.item_name.label("product_name"), Bom.version_no.label("version_no"), Bom.is_default.label("is_default"), Bom.status.label("status"), Bom.effective_date.label("effective_date"), Bom.expire_date.label("expire_date"), Bom.remark.label("remark"), ) .join(Item, Item.id == Bom.product_item_id) .order_by(Bom.is_default.desc(), Bom.id.desc()) .limit(limit) ).mappings().all() if not rows: return [] bom_ids = [row["bom_id"] for row in rows] item_rows = db.execute( select( BomItem.id.label("bom_item_id"), BomItem.bom_id.label("bom_id"), BomItem.seq_no.label("seq_no"), BomItem.material_item_id.label("material_item_id"), Item.item_code.label("material_code"), Item.item_name.label("material_name"), BomItem.usage_type.label("usage_type"), BomItem.qty_per_piece.label("qty_per_piece"), BomItem.net_weight_per_piece_kg.label("net_weight_per_piece_kg"), BomItem.gross_weight_per_piece_kg.label("gross_weight_per_piece_kg"), BomItem.loss_rate.label("loss_rate"), BomItem.issue_over_tolerance_rate.label("issue_over_tolerance_rate"), BomItem.remark.label("remark"), ) .join(Item, Item.id == BomItem.material_item_id) .where(BomItem.bom_id.in_(bom_ids)) .order_by(BomItem.bom_id, BomItem.seq_no) ).mappings().all() item_map: dict[int, list[dict]] = {} for row in item_rows: item_map.setdefault(row["bom_id"], []).append(dict(row)) return [ BomRead( bom_id=row["bom_id"], bom_code=row["bom_code"], product_item_id=row["product_item_id"], product_code=row["product_code"], product_name=row["product_name"], version_no=row["version_no"], is_default=bool(row["is_default"]), status=row["status"], effective_date=row["effective_date"], expire_date=row["expire_date"], remark=row["remark"], item_count=len(item_map.get(row["bom_id"], [])), items=item_map.get(row["bom_id"], []), ) for row in rows ] def _get_bom_read(db: Session, bom_id: int) -> BomRead: rows = _list_bom_reads(db, limit=500) for row in rows: if row.bom_id == bom_id: return row raise HTTPException(status_code=404, detail="BOM不存在") def _ensure_active_master_record(status: str | None, message: str) -> None: if _normalize_text(status).upper() == "INACTIVE": raise HTTPException(status_code=400, detail=message) def _validate_process_route_payload(db: Session, payload: ProcessRouteUpsert) -> None: _ensure_required_text(payload.route_name, "工艺路线名称") _ensure_required_text(payload.version_no, "工艺路线版本号") if payload.effective_date and payload.expire_date and payload.effective_date > payload.expire_date: raise HTTPException(status_code=400, detail="工艺路线失效日期不能早于生效日期") product = db.scalar(select(Product).where(Product.item_id == payload.product_item_id)) if not product: raise HTTPException(status_code=404, detail="产品不存在") if not payload.operations: raise HTTPException(status_code=400, detail="工艺路线至少需要一条工序") seq_set: set[int] = set() for operation in payload.operations: if operation.seq_no <= 0: raise HTTPException(status_code=400, detail="工序序号必须大于0") if operation.seq_no in seq_set: raise HTTPException(status_code=400, detail="工序序号不能重复") seq_set.add(operation.seq_no) _ensure_required_text(operation.operation_name, f"工序序号 {operation.seq_no} 的工序名称") if operation.std_setup_minutes < 0: raise HTTPException(status_code=400, detail=f"工序序号 {operation.seq_no} 的准备时长不能小于0") if operation.std_cycle_seconds < 0: raise HTTPException(status_code=400, detail=f"工序序号 {operation.seq_no} 的参考节拍不能小于0") if operation.std_labor_cost < 0: raise HTTPException(status_code=400, detail=f"工序序号 {operation.seq_no} 的人工成本不能小于0") if operation.std_machine_cost < 0: raise HTTPException(status_code=400, detail=f"工序序号 {operation.seq_no} 的设备成本不能小于0") if operation.std_outsource_cost < 0: raise HTTPException(status_code=400, detail=f"工序序号 {operation.seq_no} 的委外成本不能小于0") if operation.allowed_scrap_rate < 0: raise HTTPException(status_code=400, detail=f"工序序号 {operation.seq_no} 的允许报废率不能小于0") process = db.get(Process, operation.process_id) if not process: raise HTTPException(status_code=404, detail=f"工序ID {operation.process_id} 不存在") work_center = db.get(WorkCenter, operation.work_center_id) if not work_center: raise HTTPException(status_code=404, detail=f"工作中心ID {operation.work_center_id} 不存在") def _ensure_route_payload_does_not_touch_inactive_operations( db: Session, route_id: int, payload: ProcessRouteUpsert, ) -> None: inactive_operations = db.scalars( select(ProcessRouteOperation).where( ProcessRouteOperation.route_id == route_id, ProcessRouteOperation.status == "INACTIVE", ) ).all() if not inactive_operations: return inactive_names = { _normalize_text(operation.operation_name) for operation in inactive_operations if _normalize_text(operation.operation_name) } inactive_seq_nos = {int(operation.seq_no) for operation in inactive_operations if operation.seq_no} payload_names = { _normalize_text(operation.operation_name) for operation in payload.operations if _normalize_text(operation.operation_name) } payload_seq_nos = {int(operation.seq_no) for operation in payload.operations if operation.seq_no} touched_names = inactive_names & payload_names if touched_names: raise HTTPException( status_code=400, detail=f"已停用工序 {', '.join(sorted(touched_names))} 只读,不允许修改;如需重新启用,请先在小程序产品清单重新添加该工序", ) touched_seq_nos = inactive_seq_nos & payload_seq_nos if touched_seq_nos: seq_labels = ", ".join(str(seq_no) for seq_no in sorted(touched_seq_nos)) raise HTTPException( status_code=400, detail=f"工序序号 {seq_labels} 已被停用历史工序占用,不允许覆盖修改", ) def _clear_default_route(db: Session, product_item_id: int, exclude_route_id: int | None = None) -> None: existing_rows = db.scalars(select(ProcessRoute).where(ProcessRoute.product_item_id == product_item_id)).all() for row in existing_rows: if exclude_route_id and row.id == exclude_route_id: continue row.is_default = 0 db.add(row) def _has_active_default_route(db: Session, product_item_id: int, exclude_route_id: int | None = None) -> bool: stmt = select(func.count(ProcessRoute.id)).where( ProcessRoute.product_item_id == product_item_id, ProcessRoute.status == "ACTIVE", ProcessRoute.is_default == 1, ) if exclude_route_id is not None: stmt = stmt.where(ProcessRoute.id != exclude_route_id) return bool(db.scalar(stmt) or 0) def _route_has_usage(db: Session, route_id: int) -> bool: work_order_count = db.scalar(select(func.count(WorkOrder.id)).where(WorkOrder.route_id == route_id)) or 0 return bool(work_order_count) def _ensure_required_text(value: str | None, field_name: str) -> None: if value is None or not str(value).strip(): raise HTTPException(status_code=400, detail=f"{field_name}不能为空") def _raise_delete_blocked(entity_name: str, references: list[tuple[str, int]]) -> None: used = [f"{label}({count})" for label, count in references if count > 0] if used: raise HTTPException( status_code=400, detail=f"{entity_name}已被业务数据引用,不能删除:{'、'.join(used[:6])}", ) def _ensure_material_can_delete(db: Session, item_id: int) -> None: references = [ ("BOM明细", db.scalar(select(func.count(BomItem.id)).where(BomItem.material_item_id == item_id)) or 0), ("MRP需求", db.scalar(select(func.count(MaterialDemand.id)).where(MaterialDemand.material_item_id == item_id)) or 0), ("采购订单明细", db.scalar(select(func.count(PurchaseOrderItem.id)).where(PurchaseOrderItem.material_item_id == item_id)) or 0), ("到货入库明细", db.scalar(select(func.count(PurchaseReceiptItem.id)).where(PurchaseReceiptItem.material_item_id == item_id)) or 0), ("工单领料", db.scalar(select(func.count(WorkOrderMaterial.id)).where(WorkOrderMaterial.material_item_id == item_id)) or 0), ("库存批次", db.scalar(select(func.count(StockLot.id)).where(StockLot.item_id == item_id)) or 0), ("库存台账", db.scalar(select(func.count(StockBalance.id)).where(StockBalance.item_id == item_id)) or 0), ("库存流水", db.scalar(select(func.count(InventoryTxn.id)).where(InventoryTxn.item_id == item_id)) or 0), ("报废记录", db.scalar(select(func.count(ScrapRecord.id)).where(ScrapRecord.item_id == item_id)) or 0), ] _raise_delete_blocked("该原材料", references) def _ensure_product_can_delete(db: Session, item_id: int) -> None: references = [ ("BOM版本", db.scalar(select(func.count(Bom.id)).where(Bom.product_item_id == item_id)) or 0), ("工艺路线", db.scalar(select(func.count(ProcessRoute.id)).where(ProcessRoute.product_item_id == item_id)) or 0), ("订单明细", db.scalar(select(func.count(SalesOrderItem.id)).where(SalesOrderItem.product_item_id == item_id)) or 0), ("MRP需求", db.scalar(select(func.count(MaterialDemand.id)).where(MaterialDemand.product_item_id == item_id)) or 0), ("生产工单", db.scalar(select(func.count(WorkOrder.id)).where(WorkOrder.product_item_id == item_id)) or 0), ("成品入库明细", db.scalar(select(func.count(CompletionReceiptItem.id)).where(CompletionReceiptItem.product_item_id == item_id)) or 0), ("发货明细", db.scalar(select(func.count(DeliveryItem.id)).where(DeliveryItem.product_item_id == item_id)) or 0), ("退货明细", db.scalar(select(func.count(ReturnItem.id)).where(ReturnItem.product_item_id == item_id)) or 0), ("库存批次", db.scalar(select(func.count(StockLot.id)).where(StockLot.item_id == item_id)) or 0), ("库存台账", db.scalar(select(func.count(StockBalance.id)).where(StockBalance.item_id == item_id)) or 0), ("库存流水", db.scalar(select(func.count(InventoryTxn.id)).where(InventoryTxn.item_id == item_id)) or 0), ("报废记录", db.scalar(select(func.count(ScrapRecord.id)).where(ScrapRecord.item_id == item_id)) or 0), ("成本分摊", db.scalar(select(func.count(CostAllocation.id)).where(CostAllocation.product_item_id == item_id)) or 0), ] _raise_delete_blocked("该产品", references) def _ensure_product_spec_can_delete(db: Session, item_id: int) -> None: bom_ids = db.scalars(select(Bom.id).where(Bom.product_item_id == item_id)).all() route_ids = db.scalars(select(ProcessRoute.id).where(ProcessRoute.product_item_id == item_id)).all() references = [ ("已引用BOM版本", sum(1 for bom_id in bom_ids if _bom_has_usage(db, bom_id))), ("已引用工艺路线", sum(1 for route_id in route_ids if _route_has_usage(db, route_id))), ("订单明细", db.scalar(select(func.count(SalesOrderItem.id)).where(SalesOrderItem.product_item_id == item_id)) or 0), ("MRP需求", db.scalar(select(func.count(MaterialDemand.id)).where(MaterialDemand.product_item_id == item_id)) or 0), ("生产工单", db.scalar(select(func.count(WorkOrder.id)).where(WorkOrder.product_item_id == item_id)) or 0), ("成品入库明细", db.scalar(select(func.count(CompletionReceiptItem.id)).where(CompletionReceiptItem.product_item_id == item_id)) or 0), ("发货明细", db.scalar(select(func.count(DeliveryItem.id)).where(DeliveryItem.product_item_id == item_id)) or 0), ("退货明细", db.scalar(select(func.count(ReturnItem.id)).where(ReturnItem.product_item_id == item_id)) or 0), ("库存批次", db.scalar(select(func.count(StockLot.id)).where(StockLot.item_id == item_id)) or 0), ("库存台账", db.scalar(select(func.count(StockBalance.id)).where(StockBalance.item_id == item_id)) or 0), ("库存流水", db.scalar(select(func.count(InventoryTxn.id)).where(InventoryTxn.item_id == item_id)) or 0), ("报废记录", db.scalar(select(func.count(ScrapRecord.id)).where(ScrapRecord.item_id == item_id)) or 0), ("成本分摊", db.scalar(select(func.count(CostAllocation.id)).where(CostAllocation.product_item_id == item_id)) or 0), ] _raise_delete_blocked("该产品需规", references) def _ensure_employee_can_delete(db: Session, employee_id: int) -> None: references = [ ("系统账号", db.scalar(select(func.count(User.id)).where(User.employee_id == employee_id)) or 0), ("员工上下级关系", db.scalar(select(func.count(Employee.id)).where(Employee.manager_employee_id == employee_id)) or 0), ("仓库负责人", db.scalar(select(func.count(Warehouse.id)).where(Warehouse.manager_employee_id == employee_id)) or 0), ("保养工单负责人", db.scalar(select(func.count(MaintenanceOrder.id)).where(MaintenanceOrder.owner_employee_id == employee_id)) or 0), ("采购订单", db.scalar(select(func.count(PurchaseOrder.id)).where(PurchaseOrder.purchaser_employee_id == employee_id)) or 0), ("到货入库", db.scalar(select(func.count(PurchaseReceipt.id)).where(PurchaseReceipt.receiver_employee_id == employee_id)) or 0), ("订单", db.scalar(select(func.count(SalesOrder.id)).where(SalesOrder.sales_employee_id == employee_id)) or 0), ("工序报工", db.scalar(select(func.count(OperationReport.id)).where(OperationReport.employee_id == employee_id)) or 0), ("成品入库", db.scalar(select(func.count(CompletionReceipt.id)).where(CompletionReceipt.receiver_employee_id == employee_id)) or 0), ("发货单", db.scalar(select(func.count(Delivery.id)).where(Delivery.shipper_employee_id == employee_id)) or 0), ("退货处理", db.scalar(select(func.count(ReturnOrder.id)).where(ReturnOrder.handler_employee_id == employee_id)) or 0), ] _raise_delete_blocked("该员工", references) def _ensure_bom_can_delete(db: Session, bom: Bom) -> None: if _bom_has_usage(db, bom.id): raise HTTPException(status_code=400, detail="该BOM已被MRP或工单引用,不能删除") if bom.is_default and bom.status == "ACTIVE" and not _has_active_default_bom( db, bom.product_item_id, exclude_bom_id=bom.id ): raise HTTPException(status_code=400, detail="当前是产品唯一默认BOM,请先建立新的默认版本后再删除") def _ensure_route_can_delete(db: Session, route: ProcessRoute) -> None: if _route_has_usage(db, route.id): raise HTTPException(status_code=400, detail="该工艺路线已被工单引用,不能删除") if route.is_default and route.status == "ACTIVE" and not _has_active_default_route( db, route.product_item_id, exclude_route_id=route.id ): raise HTTPException(status_code=400, detail="当前是产品唯一默认工艺路线,请先建立新的默认版本后再删除") def _list_process_route_reads(db: Session, limit: int) -> list[ProcessRouteRead]: rows = db.execute( select( ProcessRoute.id.label("route_id"), ProcessRoute.route_code.label("route_code"), ProcessRoute.route_name.label("route_name"), ProcessRoute.product_item_id.label("product_item_id"), Item.item_code.label("product_code"), Item.item_name.label("product_name"), ProcessRoute.version_no.label("version_no"), ProcessRoute.is_default.label("is_default"), ProcessRoute.status.label("status"), ProcessRoute.effective_date.label("effective_date"), ProcessRoute.expire_date.label("expire_date"), ProcessRoute.remark.label("remark"), ) .join(Item, Item.id == ProcessRoute.product_item_id) .order_by(ProcessRoute.is_default.desc(), ProcessRoute.id.desc()) .limit(limit) ).mappings().all() if not rows: return [] route_ids = [row["route_id"] for row in rows] miniapp_stamping_subquery = ( select( MiniAppProduct.project_no.label("project_no"), MiniAppProduct.product_name.label("product_name"), MiniAppProduct.process_name.label("process_name"), func.max(MiniAppProduct.stamping_method).label("stamping_method"), ) .where(MiniAppProduct.stamping_method.is_not(None), MiniAppProduct.stamping_method != "") .group_by(MiniAppProduct.project_no, MiniAppProduct.product_name, MiniAppProduct.process_name) .subquery() ) operation_rows = db.execute( select( ProcessRouteOperation.id.label("route_operation_id"), ProcessRouteOperation.route_id.label("route_id"), ProcessRouteOperation.seq_no.label("seq_no"), ProcessRouteOperation.process_id.label("process_id"), Process.process_code.label("process_code"), Process.process_name.label("process_name"), miniapp_stamping_subquery.c.stamping_method.label("stamping_method"), ProcessRouteOperation.work_center_id.label("work_center_id"), WorkCenter.center_code.label("work_center_code"), WorkCenter.center_name.label("work_center_name"), ProcessRouteOperation.operation_name.label("operation_name"), ProcessRouteOperation.std_setup_minutes.label("std_setup_minutes"), ProcessRouteOperation.std_cycle_seconds.label("std_cycle_seconds"), ProcessRouteOperation.std_labor_cost.label("std_labor_cost"), ProcessRouteOperation.std_machine_cost.label("std_machine_cost"), ProcessRouteOperation.std_outsource_cost.label("std_outsource_cost"), ProcessRouteOperation.allowed_scrap_rate.label("allowed_scrap_rate"), ProcessRouteOperation.report_required.label("report_required"), ProcessRouteOperation.is_key_operation.label("is_key_operation"), ProcessRouteOperation.status.label("status"), ProcessRouteOperation.remark.label("remark"), ) .join(ProcessRoute, ProcessRoute.id == ProcessRouteOperation.route_id) .join(Item, Item.id == ProcessRoute.product_item_id) .join(Product, Product.item_id == Item.id) .join(Process, Process.id == ProcessRouteOperation.process_id) .join(WorkCenter, WorkCenter.id == ProcessRouteOperation.work_center_id) .outerjoin( miniapp_stamping_subquery, and_( or_( miniapp_stamping_subquery.c.project_no == Product.drawing_no, miniapp_stamping_subquery.c.project_no == Product.product_family, miniapp_stamping_subquery.c.project_no == Item.item_code, ), miniapp_stamping_subquery.c.product_name == Item.item_name, miniapp_stamping_subquery.c.process_name == ProcessRouteOperation.operation_name, ), ) .where(ProcessRouteOperation.route_id.in_(route_ids)) .order_by(ProcessRouteOperation.route_id, ProcessRouteOperation.seq_no) ).mappings().all() operation_map: dict[int, list[dict]] = {} for row in operation_rows: payload = dict(row) payload["report_required"] = bool(payload["report_required"]) payload["is_key_operation"] = bool(payload["is_key_operation"]) operation_map.setdefault(payload["route_id"], []).append(payload) return [ ProcessRouteRead( route_id=row["route_id"], route_code=row["route_code"], route_name=row["route_name"], product_item_id=row["product_item_id"], product_code=row["product_code"], product_name=row["product_name"], version_no=row["version_no"], is_default=bool(row["is_default"]), status=row["status"], effective_date=row["effective_date"], expire_date=row["expire_date"], remark=row["remark"], operation_count=len(operation_map.get(row["route_id"], [])), operations=operation_map.get(row["route_id"], []), ) for row in rows ] def _get_process_route_read(db: Session, route_id: int) -> ProcessRouteRead: rows = _list_process_route_reads(db, limit=500) for row in rows: if row.route_id == route_id: return row raise HTTPException(status_code=404, detail="工艺路线不存在")