ForgeFlow-ERP/backend/app/api/routes/master_data.py
2026-06-12 16:00:56 +08:00

4390 lines
176 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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="工艺路线不存在")