commit c927886888067e5befcf9ced9965379d3da20da5 Author: 汤学会 Date: Fri Jun 12 16:00:56 2026 +0800 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0b3097b --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.DS_Store +__pycache__/ +*.pyc +.env +.venv/ +node_modules/ +dist/ +.idea/ +.vscode/ +.pytest_cache/ +*.log +outputs/ +backend/storage/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..bc57945 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# ForgeFlow ERP + +宁波嘉恒智能科技有限公司五金行业 ERP 初始骨架,采用前后端分离架构: + +- 前端:Vue 3 + Vite +- 后端:FastAPI +- 数据库:MySQL 8 + +当前版本已经包含: + +- 五金行业 ERP 的业务设计文档 +- 覆盖销售、MRP、采购、仓储、生产报工、设备、财务、退货的 MySQL DDL +- FastAPI 模块化接口骨架与示例数据 +- 工业风 Vue 管理端首页与流程中心页面 + +## 目录结构 + +```text +backend/ + app/ + sql/ +docs/ +frontend/ +``` + +## 启动方式 + +### 后端 + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --reload +``` + +### 前端 + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +npm install +npm run dev +``` + +## 重要文件 + +- 业务设计文档:`/Users/souplearn/Gitlab/py/ForgeFlow-ERP/docs/system-design.md` +- 数据库 DDL:`/Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend/sql/jiaheng_erp_schema.sql` +- 后端入口:`/Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend/app/main.py` +- 前端入口:`/Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend/src/main.js` + +## 下一步建议 + +1. 你执行 DDL 建库建表。 +2. 下一轮把 MySQL 连接信息给我。 +3. 我继续把 SQLAlchemy 模型、真实 CRUD、登录鉴权、单据流转和报工小程序接入补齐。 + diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..7671bc4 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,12 @@ +APP_NAME=Jiaheng Hardware ERP API +APP_ENV=dev +APP_HOST=0.0.0.0 +APP_PORT=8000 +MYSQL_HOST=127.0.0.1 +MYSQL_PORT=3306 +MYSQL_DATABASE=jiaheng_erp +MYSQL_USER=root +MYSQL_PASSWORD=replace_me +ALLOWED_ORIGINS=http://localhost:5173 +AUTH_SECRET_KEY=please-change-this-in-production +AUTH_TOKEN_EXPIRE_MINUTES=480 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/api/router.py b/backend/app/api/router.py new file mode 100644 index 0000000..4761482 --- /dev/null +++ b/backend/app/api/router.py @@ -0,0 +1,38 @@ +from fastapi import APIRouter + +from app.api.routes import ( + auth, + dashboard, + document_archives, + equipment, + finance, + inventory, + master_data, + planning, + production, + purchase, + quality, + returns, + sales, + system, + system_extension, + system_permissions, +) + +api_router = APIRouter() +api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) +api_router.include_router(document_archives.router, prefix="/document-archives", tags=["document-archives"]) +api_router.include_router(system.router, prefix="/system", tags=["system"]) +api_router.include_router(system_extension.router, prefix="/system-extension", tags=["system-extension"]) +api_router.include_router(system_permissions.router, prefix="/system-permissions", tags=["system-permissions"]) +api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"]) +api_router.include_router(master_data.router, prefix="/master-data", tags=["master-data"]) +api_router.include_router(sales.router, prefix="/sales", tags=["orders-customers"]) +api_router.include_router(planning.router, prefix="/planning", tags=["planning"]) +api_router.include_router(purchase.router, prefix="/purchase", tags=["purchase"]) +api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"]) +api_router.include_router(production.router, prefix="/production", tags=["production"]) +api_router.include_router(equipment.router, prefix="/equipment", tags=["equipment"]) +api_router.include_router(quality.router, prefix="/quality", tags=["quality"]) +api_router.include_router(returns.router, prefix="/returns", tags=["returns"]) +api_router.include_router(finance.router, prefix="/finance", tags=["finance"]) diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/api/routes/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py new file mode 100644 index 0000000..b968fba --- /dev/null +++ b/backend/app/api/routes/auth.py @@ -0,0 +1,69 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.models.org import User +from app.schemas.database import ( + AuthChangePasswordRequest, + AuthLoginRequest, + AuthResetPasswordRequest, + AuthSession, + LoginUser, +) +from app.services.auth import ( + build_login_user, + get_current_auth_context, + hash_password, + login_with_password, + require_system_admin, + verify_password, +) + +router = APIRouter() + + +@router.post("/login", response_model=AuthSession) +def login(payload: AuthLoginRequest, db: Session = Depends(get_db)) -> AuthSession: + return login_with_password(db, payload.username, payload.password) + + +@router.get("/me", response_model=LoginUser) +def profile(context=Depends(get_current_auth_context)) -> LoginUser: + return build_login_user(context) + + +@router.post("/password/change") +def change_password( + payload: AuthChangePasswordRequest, + context=Depends(get_current_auth_context), + db: Session = Depends(get_db), +) -> dict[str, str]: + user = db.get(User, context.user.id) + if not user: + raise HTTPException(status_code=404, detail="当前用户不存在") + if not verify_password(payload.old_password, user.password_hash): + raise HTTPException(status_code=400, detail="原密码不正确") + if verify_password(payload.new_password, user.password_hash): + raise HTTPException(status_code=400, detail="新密码不能与原密码相同") + + user.password_hash = hash_password(payload.new_password) + db.add(user) + db.commit() + return {"message": "密码已修改"} + + +@router.post("/password/reset") +def reset_password( + payload: AuthResetPasswordRequest, + context=Depends(require_system_admin), + db: Session = Depends(get_db), +) -> dict[str, str]: + user = db.scalar(select(User).where(User.username == payload.username)) + if not user: + raise HTTPException(status_code=404, detail="账号不存在") + + user.password_hash = hash_password(payload.new_password) + db.add(user) + db.commit() + return {"message": f"账号 {payload.username} 密码已重置"} diff --git a/backend/app/api/routes/dashboard.py b/backend/app/api/routes/dashboard.py new file mode 100644 index 0000000..067288d --- /dev/null +++ b/backend/app/api/routes/dashboard.py @@ -0,0 +1,1490 @@ +from __future__ import annotations + +from decimal import Decimal +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import false, func, select +from sqlalchemy.orm import Session, aliased + +from app.db.session import get_db +from app.models.master_data import Item, Warehouse +from app.models.operations import ( + CompletionReceipt, + CompletionReceiptItem, + Delivery, + DeliveryItem, + OperationReport, + PurchaseOrder, + PurchaseOrderItem, + PurchaseReceipt, + PurchaseReceiptItem, + ReturnDisposition, + ReturnItem, + ReturnOrder, + ScrapRecord, + StockLot, + Supplier, + WorkOrder, + WorkOrderMaterialIssue, + WorkOrderOperation, +) +from app.models.org import Employee +from app.models.planning import MaterialDemand +from app.models.sales import Customer, SalesOrder, SalesOrderItem +from app.schemas.dashboard import ( + DashboardPayload, + DashboardMaterialLotRead, + DashboardSalesOrderRead, + LifecycleDetailItem, + LifecycleEdge, + LifecycleNode, + MaterialLotLifecycleRead, + SalesOrderLifecycleRead, +) +from app.services.auth import require_authenticated_user +from app.services.mock_data import get_dashboard_payload + +router = APIRouter(dependencies=[Depends(require_authenticated_user)]) + +STATUS_LABELS = { + "ACTIVE": "启用", + "INACTIVE": "停用", + "OPEN": "未开始", + "PARTIAL": "部分完成", + "CLOSED": "已关闭", + "ORDERED": "已下单", + "RECEIVED": "已收货", + "RELEASED": "已下达", + "IN_PROGRESS": "进行中", + "READY_TO_RECEIPT": "待成品入库", + "COMPLETED": "已完工", + "DONE": "已完成", + "PENDING": "待处理", + "PENDING_QC": "待质检", + "PASSED": "已放行", + "PASS": "已放行", + "REJECTED": "已拒收", + "REJECT": "已拒收", + "POSTED": "已过账", + "AVAILABLE": "可用", + "LOCKED": "锁定", + "DEPLETED": "已耗尽", + "CONFIRMED": "已确认", + "OVERRIDDEN": "已覆盖", + "CREATED": "已创建", + "IDLE": "空闲", + "SUPERSEDED": "已替代", + "SCRAP": "已报废", + "SCRAPPED": "已报废", + "NORMAL": "正常", + "ABNORMAL": "异常", +} + +WORK_ORDER_TYPE_LABELS = { + "NORMAL": "常规生产", + "REWORK": "返工生产", +} + +DISPOSAL_TYPE_LABELS = { + "SCRAP": "报废", + "REWORK": "返工", +} + + +def _num(value: Any) -> float: + if value is None: + return 0.0 + if isinstance(value, Decimal): + return float(value) + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + +def _fmt_qty(value: Any, digits: int = 2) -> str: + numeric = _num(value) + if abs(numeric - round(numeric)) < 0.000001: + return f"{int(round(numeric))}" + return f"{numeric:.{digits}f}".rstrip("0").rstrip(".") + + +def _fmt_weight(value: Any) -> str: + return f"{_fmt_qty(value, 3)} kg" + + +def _fmt_money(value: Any) -> str: + return f"¥{_num(value):,.2f}" + + +def _fmt_date(value: Any) -> str: + if not value: + return "-" + return str(value)[:10] + + +def _fmt_datetime(value: Any) -> str: + if not value: + return "-" + return str(value).replace("T", " ")[:19] + + +def _label(mapping: dict[str, str], value: Any) -> str: + if value is None or value == "": + return "-" + key = str(value).upper() + return mapping.get(key, str(value)) + + +def _status_label(value: Any) -> str: + return _label(STATUS_LABELS, value) + + +def _disposal_label(value: Any) -> str: + return _label(DISPOSAL_TYPE_LABELS, value) + + +def _detail(label: str, value: Any) -> LifecycleDetailItem: + text = "-" if value is None or value == "" else str(value) + return LifecycleDetailItem(label=label, value=text) + + +def _node( + *, + node_id: str, + node_type: str, + title: str, + stage: str, + level: int, + subtitle: str | None = None, + status: str | None = None, + tone: str = "normal", + summary: str | None = None, + metrics: list[tuple[str, Any]] | None = None, + details: list[tuple[str, Any]] | None = None, + raw: dict[str, Any] | None = None, +) -> LifecycleNode: + return LifecycleNode( + id=node_id, + type=node_type, + title=title, + subtitle=subtitle, + status=status, + stage=stage, + level=level, + tone=tone, + summary=summary, + metrics=[_detail(label, value) for label, value in (metrics or [])], + details=[_detail(label, value) for label, value in (details or [])], + raw=raw or {}, + ) + + +def _edge(source: str, target: str, label: str | None = None) -> LifecycleEdge: + return LifecycleEdge(id=f"{source}->{target}", source=source, target=target, label=label) + + +def _tone_for_status(status: str | None) -> str: + value = str(status or "").upper() + if value in {"REJECTED", "SCRAP", "SCRAPPED", "LOCKED"}: + return "danger" + if value in {"PENDING", "PENDING_QC", "OPEN", "PARTIAL", "IN_PROGRESS", "RELEASED", "NEW", "CREATED"}: + return "warning" + if value in {"CLOSED", "COMPLETED", "DONE", "POSTED", "PASSED", "RECEIVED", "CONFIRMED"}: + return "success" + return "normal" + + +def _sales_order_rows(limit: int = 100): + line_subquery = ( + select( + SalesOrderItem.sales_order_id.label("sales_order_id"), + func.count(SalesOrderItem.id).label("line_count"), + func.coalesce(func.sum(SalesOrderItem.order_qty), 0).label("total_order_qty"), + func.coalesce(func.sum(SalesOrderItem.delivered_qty), 0).label("total_delivered_qty"), + ) + .group_by(SalesOrderItem.sales_order_id) + .subquery() + ) + mrp_subquery = ( + select( + SalesOrderItem.sales_order_id.label("sales_order_id"), + func.count(MaterialDemand.id).label("mrp_count"), + ) + .join(MaterialDemand, MaterialDemand.sales_order_item_id == SalesOrderItem.id) + .where(MaterialDemand.status != "SUPERSEDED") + .group_by(SalesOrderItem.sales_order_id) + .subquery() + ) + purchase_subquery = ( + select( + SalesOrderItem.sales_order_id.label("sales_order_id"), + func.count(func.distinct(PurchaseOrderItem.purchase_order_id)).label("purchase_order_count"), + ) + .join(MaterialDemand, MaterialDemand.sales_order_item_id == SalesOrderItem.id) + .join(PurchaseOrderItem, PurchaseOrderItem.source_demand_id == MaterialDemand.id) + .group_by(SalesOrderItem.sales_order_id) + .subquery() + ) + work_order_subquery = ( + select( + SalesOrderItem.sales_order_id.label("sales_order_id"), + func.count(WorkOrder.id).label("work_order_count"), + ) + .join(WorkOrder, WorkOrder.source_sales_order_item_id == SalesOrderItem.id) + .group_by(SalesOrderItem.sales_order_id) + .subquery() + ) + completion_subquery = ( + select( + SalesOrderItem.sales_order_id.label("sales_order_id"), + func.count(func.distinct(CompletionReceipt.id)).label("completion_receipt_count"), + ) + .join(WorkOrder, WorkOrder.source_sales_order_item_id == SalesOrderItem.id) + .join(CompletionReceipt, CompletionReceipt.work_order_id == WorkOrder.id) + .group_by(SalesOrderItem.sales_order_id) + .subquery() + ) + delivery_subquery = ( + select( + Delivery.sales_order_id.label("sales_order_id"), + func.count(Delivery.id).label("delivery_count"), + ) + .group_by(Delivery.sales_order_id) + .subquery() + ) + return_subquery = ( + select( + ReturnOrder.sales_order_id.label("sales_order_id"), + func.count(ReturnOrder.id).label("return_count"), + ) + .where(ReturnOrder.sales_order_id.is_not(None)) + .group_by(ReturnOrder.sales_order_id) + .subquery() + ) + + return ( + select( + SalesOrder.id.label("order_id"), + SalesOrder.order_no.label("order_no"), + Customer.customer_code.label("customer_code"), + Customer.customer_name.label("customer_name"), + SalesOrder.order_date.label("order_date"), + SalesOrder.promised_date.label("promised_date"), + SalesOrder.delivery_address.label("delivery_address"), + SalesOrder.total_amount.label("total_amount"), + SalesOrder.status.label("status"), + func.coalesce(line_subquery.c.line_count, 0).label("line_count"), + func.coalesce(line_subquery.c.total_order_qty, 0).label("total_order_qty"), + func.coalesce(line_subquery.c.total_delivered_qty, 0).label("total_delivered_qty"), + func.coalesce(mrp_subquery.c.mrp_count, 0).label("mrp_count"), + func.coalesce(purchase_subquery.c.purchase_order_count, 0).label("purchase_order_count"), + func.coalesce(work_order_subquery.c.work_order_count, 0).label("work_order_count"), + func.coalesce(completion_subquery.c.completion_receipt_count, 0).label("completion_receipt_count"), + func.coalesce(delivery_subquery.c.delivery_count, 0).label("delivery_count"), + func.coalesce(return_subquery.c.return_count, 0).label("return_count"), + SalesOrder.updated_at.label("updated_at"), + ) + .join(Customer, Customer.id == SalesOrder.customer_id) + .outerjoin(line_subquery, line_subquery.c.sales_order_id == SalesOrder.id) + .outerjoin(mrp_subquery, mrp_subquery.c.sales_order_id == SalesOrder.id) + .outerjoin(purchase_subquery, purchase_subquery.c.sales_order_id == SalesOrder.id) + .outerjoin(work_order_subquery, work_order_subquery.c.sales_order_id == SalesOrder.id) + .outerjoin(completion_subquery, completion_subquery.c.sales_order_id == SalesOrder.id) + .outerjoin(delivery_subquery, delivery_subquery.c.sales_order_id == SalesOrder.id) + .outerjoin(return_subquery, return_subquery.c.sales_order_id == SalesOrder.id) + .order_by(SalesOrder.id.desc()) + .limit(limit) + ) + + +def _material_lot_rows(limit: int = 100): + issue_subquery = ( + select( + WorkOrderMaterialIssue.source_lot_id.label("source_lot_id"), + func.count(func.distinct(WorkOrderMaterialIssue.material_sub_batch_no)).label("issued_batch_count"), + func.coalesce(func.sum(WorkOrderMaterialIssue.issued_weight_kg), 0).label("issued_weight_kg"), + ) + .group_by(WorkOrderMaterialIssue.source_lot_id) + .subquery() + ) + finished_subquery = ( + select( + CompletionReceiptItem.source_material_lot_id.label("source_material_lot_id"), + func.count(func.distinct(CompletionReceiptItem.id)).label("finished_batch_count"), + func.coalesce(func.sum(CompletionReceiptItem.receipt_qty), 0).label("finished_qty"), + ) + .where(CompletionReceiptItem.source_material_lot_id.is_not(None)) + .group_by(CompletionReceiptItem.source_material_lot_id) + .subquery() + ) + finished_lot = aliased(StockLot) + delivery_subquery = ( + select( + finished_lot.source_material_lot_id.label("source_material_lot_id"), + func.count(func.distinct(Delivery.id)).label("delivery_count"), + ) + .join(DeliveryItem, DeliveryItem.lot_id == finished_lot.id) + .join(Delivery, Delivery.id == DeliveryItem.delivery_id) + .where(finished_lot.source_material_lot_id.is_not(None)) + .group_by(finished_lot.source_material_lot_id) + .subquery() + ) + return ( + select( + StockLot.id.label("lot_id"), + StockLot.lot_no.label("lot_no"), + StockLot.item_id.label("material_item_id"), + Item.item_code.label("material_code"), + Item.item_name.label("material_name"), + Item.specification.label("specification"), + Warehouse.warehouse_name.label("warehouse_name"), + StockLot.inbound_weight_kg.label("inbound_weight_kg"), + StockLot.remaining_weight_kg.label("remaining_weight_kg"), + StockLot.unit_cost.label("unit_cost"), + (StockLot.inbound_weight_kg * StockLot.unit_cost).label("inbound_amount"), + func.coalesce(issue_subquery.c.issued_batch_count, 0).label("issued_batch_count"), + func.coalesce(issue_subquery.c.issued_weight_kg, 0).label("issued_weight_kg"), + func.coalesce(finished_subquery.c.finished_batch_count, 0).label("finished_batch_count"), + func.coalesce(finished_subquery.c.finished_qty, 0).label("finished_qty"), + func.coalesce(delivery_subquery.c.delivery_count, 0).label("delivery_count"), + StockLot.status.label("status"), + StockLot.quality_status.label("quality_status"), + StockLot.created_at.label("created_at"), + ) + .join(Item, Item.id == StockLot.item_id) + .join(Warehouse, Warehouse.id == StockLot.warehouse_id) + .outerjoin(issue_subquery, issue_subquery.c.source_lot_id == StockLot.id) + .outerjoin(finished_subquery, finished_subquery.c.source_material_lot_id == StockLot.id) + .outerjoin(delivery_subquery, delivery_subquery.c.source_material_lot_id == StockLot.id) + .where(Item.item_type == "RAW_MATERIAL") + .order_by(StockLot.created_at.desc(), StockLot.id.desc()) + .limit(limit) + ) + + +@router.get("/overview", response_model=DashboardPayload) +def overview() -> DashboardPayload: + return get_dashboard_payload() + + +@router.get("/sales-orders", response_model=list[DashboardSalesOrderRead]) +def list_dashboard_sales_orders( + limit: int = Query(default=100, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[DashboardSalesOrderRead]: + rows = db.execute(_sales_order_rows(limit=limit)).mappings().all() + return [DashboardSalesOrderRead.model_validate(dict(row)) for row in rows] + + +@router.get("/material-lots", response_model=list[DashboardMaterialLotRead]) +def list_dashboard_material_lots( + limit: int = Query(default=100, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[DashboardMaterialLotRead]: + rows = db.execute(_material_lot_rows(limit=limit)).mappings().all() + return [DashboardMaterialLotRead.model_validate(dict(row)) for row in rows] + + +@router.get("/material-lots/{lot_id}/lifecycle", response_model=MaterialLotLifecycleRead) +def get_material_lot_lifecycle(lot_id: int, db: Session = Depends(get_db)) -> MaterialLotLifecycleRead: + lot_row = db.execute(_material_lot_rows(limit=1).where(StockLot.id == lot_id)).mappings().first() + if not lot_row: + raise HTTPException(status_code=404, detail="原材料批次不存在") + + lot = DashboardMaterialLotRead.model_validate(dict(lot_row)) + nodes: list[LifecycleNode] = [] + edges: list[LifecycleEdge] = [] + node_ids: set[str] = set() + edge_ids: set[str] = set() + + def add_node(node: LifecycleNode) -> None: + if node.id not in node_ids: + node_ids.add(node.id) + nodes.append(node) + + def add_edge(edge: LifecycleEdge) -> None: + if edge.source in node_ids and edge.target in node_ids and edge.id not in edge_ids: + edge_ids.add(edge.id) + edges.append(edge) + + lot_node_id = f"raw-lot-{lot.lot_id}" + add_node( + _node( + node_id=lot_node_id, + node_type="raw_material_lot", + title=lot.lot_no, + subtitle=f"{lot.material_code} · {lot.material_name}", + status=lot.status, + tone=_tone_for_status(lot.status), + stage="原料入库", + level=0, + summary="原材料驱动的起点。该批次记录入库成本、剩余库存、质检状态,并向后追踪所有生产出库、成品入库和发货。", + metrics=[ + ("入库重量", _fmt_weight(lot.inbound_weight_kg)), + ("剩余重量", _fmt_weight(lot.remaining_weight_kg)), + ("入库金额", _fmt_money(lot.inbound_amount)), + ], + details=[ + ("原材料编码", lot.material_code), + ("原材料名称", lot.material_name), + ("规格", lot.specification), + ("仓库", lot.warehouse_name), + ("入库单价", f"{_fmt_money(lot.unit_cost)}/kg"), + ("质检状态", _status_label(lot.quality_status)), + ("生产出库数", lot.issued_batch_count), + ("已领用重量", _fmt_weight(lot.issued_weight_kg)), + ("成品批次数", lot.finished_batch_count), + ("已发货次数", lot.delivery_count), + ], + raw=lot.model_dump(mode="json"), + ) + ) + + issues = db.execute( + select( + WorkOrderMaterialIssue.id.label("issue_id"), + WorkOrderMaterialIssue.material_sub_batch_no.label("material_sub_batch_no"), + StockLot.lot_no.label("source_lot_no"), + WorkOrderMaterialIssue.work_order_id.label("work_order_id"), + WorkOrder.work_order_no.label("work_order_no"), + WorkOrder.product_item_id.label("product_item_id"), + Item.item_code.label("product_code"), + Item.item_name.label("product_name"), + WorkOrder.planned_qty.label("planned_qty"), + WorkOrder.finished_qty.label("finished_qty"), + WorkOrder.status.label("work_order_status"), + WorkOrderMaterialIssue.issued_qty.label("issued_qty"), + WorkOrderMaterialIssue.issued_weight_kg.label("issued_weight_kg"), + WorkOrderMaterialIssue.unit_cost.label("unit_cost"), + WorkOrderMaterialIssue.amount.label("amount"), + WorkOrderMaterialIssue.issue_time.label("issue_time"), + WorkOrderMaterialIssue.remark.label("remark"), + ) + .join(WorkOrder, WorkOrder.id == WorkOrderMaterialIssue.work_order_id) + .join(Item, Item.id == WorkOrder.product_item_id) + .outerjoin(StockLot, StockLot.id == WorkOrderMaterialIssue.source_lot_id) + .where(WorkOrderMaterialIssue.source_lot_id == lot_id) + .order_by(WorkOrderMaterialIssue.issue_time, WorkOrderMaterialIssue.id) + ).mappings().all() + work_order_ids = sorted({row["work_order_id"] for row in issues}) + + for row in issues: + source_lot_no = row["source_lot_no"] or row["material_sub_batch_no"] or "未记录库存批次号" + issue_node_id = f"issue-{row['issue_id']}" + add_node( + _node( + node_id=issue_node_id, + node_type="material_issue_batch", + title=source_lot_no, + subtitle=row["work_order_no"], + status="POSTED", + tone="success", + stage="生产出库", + level=1, + summary="原材料按库存批次生产出库,记录领用重量、单价和成本。", + metrics=[ + ("领用重量", _fmt_weight(row["issued_weight_kg"])), + ("领用金额", _fmt_money(row["amount"])), + ("单价", f"{_fmt_money(row['unit_cost'])}/kg"), + ], + details=[ + ("库存批次号", source_lot_no), + ("工单号", row["work_order_no"]), + ("产品", f"{row['product_code']} · {row['product_name']}"), + ("领用时间", _fmt_datetime(row["issue_time"])), + ("领用重量", _fmt_weight(row["issued_weight_kg"])), + ("备注", row["remark"]), + ], + raw=dict(row), + ) + ) + add_edge(_edge(lot_node_id, issue_node_id, "生产出库")) + + work_order_node_id = f"wo-{row['work_order_id']}" + add_node( + _node( + node_id=work_order_node_id, + node_type="work_order", + title=row["work_order_no"], + subtitle=f"{row['product_code']} · {row['product_name']}", + status=row["work_order_status"], + tone=_tone_for_status(row["work_order_status"]), + stage="生产工单", + level=2, + summary="原材料按库存批次生产出库进入生产工单,后续工序报工、报废返工和完工入库均挂在该工单下。", + metrics=[ + ("计划数", _fmt_qty(row["planned_qty"])), + ("完工数", _fmt_qty(row["finished_qty"])), + ("本批领用", _fmt_weight(row["issued_weight_kg"])), + ], + details=[ + ("工单号", row["work_order_no"]), + ("产品编码", row["product_code"]), + ("产品名称", row["product_name"]), + ("状态", _status_label(row["work_order_status"])), + ], + raw=dict(row), + ) + ) + add_edge(_edge(issue_node_id, work_order_node_id, "投入生产")) + + operations = db.execute( + select( + WorkOrderOperation.id.label("operation_id"), + WorkOrderOperation.work_order_id.label("work_order_id"), + WorkOrderOperation.seq_no.label("seq_no"), + WorkOrderOperation.operation_name.label("operation_name"), + WorkOrderOperation.planned_qty.label("planned_qty"), + WorkOrderOperation.report_qty.label("report_qty"), + WorkOrderOperation.good_qty.label("good_qty"), + WorkOrderOperation.scrap_qty.label("scrap_qty"), + WorkOrderOperation.rework_qty.label("rework_qty"), + WorkOrderOperation.is_abnormal.label("is_abnormal"), + WorkOrderOperation.status.label("status"), + ) + .where(WorkOrderOperation.work_order_id.in_(work_order_ids) if work_order_ids else false()) + .order_by(WorkOrderOperation.work_order_id, WorkOrderOperation.seq_no) + ).mappings().all() + + for row in operations: + node_id = f"op-{row['operation_id']}" + add_node( + _node( + node_id=node_id, + node_type="work_order_operation", + title=f"OP{row['seq_no']:02d} {row['operation_name']}", + subtitle="异常工序" if row["is_abnormal"] else "工序执行", + status=row["status"], + tone="danger" if row["is_abnormal"] else _tone_for_status(row["status"]), + stage="工序执行", + level=3, + summary="工序节点展示该库存批次对应工单的加工进度、报废返工和异常情况。", + metrics=[ + ("计划数", _fmt_qty(row["planned_qty"])), + ("已报工", _fmt_qty(row["report_qty"])), + ("报废数", _fmt_qty(row["scrap_qty"])), + ], + details=[ + ("合格数", _fmt_qty(row["good_qty"])), + ("返工数", _fmt_qty(row["rework_qty"])), + ("是否异常", "是" if row["is_abnormal"] else "否"), + ("状态", _status_label(row["status"])), + ], + raw=dict(row), + ) + ) + add_edge(_edge(f"wo-{row['work_order_id']}", node_id, "工艺路线")) + + reports = db.execute( + select( + OperationReport.id.label("report_id"), + OperationReport.report_no.label("report_no"), + OperationReport.work_order_id.label("work_order_id"), + OperationReport.work_order_operation_id.label("work_order_operation_id"), + Employee.employee_name.label("employee_name"), + OperationReport.report_qty.label("report_qty"), + OperationReport.good_qty.label("good_qty"), + OperationReport.scrap_qty.label("scrap_qty"), + OperationReport.rework_qty.label("rework_qty"), + OperationReport.is_abnormal.label("is_abnormal"), + OperationReport.scrap_reason.label("scrap_reason"), + OperationReport.start_time.label("start_time"), + OperationReport.end_time.label("end_time"), + ) + .join(Employee, Employee.id == OperationReport.employee_id) + .where(OperationReport.work_order_id.in_(work_order_ids) if work_order_ids else false()) + .order_by(OperationReport.start_time, OperationReport.id) + ).mappings().all() + + for row in reports: + node_id = f"report-{row['report_id']}" + add_node( + _node( + node_id=node_id, + node_type="operation_report", + title=row["report_no"], + subtitle=row["employee_name"], + status="ABNORMAL" if row["is_abnormal"] else "NORMAL", + tone="danger" if row["is_abnormal"] else "success", + stage="工人报工", + level=4, + summary="现场报工决定真实产出、报废、返工和人工/设备成本,是材料批次成本落到产品上的关键依据。", + metrics=[ + ("报工数", _fmt_qty(row["report_qty"])), + ("合格数", _fmt_qty(row["good_qty"])), + ("报废数", _fmt_qty(row["scrap_qty"])), + ], + details=[ + ("员工", row["employee_name"]), + ("开始", _fmt_datetime(row["start_time"])), + ("结束", _fmt_datetime(row["end_time"])), + ("返工数", _fmt_qty(row["rework_qty"])), + ("异常原因", row["scrap_reason"] or ("报工异常" if row["is_abnormal"] else "-")), + ], + raw=dict(row), + ) + ) + add_edge(_edge(f"op-{row['work_order_operation_id']}", node_id, "扫码报工")) + + source_material_lot = aliased(StockLot) + completion_items = db.execute( + select( + CompletionReceiptItem.id.label("completion_item_id"), + CompletionReceipt.work_order_id.label("work_order_id"), + CompletionReceipt.receipt_no.label("receipt_no"), + CompletionReceipt.receipt_time.label("receipt_time"), + CompletionReceiptItem.lot_no.label("lot_no"), + CompletionReceiptItem.receipt_qty.label("receipt_qty"), + CompletionReceiptItem.receipt_weight_kg.label("receipt_weight_kg"), + CompletionReceiptItem.unit_cost.label("unit_cost"), + CompletionReceiptItem.source_material_sub_batch_no.label("source_material_sub_batch_no"), + func.coalesce( + source_material_lot.lot_no, + CompletionReceiptItem.source_material_sub_batch_no, + ).label("source_material_lot_no"), + CompletionReceiptItem.source_material_summary.label("source_material_summary"), + Warehouse.warehouse_name.label("warehouse_name"), + ) + .join(CompletionReceipt, CompletionReceipt.id == CompletionReceiptItem.completion_receipt_id) + .join(Warehouse, Warehouse.id == CompletionReceipt.warehouse_id) + .outerjoin(source_material_lot, source_material_lot.id == CompletionReceiptItem.source_material_lot_id) + .where(CompletionReceipt.work_order_id.in_(work_order_ids) if work_order_ids else false()) + .order_by(CompletionReceipt.receipt_time, CompletionReceiptItem.id) + ).mappings().all() + completion_item_ids = [row["completion_item_id"] for row in completion_items] + + for row in completion_items: + node_id = f"fg-{row['completion_item_id']}" + add_node( + _node( + node_id=node_id, + node_type="completion_receipt", + title=row["receipt_no"], + subtitle=row["lot_no"], + status="POSTED", + tone="success", + stage="成品入库", + level=5, + summary="成品入库批次已记录来源库存批次号,后续发货可以从成品批次倒查到原材料入库成本。", + metrics=[ + ("入库数量", _fmt_qty(row["receipt_qty"])), + ("入库重量", _fmt_weight(row["receipt_weight_kg"])), + ("单位成本", _fmt_money(row["unit_cost"])), + ], + details=[ + ("成品批次", row["lot_no"]), + ("仓库", row["warehouse_name"]), + ("入库时间", _fmt_datetime(row["receipt_time"])), + ("来源库存批次号", row["source_material_lot_no"]), + ("来源摘要", row["source_material_summary"]), + ], + raw=dict(row), + ) + ) + add_edge(_edge(f"wo-{row['work_order_id']}", node_id, "完工入库")) + + finished_lot = aliased(StockLot) + order_customer = aliased(Customer) + direct_customer = aliased(Customer) + deliveries = db.execute( + select( + DeliveryItem.id.label("delivery_item_id"), + finished_lot.source_line_id.label("completion_item_id"), + Delivery.delivery_no.label("delivery_no"), + SalesOrder.order_no.label("order_no"), + func.coalesce(direct_customer.customer_name, order_customer.customer_name).label("customer_name"), + Delivery.delivery_date.label("delivery_date"), + DeliveryItem.delivery_qty.label("delivery_qty"), + DeliveryItem.delivery_weight_kg.label("delivery_weight_kg"), + DeliveryItem.line_amount.label("line_amount"), + DeliveryItem.status.label("status"), + finished_lot.lot_no.label("lot_no"), + ) + .join(finished_lot, finished_lot.id == DeliveryItem.lot_id) + .join(Delivery, Delivery.id == DeliveryItem.delivery_id) + .outerjoin(SalesOrder, SalesOrder.id == Delivery.sales_order_id) + .outerjoin(order_customer, order_customer.id == SalesOrder.customer_id) + .outerjoin(direct_customer, direct_customer.id == Delivery.customer_id) + .where(finished_lot.source_line_id.in_(completion_item_ids) if completion_item_ids else false()) + .order_by(Delivery.delivery_date, DeliveryItem.id) + ).mappings().all() + + for row in deliveries: + node_id = f"delivery-{row['delivery_item_id']}" + add_node( + _node( + node_id=node_id, + node_type="delivery", + title=row["delivery_no"], + subtitle=row["order_no"], + status=row["status"], + tone=_tone_for_status(row["status"]), + stage="发货", + level=6, + summary="成品库存发给客户。即使订单晚于生产发生,也能从发货批次倒查到原材料入库批次和成本。", + metrics=[ + ("发货数量", _fmt_qty(row["delivery_qty"])), + ("发货重量", _fmt_weight(row["delivery_weight_kg"])), + ("发货金额", _fmt_money(row["line_amount"])), + ], + details=[ + ("订单", row["order_no"]), + ("客户", row["customer_name"]), + ("库存批次号", row["lot_no"]), + ("发货时间", _fmt_datetime(row["delivery_date"])), + ("状态", _status_label(row["status"])), + ], + raw=dict(row), + ) + ) + add_edge(_edge(f"fg-{row['completion_item_id']}", node_id, "库存发货")) + + return MaterialLotLifecycleRead(lot=lot, nodes=nodes, edges=edges) + + +@router.get("/sales-orders/{sales_order_id}/lifecycle", response_model=SalesOrderLifecycleRead) +def get_sales_order_lifecycle( + sales_order_id: int, + db: Session = Depends(get_db), +) -> SalesOrderLifecycleRead: + order_row = db.execute(_sales_order_rows(limit=1).where(SalesOrder.id == sales_order_id)).mappings().first() + if not order_row: + raise HTTPException(status_code=404, detail="订单不存在") + + order = DashboardSalesOrderRead.model_validate(dict(order_row)) + nodes: list[LifecycleNode] = [] + edges: list[LifecycleEdge] = [] + node_ids: set[str] = set() + edge_ids: set[str] = set() + + def add_node(node: LifecycleNode) -> None: + if node.id not in node_ids: + node_ids.add(node.id) + nodes.append(node) + + def add_edge(edge: LifecycleEdge) -> None: + if edge.source in node_ids and edge.target in node_ids and edge.id not in edge_ids: + edge_ids.add(edge.id) + edges.append(edge) + + order_node_id = f"order-{order.order_id}" + add_node( + _node( + node_id=order_node_id, + node_type="sales_order", + title=order.order_no, + subtitle=order.customer_name, + status=order.status, + tone=_tone_for_status(order.status), + stage="订单", + level=0, + summary="订单生命周期起点,后续 MRP、采购、生产、入库、发货和售后均围绕此单展开。", + metrics=[ + ("订单金额", _fmt_money(order.total_amount)), + ("产品行数", order.line_count), + ("订单数量", _fmt_qty(order.total_order_qty)), + ("已发数量", _fmt_qty(order.total_delivered_qty)), + ], + details=[ + ("订单号", order.order_no), + ("客户编码", order.customer_code), + ("客户名称", order.customer_name), + ("订单日期", _fmt_date(order.order_date)), + ("承诺交期", _fmt_date(order.promised_date)), + ("送货地址", order.delivery_address), + ("MRP需求数", order.mrp_count), + ("采购单数", order.purchase_order_count), + ("生产工单数", order.work_order_count), + ("成品入库单数", order.completion_receipt_count), + ("发货单数", order.delivery_count), + ("退货单数", order.return_count), + ("最近更新", _fmt_datetime(order.updated_at)), + ], + raw=order.model_dump(mode="json"), + ) + ) + + order_items = db.execute( + select( + SalesOrderItem.id.label("sales_order_item_id"), + SalesOrderItem.line_no.label("line_no"), + SalesOrderItem.product_item_id.label("product_item_id"), + Item.item_code.label("product_code"), + Item.item_name.label("product_name"), + Item.specification.label("specification"), + Item.unit_weight_kg.label("unit_weight_kg"), + SalesOrderItem.customer_part_no.label("customer_part_no"), + SalesOrderItem.order_qty.label("order_qty"), + SalesOrderItem.delivered_qty.label("delivered_qty"), + SalesOrderItem.unit_price.label("unit_price"), + SalesOrderItem.line_amount.label("line_amount"), + SalesOrderItem.promised_date.label("promised_date"), + SalesOrderItem.status.label("status"), + ) + .join(Item, Item.id == SalesOrderItem.product_item_id) + .where(SalesOrderItem.sales_order_id == sales_order_id) + .order_by(SalesOrderItem.line_no) + ).mappings().all() + + item_ids = [row["sales_order_item_id"] for row in order_items] + product_ids = [row["product_item_id"] for row in order_items] + + demands = db.execute( + select( + MaterialDemand.id.label("demand_id"), + MaterialDemand.demand_no.label("demand_no"), + MaterialDemand.sales_order_item_id.label("sales_order_item_id"), + MaterialDemand.material_item_id.label("material_item_id"), + Item.item_code.label("material_code"), + Item.item_name.label("material_name"), + Item.specification.label("specification"), + MaterialDemand.order_qty.label("order_qty"), + MaterialDemand.theoretical_weight_kg.label("theoretical_weight_kg"), + MaterialDemand.planned_weight_kg.label("planned_weight_kg"), + MaterialDemand.shortage_weight_kg.label("shortage_weight_kg"), + MaterialDemand.suggested_purchase_weight_kg.label("suggested_purchase_weight_kg"), + MaterialDemand.status.label("status"), + MaterialDemand.generated_at.label("generated_at"), + MaterialDemand.remark.label("remark"), + ) + .join(Item, Item.id == MaterialDemand.material_item_id) + .where(MaterialDemand.sales_order_item_id.in_(item_ids) if item_ids else false()) + .where(MaterialDemand.status != "SUPERSEDED") + .order_by(MaterialDemand.id) + ).mappings().all() + demand_ids = [row["demand_id"] for row in demands] + + po_items = db.execute( + select( + PurchaseOrderItem.id.label("purchase_order_item_id"), + PurchaseOrderItem.purchase_order_id.label("purchase_order_id"), + PurchaseOrderItem.source_demand_id.label("source_demand_id"), + PurchaseOrderItem.line_no.label("line_no"), + PurchaseOrderItem.order_weight_kg.label("order_weight_kg"), + PurchaseOrderItem.received_weight_kg.label("received_weight_kg"), + PurchaseOrderItem.unit_price.label("unit_price"), + PurchaseOrderItem.line_amount.label("line_amount"), + PurchaseOrderItem.status.label("status"), + PurchaseOrder.po_no.label("po_no"), + PurchaseOrder.order_date.label("order_date"), + PurchaseOrder.expected_date.label("expected_date"), + PurchaseOrder.total_amount.label("total_amount"), + Supplier.supplier_name.label("supplier_name"), + ) + .join(PurchaseOrder, PurchaseOrder.id == PurchaseOrderItem.purchase_order_id) + .join(Supplier, Supplier.id == PurchaseOrder.supplier_id) + .where(PurchaseOrderItem.source_demand_id.in_(demand_ids) if demand_ids else false()) + .order_by(PurchaseOrder.id, PurchaseOrderItem.line_no) + ).mappings().all() + po_item_ids = [row["purchase_order_item_id"] for row in po_items] + + receipt_items = db.execute( + select( + PurchaseReceiptItem.id.label("receipt_item_id"), + PurchaseReceiptItem.purchase_order_item_id.label("purchase_order_item_id"), + PurchaseReceiptItem.line_no.label("line_no"), + PurchaseReceiptItem.lot_no.label("lot_no"), + PurchaseReceiptItem.received_weight_kg.label("received_weight_kg"), + PurchaseReceiptItem.accepted_weight_kg.label("accepted_weight_kg"), + PurchaseReceiptItem.unit_cost.label("unit_cost"), + PurchaseReceiptItem.status.label("status"), + PurchaseReceipt.receipt_no.label("receipt_no"), + PurchaseReceipt.receipt_date.label("receipt_date"), + Warehouse.warehouse_name.label("warehouse_name"), + ) + .join(PurchaseReceipt, PurchaseReceipt.id == PurchaseReceiptItem.receipt_id) + .join(Warehouse, Warehouse.id == PurchaseReceipt.warehouse_id) + .where(PurchaseReceiptItem.purchase_order_item_id.in_(po_item_ids) if po_item_ids else false()) + .order_by(PurchaseReceipt.id, PurchaseReceiptItem.line_no) + ).mappings().all() + + work_orders = db.execute( + select( + WorkOrder.id.label("work_order_id"), + WorkOrder.work_order_no.label("work_order_no"), + WorkOrder.source_sales_order_item_id.label("source_sales_order_item_id"), + WorkOrder.product_item_id.label("product_item_id"), + Item.item_code.label("product_code"), + Item.item_name.label("product_name"), + WorkOrder.work_order_type.label("work_order_type"), + WorkOrder.planned_qty.label("planned_qty"), + WorkOrder.finished_qty.label("finished_qty"), + WorkOrder.scrap_qty.label("scrap_qty"), + WorkOrder.planned_start_time.label("planned_start_time"), + WorkOrder.planned_end_time.label("planned_end_time"), + WorkOrder.actual_start_time.label("actual_start_time"), + WorkOrder.actual_end_time.label("actual_end_time"), + WorkOrder.priority_level.label("priority_level"), + WorkOrder.status.label("status"), + WorkOrder.remark.label("remark"), + ) + .join(Item, Item.id == WorkOrder.product_item_id) + .where(WorkOrder.source_sales_order_item_id.in_(item_ids) if item_ids else false()) + .order_by(WorkOrder.id) + ).mappings().all() + work_order_ids = [row["work_order_id"] for row in work_orders] + + operations = db.execute( + select( + WorkOrderOperation.id.label("work_order_operation_id"), + WorkOrderOperation.work_order_id.label("work_order_id"), + WorkOrderOperation.seq_no.label("seq_no"), + WorkOrderOperation.operation_name.label("operation_name"), + WorkOrderOperation.planned_qty.label("planned_qty"), + WorkOrderOperation.report_qty.label("report_qty"), + WorkOrderOperation.good_qty.label("good_qty"), + WorkOrderOperation.scrap_qty.label("scrap_qty"), + WorkOrderOperation.rework_qty.label("rework_qty"), + WorkOrderOperation.allowed_scrap_rate.label("allowed_scrap_rate"), + WorkOrderOperation.is_abnormal.label("is_abnormal"), + WorkOrderOperation.status.label("status"), + WorkOrderOperation.remark.label("remark"), + ) + .where(WorkOrderOperation.work_order_id.in_(work_order_ids) if work_order_ids else false()) + .order_by(WorkOrderOperation.work_order_id, WorkOrderOperation.seq_no) + ).mappings().all() + operation_ids = [row["work_order_operation_id"] for row in operations] + + reports = db.execute( + select( + OperationReport.id.label("report_id"), + OperationReport.report_no.label("report_no"), + OperationReport.work_order_id.label("work_order_id"), + OperationReport.work_order_operation_id.label("work_order_operation_id"), + Employee.employee_name.label("employee_name"), + OperationReport.start_time.label("start_time"), + OperationReport.end_time.label("end_time"), + OperationReport.report_qty.label("report_qty"), + OperationReport.good_qty.label("good_qty"), + OperationReport.scrap_qty.label("scrap_qty"), + OperationReport.rework_qty.label("rework_qty"), + OperationReport.scrap_reason.label("scrap_reason"), + OperationReport.downtime_minutes.label("downtime_minutes"), + OperationReport.extra_cost_amount.label("extra_cost_amount"), + OperationReport.is_abnormal.label("is_abnormal"), + ) + .join(Employee, Employee.id == OperationReport.employee_id) + .where(OperationReport.work_order_operation_id.in_(operation_ids) if operation_ids else false()) + .order_by(OperationReport.id) + ).mappings().all() + report_ids = [row["report_id"] for row in reports] + + scrap_records = db.execute( + select( + ScrapRecord.id.label("scrap_record_id"), + ScrapRecord.scrap_no.label("scrap_no"), + ScrapRecord.report_id.label("report_id"), + ScrapRecord.work_order_id.label("work_order_id"), + ScrapRecord.work_order_operation_id.label("work_order_operation_id"), + ScrapRecord.scrap_qty.label("scrap_qty"), + ScrapRecord.scrap_weight_kg.label("scrap_weight_kg"), + ScrapRecord.disposal_type.label("disposal_type"), + ScrapRecord.saleable_scrap_weight_kg.label("saleable_scrap_weight_kg"), + ScrapRecord.scrap_cost_amount.label("scrap_cost_amount"), + ScrapRecord.scrap_reason.label("scrap_reason"), + ScrapRecord.status.label("status"), + ) + .where(ScrapRecord.report_id.in_(report_ids) if report_ids else false()) + .order_by(ScrapRecord.id) + ).mappings().all() + + source_material_lot = aliased(StockLot) + completion_items = db.execute( + select( + CompletionReceiptItem.id.label("completion_item_id"), + CompletionReceiptItem.completion_receipt_id.label("completion_receipt_id"), + CompletionReceipt.work_order_id.label("work_order_id"), + CompletionReceipt.receipt_no.label("receipt_no"), + CompletionReceipt.receipt_time.label("receipt_time"), + CompletionReceiptItem.lot_no.label("lot_no"), + CompletionReceiptItem.receipt_qty.label("receipt_qty"), + CompletionReceiptItem.receipt_weight_kg.label("receipt_weight_kg"), + CompletionReceiptItem.unit_cost.label("unit_cost"), + CompletionReceiptItem.source_material_lot_id.label("source_material_lot_id"), + source_material_lot.lot_no.label("source_material_lot_no"), + CompletionReceiptItem.source_material_sub_batch_no.label("source_material_sub_batch_no"), + CompletionReceiptItem.status.label("status"), + Warehouse.warehouse_name.label("warehouse_name"), + ) + .join(CompletionReceipt, CompletionReceipt.id == CompletionReceiptItem.completion_receipt_id) + .join(Warehouse, Warehouse.id == CompletionReceipt.warehouse_id) + .outerjoin(source_material_lot, source_material_lot.id == CompletionReceiptItem.source_material_lot_id) + .where(CompletionReceipt.work_order_id.in_(work_order_ids) if work_order_ids else false()) + .order_by(CompletionReceipt.id, CompletionReceiptItem.line_no) + ).mappings().all() + + deliveries = db.execute( + select( + DeliveryItem.id.label("delivery_item_id"), + DeliveryItem.delivery_id.label("delivery_id"), + Delivery.delivery_no.label("delivery_no"), + DeliveryItem.sales_order_item_id.label("sales_order_item_id"), + DeliveryItem.product_item_id.label("product_item_id"), + DeliveryItem.lot_id.label("lot_id"), + StockLot.lot_no.label("lot_no"), + DeliveryItem.delivery_qty.label("delivery_qty"), + DeliveryItem.delivery_weight_kg.label("delivery_weight_kg"), + DeliveryItem.unit_price.label("unit_price"), + DeliveryItem.line_amount.label("line_amount"), + DeliveryItem.status.label("status"), + Delivery.delivery_date.label("delivery_date"), + Delivery.consignee_name.label("consignee_name"), + Delivery.consignee_phone.label("consignee_phone"), + Delivery.delivery_address.label("delivery_address"), + ) + .join(Delivery, Delivery.id == DeliveryItem.delivery_id) + .outerjoin(StockLot, StockLot.id == DeliveryItem.lot_id) + .where(Delivery.sales_order_id == sales_order_id) + .order_by(Delivery.id, DeliveryItem.line_no) + ).mappings().all() + + return_items = db.execute( + select( + ReturnItem.id.label("return_item_id"), + ReturnItem.return_order_id.label("return_order_id"), + ReturnOrder.return_no.label("return_no"), + ReturnOrder.return_date.label("return_date"), + ReturnOrder.return_reason.label("return_reason"), + ReturnOrder.status.label("return_status"), + ReturnItem.sales_order_item_id.label("sales_order_item_id"), + ReturnItem.product_item_id.label("product_item_id"), + ReturnItem.return_qty.label("return_qty"), + ReturnItem.return_weight_kg.label("return_weight_kg"), + ReturnItem.disposition_status.label("disposition_status"), + ReturnItem.responsibility_type.label("responsibility_type"), + ReturnItem.remark.label("remark"), + ) + .join(ReturnOrder, ReturnOrder.id == ReturnItem.return_order_id) + .where( + (ReturnOrder.sales_order_id == sales_order_id) + | (ReturnItem.sales_order_item_id.in_(item_ids) if item_ids else false()) + ) + .order_by(ReturnOrder.id, ReturnItem.line_no) + ).mappings().all() + return_item_ids = [row["return_item_id"] for row in return_items] + + rework_wo = aliased(WorkOrder) + return_dispositions = db.execute( + select( + ReturnDisposition.id.label("return_disposition_id"), + ReturnDisposition.disposition_no.label("disposition_no"), + ReturnDisposition.return_item_id.label("return_item_id"), + ReturnDisposition.disposition_type.label("disposition_type"), + ReturnDisposition.scrap_weight_kg.label("scrap_weight_kg"), + ReturnDisposition.extra_cost_amount.label("extra_cost_amount"), + ReturnDisposition.scrap_sale_amount.label("scrap_sale_amount"), + ReturnDisposition.closed_at.label("closed_at"), + ReturnDisposition.status.label("status"), + ReturnDisposition.remark.label("remark"), + rework_wo.work_order_no.label("rework_work_order_no"), + ) + .outerjoin(rework_wo, rework_wo.id == ReturnDisposition.rework_work_order_id) + .where(ReturnDisposition.return_item_id.in_(return_item_ids) if return_item_ids else false()) + .order_by(ReturnDisposition.id) + ).mappings().all() + + # Build nodes and edges from left to right. + for row in order_items: + node_id = f"item-{row['sales_order_item_id']}" + add_node( + _node( + node_id=node_id, + node_type="sales_order_item", + title=f"L{row['line_no']:02d} {row['product_name']}", + subtitle=row["product_code"], + status=row["status"], + tone=_tone_for_status(row["status"]), + stage="产品明细", + level=1, + summary="订单中的一个产品行,后续 MRP、工单、发货和退货都可追溯到该行。", + metrics=[ + ("订单数", _fmt_qty(row["order_qty"])), + ("已发数", _fmt_qty(row["delivered_qty"])), + ("行金额", _fmt_money(row["line_amount"])), + ], + details=[ + ("产品编码", row["product_code"]), + ("产品名称", row["product_name"]), + ("规格", row["specification"]), + ("单重", _fmt_weight(row["unit_weight_kg"])), + ("客户料号", row["customer_part_no"]), + ("承诺交期", _fmt_date(row["promised_date"])), + ("订单单价", _fmt_money(row["unit_price"])), + ("状态", _status_label(row["status"])), + ], + raw=dict(row), + ) + ) + add_edge(_edge(order_node_id, node_id, "订单产品")) + + for row in demands: + node_id = f"mrp-{row['demand_id']}" + source_id = f"item-{row['sales_order_item_id']}" + add_node( + _node( + node_id=node_id, + node_type="mrp_demand", + title=row["demand_no"], + subtitle=f"{row['material_code']} · {row['material_name']}", + status=row["status"], + tone=_tone_for_status(row["status"]), + stage="MRP需求", + level=2, + summary="按产品 BOM、订单数量、损耗率和库存余额生成的原材料需求。", + metrics=[ + ("计划重量", _fmt_weight(row["planned_weight_kg"])), + ("缺口重量", _fmt_weight(row["shortage_weight_kg"])), + ("建议采购", _fmt_weight(row["suggested_purchase_weight_kg"])), + ], + details=[ + ("需求单号", row["demand_no"]), + ("原材料编码", row["material_code"]), + ("原材料名称", row["material_name"]), + ("规格", row["specification"]), + ("理论重量", _fmt_weight(row["theoretical_weight_kg"])), + ("订单数量", _fmt_qty(row["order_qty"])), + ("生成时间", _fmt_datetime(row["generated_at"])), + ("备注", row["remark"]), + ], + raw=dict(row), + ) + ) + add_edge(_edge(source_id, node_id, "BOM展开")) + + for row in po_items: + node_id = f"po-item-{row['purchase_order_item_id']}" + source_id = f"mrp-{row['source_demand_id']}" + add_node( + _node( + node_id=node_id, + node_type="purchase_order_item", + title=f"{row['po_no']} L{row['line_no']:02d}", + subtitle=row["supplier_name"], + status=row["status"], + tone=_tone_for_status(row["status"]), + stage="采购订单", + level=3, + summary="MRP需求转为采购执行,记录供应商、采购重量、到货进度和采购金额。", + metrics=[ + ("采购重量", _fmt_weight(row["order_weight_kg"])), + ("已收重量", _fmt_weight(row["received_weight_kg"])), + ("行金额", _fmt_money(row["line_amount"])), + ], + details=[ + ("采购单号", row["po_no"]), + ("供应商", row["supplier_name"]), + ("采购日期", _fmt_date(row["order_date"])), + ("预计到货", _fmt_date(row["expected_date"])), + ("采购单价", f"{_fmt_money(row['unit_price'])}/kg"), + ("采购总额", _fmt_money(row["total_amount"])), + ("状态", _status_label(row["status"])), + ], + raw=dict(row), + ) + ) + add_edge(_edge(source_id, node_id, "采购下单")) + + for row in receipt_items: + node_id = f"receipt-item-{row['receipt_item_id']}" + source_id = f"po-item-{row['purchase_order_item_id']}" + add_node( + _node( + node_id=node_id, + node_type="purchase_receipt_item", + title=f"{row['receipt_no']} L{row['line_no']:02d}", + subtitle=row["lot_no"], + status=row["status"], + tone=_tone_for_status(row["status"]), + stage="到货质检", + level=4, + summary="采购到货后进入仓库待检/放行,批次号成为后续库存追溯的关键。", + metrics=[ + ("收货重量", _fmt_weight(row["received_weight_kg"])), + ("合格重量", _fmt_weight(row["accepted_weight_kg"])), + ("入库单价", f"{_fmt_money(row['unit_cost'])}/kg"), + ], + details=[ + ("入库单号", row["receipt_no"]), + ("批次号", row["lot_no"]), + ("仓库", row["warehouse_name"]), + ("收货时间", _fmt_datetime(row["receipt_date"])), + ("质检/入库状态", _status_label(row["status"])), + ], + raw=dict(row), + ) + ) + add_edge(_edge(source_id, node_id, "到货入库")) + + for row in work_orders: + node_id = f"wo-{row['work_order_id']}" + source_id = f"item-{row['source_sales_order_item_id']}" + add_node( + _node( + node_id=node_id, + node_type="work_order", + title=row["work_order_no"], + subtitle=row["product_name"], + status=row["status"], + tone=_tone_for_status(row["status"]), + stage="生产工单", + level=5, + summary="订单产品进入车间执行,工单承接 BOM 用料、工艺路线、报工和完工入库。", + metrics=[ + ("计划数", _fmt_qty(row["planned_qty"])), + ("完工数", _fmt_qty(row["finished_qty"])), + ("报废数", _fmt_qty(row["scrap_qty"])), + ], + details=[ + ("工单号", row["work_order_no"]), + ("工单类型", _label(WORK_ORDER_TYPE_LABELS, row["work_order_type"])), + ("产品编码", row["product_code"]), + ("产品名称", row["product_name"]), + ("计划开始", _fmt_datetime(row["planned_start_time"])), + ("计划完工", _fmt_datetime(row["planned_end_time"])), + ("实际开始", _fmt_datetime(row["actual_start_time"])), + ("实际完工", _fmt_datetime(row["actual_end_time"])), + ("优先级", row["priority_level"]), + ("备注", row["remark"]), + ], + raw=dict(row), + ) + ) + add_edge(_edge(source_id, node_id, "工单下达")) + + operation_by_work_order: dict[int, list[dict[str, Any]]] = {} + for row in operations: + operation_by_work_order.setdefault(row["work_order_id"], []).append(dict(row)) + node_id = f"op-{row['work_order_operation_id']}" + source_id = f"wo-{row['work_order_id']}" + add_node( + _node( + node_id=node_id, + node_type="work_order_operation", + title=f"OP{row['seq_no']:02d} {row['operation_name']}", + subtitle="关键异常" if row["is_abnormal"] else "工序执行", + status=row["status"], + tone="danger" if row["is_abnormal"] else _tone_for_status(row["status"]), + stage="工序执行", + level=6, + summary="工序节点反映计划数、报工数、合格数、报废返工数和异常情况。", + metrics=[ + ("计划数", _fmt_qty(row["planned_qty"])), + ("已报工", _fmt_qty(row["report_qty"])), + ("报废数", _fmt_qty(row["scrap_qty"])), + ], + details=[ + ("工序序号", row["seq_no"]), + ("工序名称", row["operation_name"]), + ("合格数", _fmt_qty(row["good_qty"])), + ("返工数", _fmt_qty(row["rework_qty"])), + ("允许报废率", f"{_num(row['allowed_scrap_rate']) * 100:.2f}%"), + ("是否异常", "是" if row["is_abnormal"] else "否"), + ("备注", row["remark"]), + ], + raw=dict(row), + ) + ) + add_edge(_edge(source_id, node_id, "工艺路线")) + + for row in reports: + node_id = f"report-{row['report_id']}" + source_id = f"op-{row['work_order_operation_id']}" + add_node( + _node( + node_id=node_id, + node_type="operation_report", + title=row["report_no"], + subtitle=row["employee_name"], + status="ABNORMAL" if row["is_abnormal"] else "NORMAL", + tone="danger" if row["is_abnormal"] else "success", + stage="工人报工", + level=7, + summary="一线员工扫码报工记录,直接影响工序进度、节拍、报废返工和成本。", + metrics=[ + ("报工数", _fmt_qty(row["report_qty"])), + ("合格数", _fmt_qty(row["good_qty"])), + ("报废数", _fmt_qty(row["scrap_qty"])), + ], + details=[ + ("报工单号", row["report_no"]), + ("员工", row["employee_name"]), + ("开始时间", _fmt_datetime(row["start_time"])), + ("结束时间", _fmt_datetime(row["end_time"])), + ("返工数", _fmt_qty(row["rework_qty"])), + ("停机分钟", _fmt_qty(row["downtime_minutes"])), + ("额外成本", _fmt_money(row["extra_cost_amount"])), + ("异常原因", row["scrap_reason"] or ("节拍/报废异常" if row["is_abnormal"] else "-")), + ], + raw=dict(row), + ) + ) + add_edge(_edge(source_id, node_id, "扫码报工")) + + for row in scrap_records: + node_id = f"scrap-{row['scrap_record_id']}" + source_id = f"report-{row['report_id']}" + add_node( + _node( + node_id=node_id, + node_type="scrap_rework", + title=row["scrap_no"], + subtitle="返工" if row["disposal_type"] == "REWORK" else "报废", + status=row["status"], + tone="warning" if row["disposal_type"] == "REWORK" else "danger", + stage="报废/返工", + level=8, + summary="对报工异常、不良品进行处置,返工进入再加工,报废进入废料成本/收益口径。", + metrics=[ + ("处置数", _fmt_qty(row["scrap_qty"])), + ("处置重量", _fmt_weight(row["scrap_weight_kg"])), + ("成本", _fmt_money(row["scrap_cost_amount"])), + ], + details=[ + ("处置单号", row["scrap_no"]), + ("处置方式", _disposal_label(row["disposal_type"])), + ("可售废料重量", _fmt_weight(row["saleable_scrap_weight_kg"])), + ("处置原因", row["scrap_reason"]), + ("状态", _status_label(row["status"])), + ], + raw=dict(row), + ) + ) + add_edge(_edge(source_id, node_id, "异常处置")) + + for row in completion_items: + node_id = f"fg-{row['completion_item_id']}" + source_id = f"wo-{row['work_order_id']}" + source_material_lot_no = row["source_material_lot_no"] or row["source_material_sub_batch_no"] or "-" + add_node( + _node( + node_id=node_id, + node_type="completion_receipt", + title=row["receipt_no"], + subtitle=row["lot_no"], + status=row["status"], + tone=_tone_for_status(row["status"]), + stage="成品入库", + level=9, + summary="工单全部工序完成后形成成品入库批次,后续发货从该批次扣减。", + metrics=[ + ("入库数量", _fmt_qty(row["receipt_qty"])), + ("入库重量", _fmt_weight(row["receipt_weight_kg"])), + ("单位成本", _fmt_money(row["unit_cost"])), + ], + details=[ + ("成品入库单", row["receipt_no"]), + ("成品批次", row["lot_no"]), + ("来源库存批次号", source_material_lot_no), + ("仓库", row["warehouse_name"]), + ("入库时间", _fmt_datetime(row["receipt_time"])), + ("状态", _status_label(row["status"])), + ], + raw=dict(row), + ) + ) + add_edge(_edge(source_id, node_id, "完工入库")) + + for row in deliveries: + node_id = f"delivery-{row['delivery_item_id']}" + source_id = f"item-{row['sales_order_item_id']}" + lot_node = next((node for node in nodes if node.type == "completion_receipt" and node.subtitle == row["lot_no"]), None) + if lot_node: + source_id = lot_node.id + add_node( + _node( + node_id=node_id, + node_type="delivery", + title=row["delivery_no"], + subtitle=row["lot_no"] or "发货明细", + status=row["status"], + tone=_tone_for_status(row["status"]), + stage="发货", + level=10, + summary="成品从库存批次发出,记录发货数量、金额、收货人与送货地址。", + metrics=[ + ("发货数量", _fmt_qty(row["delivery_qty"])), + ("发货重量", _fmt_weight(row["delivery_weight_kg"])), + ("金额", _fmt_money(row["line_amount"])), + ], + details=[ + ("发货单号", row["delivery_no"]), + ("库存批次号", row["lot_no"]), + ("发货时间", _fmt_datetime(row["delivery_date"])), + ("收货人", row["consignee_name"]), + ("联系电话", row["consignee_phone"]), + ("送货地址", row["delivery_address"]), + ("单价", _fmt_money(row["unit_price"])), + ("状态", _status_label(row["status"])), + ], + raw=dict(row), + ) + ) + add_edge(_edge(source_id, node_id, "发货出库")) + + for row in return_items: + node_id = f"return-{row['return_item_id']}" + source_id = f"item-{row['sales_order_item_id']}" if row["sales_order_item_id"] else order_node_id + add_node( + _node( + node_id=node_id, + node_type="return", + title=row["return_no"], + subtitle=row["return_reason"] or "退货", + status=row["disposition_status"], + tone=_tone_for_status(row["disposition_status"]), + stage="退货售后", + level=11, + summary="客户退货登记,进入待处置状态后可选择返工或报废。", + metrics=[ + ("退货数量", _fmt_qty(row["return_qty"])), + ("退货重量", _fmt_weight(row["return_weight_kg"])), + ("处置状态", _status_label(row["disposition_status"])), + ], + details=[ + ("退货单号", row["return_no"]), + ("退货时间", _fmt_datetime(row["return_date"])), + ("退货原因", row["return_reason"]), + ("责任归属", row["responsibility_type"]), + ("退货单状态", _status_label(row["return_status"])), + ("备注", row["remark"]), + ], + raw=dict(row), + ) + ) + add_edge(_edge(source_id, node_id, "退货登记")) + + for row in return_dispositions: + node_id = f"return-disposition-{row['return_disposition_id']}" + source_id = f"return-{row['return_item_id']}" + add_node( + _node( + node_id=node_id, + node_type="return_disposition", + title=row["disposition_no"], + subtitle="返工" if row["disposition_type"] == "REWORK" else "报废", + status=row["status"], + tone="warning" if row["disposition_type"] == "REWORK" else "danger", + stage="退货处置", + level=12, + summary="退货最终处置结果。返工会产生返工工单和额外人工成本,报废会形成废料销售金额。", + metrics=[ + ("报废重量", _fmt_weight(row["scrap_weight_kg"])), + ("额外人工", _fmt_money(row["extra_cost_amount"])), + ("废料销售", _fmt_money(row["scrap_sale_amount"])), + ], + details=[ + ("处置单号", row["disposition_no"]), + ("处置方式", _disposal_label(row["disposition_type"])), + ("返工工单", row["rework_work_order_no"]), + ("完成时间", _fmt_datetime(row["closed_at"])), + ("备注", row["remark"]), + ("状态", _status_label(row["status"])), + ], + raw=dict(row), + ) + ) + add_edge(_edge(source_id, node_id, "售后处置")) + + return SalesOrderLifecycleRead(order=order, nodes=nodes, edges=edges) diff --git a/backend/app/api/routes/document_archives.py b/backend/app/api/routes/document_archives.py new file mode 100644 index 0000000..82fe510 --- /dev/null +++ b/backend/app/api/routes/document_archives.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +from typing import Any +from urllib.parse import quote +from zipfile import ZipFile + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.schemas.document_archives import DocumentArchiveBatchDownloadRequest, DocumentArchiveGenerateResult +from app.services.auth import AuthContext, require_authenticated_user +from app.services.document_archives import ( + ARCHIVE_STATUS_READY, + DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, + DOCUMENT_TYPE_PURCHASE_ORDER, + DOCUMENT_TYPE_PURCHASE_RECEIPT, + DOCUMENT_TYPE_QUALITY_INSPECTION, + DOCUMENT_TYPE_SALES_ORDER, + DOCUMENT_TYPE_WAREHOUSE_OPERATION, + FILE_FORMAT_PDF, + build_archive_zip, + default_archive_root, + generate_document_archive, + get_latest_archive, + safe_filename, +) + +router = APIRouter(dependencies=[Depends(require_authenticated_user)]) + +DOCUMENT_TYPE_KEY_MAP = { + "sales-order": DOCUMENT_TYPE_SALES_ORDER, + "purchase-order": DOCUMENT_TYPE_PURCHASE_ORDER, + "purchase-receipt": DOCUMENT_TYPE_PURCHASE_RECEIPT, + "quality-inspection": DOCUMENT_TYPE_QUALITY_INSPECTION, + "production-material-out": DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, + "production-inbound-settlement": DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + "warehouse-operation": DOCUMENT_TYPE_WAREHOUSE_OPERATION, + DOCUMENT_TYPE_SALES_ORDER: DOCUMENT_TYPE_SALES_ORDER, + DOCUMENT_TYPE_PURCHASE_ORDER: DOCUMENT_TYPE_PURCHASE_ORDER, + DOCUMENT_TYPE_PURCHASE_RECEIPT: DOCUMENT_TYPE_PURCHASE_RECEIPT, + DOCUMENT_TYPE_QUALITY_INSPECTION: DOCUMENT_TYPE_QUALITY_INSPECTION, + DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT: DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, + DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT: DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + DOCUMENT_TYPE_WAREHOUSE_OPERATION: DOCUMENT_TYPE_WAREHOUSE_OPERATION, +} + + +def normalize_document_type_key(document_type_key: str) -> str: + document_type = DOCUMENT_TYPE_KEY_MAP.get(document_type_key) + if document_type is None: + raise HTTPException( + status_code=400, + detail="单据类型只能选择销售订单、采购订单、到货入库单、质量校验单、生产领料出库单、生产入库结算单或仓库出入库单", + ) + return document_type + + +def _content_disposition(filename: str, inline: bool) -> str: + disposition = "inline" if inline else "attachment" + ascii_filename = safe_filename(filename.encode("ascii", "ignore").decode("ascii")) + encoded_filename = quote(filename, safe="") + return f"{disposition}; filename=\"{ascii_filename}\"; filename*=UTF-8''{encoded_filename}" + + +def _archive_file_response(path: str | Path, filename: str, inline: bool) -> FileResponse: + file_path = Path(path) + if not file_path.exists(): + raise HTTPException(status_code=404, detail="归档文件不存在,请重新生成") + return FileResponse( + file_path, + media_type="application/pdf", + filename=filename, + headers={"Content-Disposition": _content_disposition(filename, inline)}, + ) + + +def _extract_user_id(context: AuthContext | dict[str, Any] | Any) -> int | None: + if isinstance(context, dict): + value = context.get("user_id", context.get("uid", context.get("id"))) + return int(value) if value is not None else None + user = getattr(context, "user", None) + value = getattr(user, "id", None) + if value is None: + value = getattr(context, "user_id", None) + return int(value) if value is not None else None + + +def _latest_ready_pdf_or_404(db: Session, document_type: str, business_id: int): + archive = get_latest_archive(db, document_type, business_id) + if archive is None or archive.status != ARCHIVE_STATUS_READY or archive.file_format != FILE_FORMAT_PDF: + raise HTTPException(status_code=404, detail="归档文件不存在,请重新生成") + return archive + + +def _ensure_zip_has_downloadable_archives(zip_path: Path) -> None: + with ZipFile(zip_path) as archive_zip: + if not archive_zip.namelist(): + raise HTTPException(status_code=404, detail="没有可下载的归档文件,请先生成PDF归档") + + +@router.post("/{document_type_key}/{business_id}/generate", response_model=DocumentArchiveGenerateResult) +def generate_archive( + document_type_key: str, + business_id: int, + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> DocumentArchiveGenerateResult: + document_type = normalize_document_type_key(document_type_key) + return generate_document_archive(db, document_type, business_id, created_by=_extract_user_id(context)) + + +@router.get("/{document_type_key}/{business_id}/latest/preview") +def preview_latest_archive(document_type_key: str, business_id: int, db: Session = Depends(get_db)) -> FileResponse: + document_type = normalize_document_type_key(document_type_key) + archive = _latest_ready_pdf_or_404(db, document_type, business_id) + return _archive_file_response(archive.file_path, archive.file_name, inline=True) + + +@router.get("/{document_type_key}/{business_id}/latest/download") +def download_latest_archive(document_type_key: str, business_id: int, db: Session = Depends(get_db)) -> FileResponse: + document_type = normalize_document_type_key(document_type_key) + archive = _latest_ready_pdf_or_404(db, document_type, business_id) + return _archive_file_response(archive.file_path, archive.file_name, inline=False) + + +@router.post("/batch-download") +def batch_download_archives(payload: DocumentArchiveBatchDownloadRequest, db: Session = Depends(get_db)) -> FileResponse: + document_type = normalize_document_type_key(payload.document_type) + batch_dir = Path(default_archive_root()) / "batch" + timestamp = datetime.now().strftime("%Y%m%d%H%M%S%f") + zip_filename = safe_filename(f"{document_type}_归档_{timestamp}") + ".zip" + zip_path = build_archive_zip(db, document_type, payload.business_ids, batch_dir / zip_filename) + _ensure_zip_has_downloadable_archives(zip_path) + return FileResponse( + zip_path, + media_type="application/zip", + filename=zip_filename, + headers={"Content-Disposition": _content_disposition(zip_filename, inline=False)}, + ) diff --git a/backend/app/api/routes/equipment.py b/backend/app/api/routes/equipment.py new file mode 100644 index 0000000..3c54471 --- /dev/null +++ b/backend/app/api/routes/equipment.py @@ -0,0 +1,136 @@ +from datetime import datetime, timedelta + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.models.operations import Equipment, MaintenanceOrder, WorkCenter +from app.schemas.operations import ( + EquipmentCreate, + EquipmentRead, + MaintenanceOrderCreate, + MaintenanceOrderRead, +) +from app.services.auth import require_authenticated_user +from app.services.operations import get_equipment_query, get_maintenance_orders_query, to_decimal +from app.services.system_permissions import ensure_employee_has_permission + +router = APIRouter(dependencies=[Depends(require_authenticated_user)]) + + +def _code_token(value: str | None, fallback: str, max_length: int = 12) -> str: + token = "".join(ch for ch in str(value or "").upper() if ch.isalnum()) + return (token or fallback)[-max_length:] + + +def _next_doc_no(db: Session, model, column_name: str, prefix: str) -> str: + column = getattr(model, column_name) + count = db.scalar(select(func.count(model.id)).where(column.like(f"{prefix}-%"))) or 0 + return f"{prefix}-{count + 1:03d}" + + +def build_equipment_code(db: Session, work_center: WorkCenter, equipment_name: str | None) -> str: + date_part = datetime.now().strftime("%Y%m%d") + center_part = _code_token(work_center.center_code or work_center.center_name, f"WC{work_center.id:04d}", 10) + equipment_part = _code_token(equipment_name, "EQ", 10) + return _next_doc_no(db, Equipment, "equipment_code", f"EQ-NB-{date_part}-{center_part}-{equipment_part}") + + +def build_maintenance_order_no(db: Session, equipment: Equipment, maintenance_type: str | None) -> str: + date_part = datetime.now().strftime("%Y%m%d") + equipment_part = _code_token(equipment.equipment_code or equipment.equipment_name, f"EQ{equipment.id:04d}", 14) + type_part = _code_token(maintenance_type, "MTN", 8) + return _next_doc_no(db, MaintenanceOrder, "order_no", f"MTN-NB-{date_part}-{equipment_part}-{type_part}") + + +@router.get("/assets", response_model=list[EquipmentRead]) +def list_equipment_assets( + limit: int = Query(default=100, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[EquipmentRead]: + rows = db.execute(get_equipment_query(limit=limit)).mappings().all() + return [EquipmentRead.model_validate(dict(row)) for row in rows] + + +@router.post("/assets", response_model=EquipmentRead) +def create_equipment_asset(payload: EquipmentCreate, db: Session = Depends(get_db)) -> EquipmentRead: + work_center = db.get(WorkCenter, payload.work_center_id) + if not work_center: + raise HTTPException(status_code=404, detail="工作中心不存在") + + equipment = Equipment( + equipment_code=build_equipment_code(db, work_center, payload.equipment_name), + equipment_name=payload.equipment_name, + category_name=payload.category_name, + work_center_id=payload.work_center_id, + manufacturer=payload.manufacturer, + model_no=payload.model_no, + install_date=payload.install_date, + rated_power_kw=to_decimal(payload.rated_power_kw, "0.01"), + occupied_area_sqm=to_decimal(payload.occupied_area_sqm, "0.01"), + maintenance_cycle_hours=to_decimal(payload.maintenance_cycle_hours, "0.01"), + last_maintenance_at=None, + next_maintenance_at=payload.next_maintenance_at, + status=payload.status, + remark=payload.remark, + ) + db.add(equipment) + db.commit() + + row = db.execute(get_equipment_query(limit=1).where(Equipment.id == equipment.id)).mappings().first() + if not row: + raise HTTPException(status_code=500, detail="设备创建后读取失败") + return EquipmentRead.model_validate(dict(row)) + + +@router.get("/maintenance-orders", response_model=list[MaintenanceOrderRead]) +def list_maintenance_orders( + limit: int = Query(default=100, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[MaintenanceOrderRead]: + rows = db.execute(get_maintenance_orders_query(limit=limit)).mappings().all() + return [MaintenanceOrderRead.model_validate(dict(row)) for row in rows] + + +@router.post("/maintenance-orders", response_model=MaintenanceOrderRead) +def create_maintenance_order(payload: MaintenanceOrderCreate, db: Session = Depends(get_db)) -> MaintenanceOrderRead: + equipment = db.get(Equipment, payload.equipment_id) + if not equipment: + raise HTTPException(status_code=404, detail="设备不存在") + ensure_employee_has_permission( + db, + payload.owner_employee_id, + "MENU_EQUIPMENT_MANAGEMENT", + "负责人", + required=False, + ) + + order = MaintenanceOrder( + order_no=build_maintenance_order_no(db, equipment, payload.maintenance_type), + equipment_id=payload.equipment_id, + plan_id=None, + maintenance_type=payload.maintenance_type, + start_time=payload.start_time or datetime.now(), + end_time=payload.end_time, + downtime_minutes=to_decimal(payload.downtime_minutes, "0.01"), + owner_employee_id=payload.owner_employee_id, + result_summary=payload.result_summary, + cost_amount=to_decimal(payload.cost_amount, "0.01"), + status=payload.status, + ) + db.add(order) + + if payload.status == "COMPLETED" or payload.end_time: + finished_time = payload.end_time or datetime.now() + equipment.last_maintenance_at = finished_time + if to_decimal(equipment.maintenance_cycle_hours, "0.01") > 0: + equipment.next_maintenance_at = finished_time + timedelta(hours=float(equipment.maintenance_cycle_hours)) + db.add(equipment) + + db.commit() + + row = db.execute(get_maintenance_orders_query(limit=1).where(MaintenanceOrder.id == order.id)).mappings().first() + if not row: + raise HTTPException(status_code=500, detail="保养工单创建后读取失败") + return MaintenanceOrderRead.model_validate(dict(row)) diff --git a/backend/app/api/routes/finance.py b/backend/app/api/routes/finance.py new file mode 100644 index 0000000..263545c --- /dev/null +++ b/backend/app/api/routes/finance.py @@ -0,0 +1,178 @@ +from datetime import date + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.models.operations import AccountingPeriod, OverheadEntry, StatementSnapshot +from app.schemas.operations import ( + AccountingPeriodCreate, + AccountingPeriodRead, + CostAllocationGenerateResult, + CostAllocationRead, + OverheadEntryCreate, + OverheadEntryRead, + ProductMaterialCostBoardRead, + StatementSnapshotCreate, + StatementSnapshotRead, +) +from app.services.auth import AuthContext, require_authenticated_user +from app.services.operations import ( + build_product_material_cost_board, + build_statement_summary, + generate_cost_allocations, + get_cost_allocation_rows, + get_overheads_query, + get_periods_query, + get_statement_snapshots_query, + to_decimal, +) +from app.services.sales_planning import code_token, next_doc_no + +router = APIRouter(dependencies=[Depends(require_authenticated_user)]) + + +def build_statement_snapshot_no(db: Session, period: AccountingPeriod, statement_type: str, report_date: date) -> str: + date_part = report_date.strftime("%Y%m%d") + period_part = code_token(period.period_code, f"PER{period.id:04d}", 8) + type_part = code_token(statement_type, "STM", 8) + return next_doc_no(db, StatementSnapshot, "snapshot_no", f"FIN-NB-{date_part}-{period_part}-{type_part}") + + +@router.get("/product-material-cost-board", response_model=ProductMaterialCostBoardRead) +def get_product_material_cost_board( + date_from: date | None = Query(default=None), + date_to: date | None = Query(default=None), + db: Session = Depends(get_db), +) -> ProductMaterialCostBoardRead: + end_date = date_to or date.today() + start_date = date_from or end_date.replace(day=1) + return build_product_material_cost_board(db, date_from=start_date, date_to=end_date) + + +@router.get("/periods", response_model=list[AccountingPeriodRead]) +def list_accounting_periods( + limit: int = Query(default=50, ge=1, le=200), + db: Session = Depends(get_db), +) -> list[AccountingPeriodRead]: + rows = db.execute(get_periods_query(limit=limit)).mappings().all() + return [AccountingPeriodRead.model_validate(dict(row)) for row in rows] + + +@router.post("/periods", response_model=AccountingPeriodRead) +def create_accounting_period(payload: AccountingPeriodCreate, db: Session = Depends(get_db)) -> AccountingPeriodRead: + period_code = f"{payload.year_no}{payload.month_no:02d}" + period = AccountingPeriod( + period_code=period_code, + year_no=payload.year_no, + month_no=payload.month_no, + start_date=payload.start_date, + end_date=payload.end_date, + close_status=payload.close_status, + closed_at=None, + ) + db.add(period) + db.commit() + + row = db.execute(get_periods_query(limit=1).where(AccountingPeriod.id == period.id)).mappings().first() + if not row: + raise HTTPException(status_code=500, detail="财务期间创建后读取失败") + return AccountingPeriodRead.model_validate(dict(row)) + + +@router.get("/overheads", response_model=list[OverheadEntryRead]) +def list_overhead_entries( + limit: int = Query(default=100, ge=1, le=300), + db: Session = Depends(get_db), +) -> list[OverheadEntryRead]: + rows = db.execute(get_overheads_query(limit=limit)).mappings().all() + return [OverheadEntryRead.model_validate(dict(row)) for row in rows] + + +@router.post("/overheads", response_model=OverheadEntryRead) +def create_overhead_entry( + payload: OverheadEntryCreate, + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> OverheadEntryRead: + period = db.get(AccountingPeriod, payload.period_id) + if not period: + raise HTTPException(status_code=404, detail="财务期间不存在") + + entry = OverheadEntry( + period_id=payload.period_id, + overhead_type=payload.overhead_type, + dept_id=payload.dept_id, + amount=to_decimal(payload.amount, "0.01"), + allocation_basis=payload.allocation_basis, + description=payload.description, + created_by=context.user.id, + ) + db.add(entry) + db.commit() + + row = db.execute(get_overheads_query(limit=1).where(OverheadEntry.id == entry.id)).mappings().first() + if not row: + raise HTTPException(status_code=500, detail="制造费用录入后读取失败") + return OverheadEntryRead.model_validate(dict(row)) + + +@router.get("/cost-allocations", response_model=list[CostAllocationRead]) +def list_cost_allocations( + period_id: int | None = Query(default=None), + limit: int = Query(default=100, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[CostAllocationRead]: + return get_cost_allocation_rows(db, period_id=period_id, limit=limit) + + +@router.post("/generate-cost-allocations/{period_id}", response_model=CostAllocationGenerateResult) +def generate_period_cost_allocations(period_id: int, db: Session = Depends(get_db)) -> CostAllocationGenerateResult: + period = db.get(AccountingPeriod, period_id) + if not period: + raise HTTPException(status_code=404, detail="财务期间不存在") + rows = generate_cost_allocations(db, period_id) + db.commit() + return CostAllocationGenerateResult( + period_id=period.id, + period_code=period.period_code, + generated_count=len(rows), + rows=rows, + ) + + +@router.get("/statements", response_model=list[StatementSnapshotRead]) +def list_statement_snapshots( + limit: int = Query(default=100, ge=1, le=300), + db: Session = Depends(get_db), +) -> list[StatementSnapshotRead]: + rows = db.execute(get_statement_snapshots_query(limit=limit)).mappings().all() + return [StatementSnapshotRead.model_validate(dict(row)) for row in rows] + + +@router.post("/statements", response_model=StatementSnapshotRead) +def create_statement_snapshot( + payload: StatementSnapshotCreate, + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> StatementSnapshotRead: + period = db.get(AccountingPeriod, payload.period_id) + if not period: + raise HTTPException(status_code=404, detail="财务期间不存在") + + summary_json = build_statement_summary(db, payload.period_id, payload.report_date) + snapshot = StatementSnapshot( + snapshot_no=build_statement_snapshot_no(db, period, payload.statement_type, payload.report_date), + period_id=payload.period_id, + statement_type=payload.statement_type, + report_date=payload.report_date, + summary_json=summary_json, + created_by=context.user.id, + ) + db.add(snapshot) + db.commit() + + row = db.execute(get_statement_snapshots_query(limit=1).where(StatementSnapshot.id == snapshot.id)).mappings().first() + if not row: + raise HTTPException(status_code=500, detail="财务报表快照创建后读取失败") + return StatementSnapshotRead.model_validate(dict(row)) diff --git a/backend/app/api/routes/inventory.py b/backend/app/api/routes/inventory.py new file mode 100644 index 0000000..32f57e8 --- /dev/null +++ b/backend/app/api/routes/inventory.py @@ -0,0 +1,2933 @@ +from datetime import date, datetime +from decimal import Decimal +from io import BytesIO +from pathlib import Path +from urllib.parse import quote +from uuid import uuid4 + +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile +from fastapi.responses import FileResponse, Response +from openpyxl import Workbook, load_workbook +from sqlalchemy import and_, case, func, or_, select, update +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.models.document_archive import DocumentArchive +from app.models.master_data import Bom, BomItem, Item, StockBalance, Warehouse +from app.models.operations import ( + InventoryTxn, + OperationReport, + ProcessRoute, + ProcessRouteOperation, + ProductionBatchLedger, + ProductionBatchLedgerTxn, + PurchaseOrder, + PurchaseOrderItem, + PurchaseReceipt, + PurchaseReceiptItem, + PurchaseReturn, + PurchaseReturnItem, + SpecialWarehouseAdjustmentLine, + StockLot, + Stocktake, + StocktakeLine, + StocktakeWarehouse, + WarehouseLocation, + WorkOrder, +) +from app.schemas.operations import ( + CustomerSuppliedInboundCreate, + InventorySettlementRead, + OpeningInventoryImportResult, + InventoryTxnLedgerResponse, + InventoryTxnRead, + PurchaseReturnRead, + RawMaterialReturnOutboundCreate, + SpecialWarehouseAdjustmentCreate, + SpecialWarehouseAdjustmentRead, + StockBalanceRead, + StockLotPurchaseLinkRead, + StockLotOptionRead, + StockLotRead, + StocktakeConfirmCreate, + StocktakeImportPreviewRead, + StocktakeLineRead, + StocktakeRead, + StocktakeStartCreate, + WarehouseInboundCreate, + WarehouseOutboundCreate, +) +from app.services.auth import AuthContext, require_authenticated_user +from app.services.document_archives import ( + ARCHIVE_STATUS_MISSING, + DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + DOCUMENT_TYPE_WAREHOUSE_OPERATION, + FILE_FORMAT_PDF, + generate_document_archive, + production_document_batch_no_from_text, +) +from app.services.logistics import normalize_logistics_fields +from app.services.operations import ( + build_inventory_lot_no, + create_inventory_txn, + WAREHOUSE_LEDGER_TYPES, + get_location_or_default, + get_inventory_settlements, + get_inventory_txn_ledger_query, + get_inventory_txn_query, + get_stock_balances_query, + get_stock_lot_purchase_links, + get_stock_lot_options_query, + get_stock_lots_query, + to_decimal, + summarize_inventory_txn_ledger, + upsert_stock_balance, +) +from app.services.production_work_order_ledger import ( + INBOUND_TOLERANCE_MULTIPLIER, + get_work_order_limit_snapshot, + validate_inbound_with_tolerance, + validate_work_order_source_lot, +) +from app.services.production_batch_ledger import ( + lock_production_batch_ledger, + post_finished_inbound_to_ledger, + post_scrap_inbound_to_ledger, + post_surplus_inbound_to_ledger, +) +from app.services.special_inventory_adjustment import create_special_inventory_adjustment +from app.services.stocktake import ( + _stocktake_summary, + build_stocktake_workbook, + cancel_stocktake, + confirm_stocktake, + ensure_warehouses_unlocked, + import_stocktake_workbook, + start_stocktake, + stocktake_lines_for_read, + stocktake_warehouses_for_read, +) + +router = APIRouter(dependencies=[Depends(require_authenticated_user)]) + + +def _stock_lot_read_with_archive( + row: dict, + *, + archive_status: str | None = None, + archive_business_id: int | None = None, + archive_document_type: str | None = None, + archive_error_message: str | None = None, +) -> StockLotRead: + response = StockLotRead.model_validate(dict(row)) + response.archive_status = archive_status + response.archive_business_id = archive_business_id + response.archive_document_type = archive_document_type + response.archive_error_message = archive_error_message + return response + + +def _generate_inventory_archive_fields( + db: Session, + *, + document_type: str, + business_id: int | None, + created_by: int | None, +) -> dict[str, object]: + if not business_id: + return { + "archive_status": None, + "archive_business_id": None, + "archive_document_type": None, + "archive_error_message": None, + } + try: + archive_result = generate_document_archive(db, document_type, int(business_id), created_by=created_by) + return { + "archive_status": archive_result.archive_status, + "archive_business_id": int(business_id), + "archive_document_type": document_type, + "archive_error_message": archive_result.archive_error_message, + } + except Exception as exc: + _ = exc + return { + "archive_status": "归档失败", + "archive_business_id": int(business_id), + "archive_document_type": document_type, + "archive_error_message": "归档失败:PDF归档服务异常,请稍后重新生成", + } + + +def _generate_inventory_archives_for_txns( + db: Session, + *, + document_type: str, + business_ids: list[int], + created_by: int | None, +) -> dict[str, object]: + first_archive_fields: dict[str, object] | None = None + for business_id in business_ids: + archive_fields = _generate_inventory_archive_fields( + db, + document_type=document_type, + business_id=business_id, + created_by=created_by, + ) + if first_archive_fields is None: + first_archive_fields = archive_fields + return first_archive_fields or _generate_inventory_archive_fields( + db, + document_type=document_type, + business_id=None, + created_by=created_by, + ) + +LOGISTICS_UPLOAD_DIR = Path(__file__).resolve().parents[3] / "uploads" / "logistics" +LOGISTICS_ALLOWED_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif"} +LOGISTICS_MAX_BYTES = 10 * 1024 * 1024 + +OPENING_INVENTORY_HEADERS = [ + "仓库类型", + "仓库名称", + "库位", + "物料编码", + "物料名称", + "批次号", + "数量", + "重量(kg)", + "单价", + "来源说明", + "备注", +] +WEIGHT_ONLY_WAREHOUSE_TYPES = {"RAW", "SCRAP"} + +WAREHOUSE_TYPE_LABELS = { + "RAW": "原材料库", + "SEMI": "半成品库", + "FINISHED": "成品库", + "AUX": "辅料库", + "SCRAP": "废料库", + "RETURN": "退货库", +} + +WAREHOUSE_LABEL_TO_TYPE = {label: key for key, label in WAREHOUSE_TYPE_LABELS.items()} + +OPENING_INVENTORY_SAMPLE_ROWS = { + "RAW": [ + ["原材料库", "原材料仓", "原料主库位", "示例原材料001", "304不锈钢板", "示例-RAW-0001", 0, 500, 4.2, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"], + ["原材料库", "原材料仓", "原料主库位", "示例原材料002", "冷轧钢板", "示例-RAW-0002", 0, 1200, 3.8, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"], + ], + "SEMI": [ + ["半成品库", "半成品仓", "半成品主库位", "示例产品001", "五金支架半成品", "示例-SEMI-0001", 100, 250, 8.5, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"], + ["半成品库", "半成品仓", "半成品主库位", "示例产品002", "冲压件半成品", "示例-SEMI-0002", 60, 180, 7.2, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"], + ], + "FINISHED": [ + ["成品库", "成品仓", "成品主库位", "示例产品001", "五金支架", "示例-FG-0001", 100, 250, 12.5, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"], + ["成品库", "成品仓", "成品主库位", "示例产品002", "苹果电脑支架", "示例-FG-0002", 80, 200, 15, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"], + ], + "AUX": [ + ["辅料库", "辅料仓", "辅料主库位", "示例辅料001", "包装薄膜", "示例-AUX-0001", 80, 0, 6.5, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"], + ["辅料库", "辅料仓", "辅料主库位", "示例辅料002", "劳保手套", "示例-AUX-0002", 25, 0, 12, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"], + ], + "SCRAP": [ + ["废料库", "废料库", "废料暂存位", "示例原材料001", "304不锈钢边角料", "示例-SCRAP-0001", 0, 120, 1.6, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"], + ["废料库", "废料库", "废料暂存位", "示例原材料002", "冲压报废件", "示例-SCRAP-0002", 0, 80, 1.2, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"], + ], + "RETURN": [ + ["退货库", "退货库", "退货暂存位", "示例产品001", "返工待处理五金支架", "示例-RETURN-0001", 20, 50, 12.5, "客户退回待返工初始化", "示例行,导入时会自动跳过,请替换为真实数据"], + ["退货库", "退货库", "退货暂存位", "示例产品002", "返工待处理苹果电脑支架", "示例-RETURN-0002", 12, 30, 15, "客户退回待返工初始化", "示例行,导入时会自动跳过,请替换为真实数据"], + ], +} + + +def _is_weight_only_warehouse_type(warehouse_type: str | None) -> bool: + return str(warehouse_type or "").upper() in WEIGHT_ONLY_WAREHOUSE_TYPES + + +def _opening_inventory_headers_for_type(warehouse_type: str | None) -> list[str]: + if _is_weight_only_warehouse_type(warehouse_type): + return [header for header in OPENING_INVENTORY_HEADERS if header != "数量"] + return list(OPENING_INVENTORY_HEADERS) + + +def _opening_inventory_row_for_headers(row: list[object], headers: list[str]) -> list[object]: + legacy_payload = {header: row[index] if index < len(row) else "" for index, header in enumerate(OPENING_INVENTORY_HEADERS)} + return [legacy_payload.get(header, "") for header in headers] + + +def _excel_response(workbook, filename: str) -> Response: + output = BytesIO() + workbook.save(output) + encoded_filename = quote(filename) + return Response( + content=output.getvalue(), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f"attachment; filename=\"stocktake.xlsx\"; filename*=UTF-8''{encoded_filename}"}, + ) + + +def _stocktake_read(db: Session, stocktake_id: int, warehouse_id: int | None = None) -> StocktakeRead: + stocktake = db.get(Stocktake, stocktake_id) + if not stocktake: + raise HTTPException(status_code=404, detail="盘库单不存在") + warehouses = stocktake_warehouses_for_read(db, stocktake_id) + lines = stocktake_lines_for_read(db, stocktake_id) + if warehouse_id: + lines = [line for line in lines if int(line.warehouse_id) == int(warehouse_id)] + return StocktakeRead.model_validate( + { + "id": stocktake.id, + "stocktake_no": stocktake.stocktake_no, + "status": stocktake.status, + "started_by_user_id": stocktake.started_by_user_id, + "imported_by_user_id": stocktake.imported_by_user_id, + "confirmed_by_user_id": stocktake.confirmed_by_user_id, + "canceled_by_user_id": stocktake.canceled_by_user_id, + "started_at": stocktake.started_at, + "exported_at": stocktake.exported_at, + "imported_at": stocktake.imported_at, + "confirmed_at": stocktake.confirmed_at, + "canceled_at": stocktake.canceled_at, + "remark": stocktake.remark, + "warehouses": [ + { + "id": row.id, + "warehouse_id": row.warehouse_id, + "warehouse_name": row.warehouse_name, + "warehouse_type": row.warehouse_type, + "status": row.status, + } + for row in warehouses + ], + "summary": _stocktake_summary(lines), + } + ) + + +def _stocktake_line_read(line: StocktakeLine) -> dict[str, object]: + return { + "id": line.id, + "stocktake_id": line.stocktake_id, + "line_no": line.line_no, + "warehouse_id": line.warehouse_id, + "warehouse_name": line.warehouse_name, + "warehouse_type": line.warehouse_type, + "location_id": line.location_id, + "location_name": line.location_name, + "item_id": line.item_id, + "item_code": line.item_code, + "item_name": line.item_name, + "item_type": line.item_type, + "lot_id": line.lot_id, + "lot_no": line.lot_no, + "source_material_sub_batch_no": line.source_material_sub_batch_no, + "snapshot_qty": line.snapshot_qty, + "snapshot_weight_kg": line.snapshot_weight_kg, + "snapshot_unit_cost": line.snapshot_unit_cost, + "counted_qty": line.counted_qty, + "counted_weight_kg": line.counted_weight_kg, + "diff_qty": line.diff_qty, + "diff_weight_kg": line.diff_weight_kg, + "diff_amount": line.diff_amount, + "diff_type": line.diff_type, + "row_status": line.row_status, + "error_message": line.error_message, + "remark": line.remark, + } + + +def _stocktake_preview_read(db: Session, result: dict[str, object]) -> StocktakeImportPreviewRead: + stocktake = result["stocktake"] + lines = result["lines"] + return StocktakeImportPreviewRead.model_validate( + { + "stocktake": _stocktake_read(db, stocktake.id), + "lines": [_stocktake_line_read(line) for line in lines], + "summary": result["summary"], + } + ) + + +def _code_token(value: str | None, fallback: str, max_length: int = 14) -> str: + token = "".join(ch for ch in str(value or "").upper() if ch.isalnum()) + return (token or fallback)[-max_length:] + + +def _build_customer_supplied_lot_no(db: Session, material: Item) -> str: + return build_inventory_lot_no(db, material.id) + + +def _build_purchase_return_no(db: Session) -> str: + year = datetime.now().strftime("%Y") + prefix = f"退货{year}-" + rows = db.scalars(select(PurchaseReturn.return_no).where(PurchaseReturn.return_no.like(f"{prefix}%"))).all() + max_no = 0 + for value in rows: + tail = str(value or "").replace(prefix, "", 1) + if tail.isdigit(): + max_no = max(max_no, int(tail)) + return f"{prefix}{max_no + 1:05d}" + + +def _lock_purchase_order_items_for_return(db: Session, purchase_order_item_ids: set[int]) -> None: + if not purchase_order_item_ids: + return + db.scalars( + select(PurchaseOrderItem) + .where(PurchaseOrderItem.id.in_(sorted(purchase_order_item_ids))) + .with_for_update() + ).all() + + +def _current_returned_weight_for_purchase_line_lot(db: Session, purchase_order_item_id: int, lot_id: int) -> Decimal: + rows = db.scalars( + select(PurchaseReturnItem.return_weight_kg) + .join(PurchaseReturn, PurchaseReturn.id == PurchaseReturnItem.purchase_return_id) + .where( + PurchaseReturnItem.purchase_order_item_id == purchase_order_item_id, + PurchaseReturnItem.lot_id == lot_id, + PurchaseReturn.status != "CANCELED", + PurchaseReturnItem.status != "CANCELED", + ) + .with_for_update() + ).all() + return sum((to_decimal(row) for row in rows), Decimal("0")) + + +def _recheck_raw_material_return_purchase_line_availability(db: Session, prepared_lines: list[dict[str, object]]) -> None: + for prepared in prepared_lines: + lot_id = int(prepared["lot_id"]) + purchase_order_item_id = int(prepared["purchase_order_item_id"]) + return_weight = to_decimal(prepared["return_weight"]) + matched_link = next( + ( + link + for link in get_stock_lot_purchase_links(db, lot_id) + if int(link["purchase_order_item_id"]) == purchase_order_item_id + ), + None, + ) + if not matched_link: + db.rollback() + raise HTTPException(status_code=400, detail="退货重量不能超过该采购明细可退重量") + current_returned_weight = _current_returned_weight_for_purchase_line_lot(db, purchase_order_item_id, lot_id) + current_line_returnable_weight = to_decimal(matched_link["accepted_weight_kg"]) - current_returned_weight + if current_line_returnable_weight < 0: + current_line_returnable_weight = Decimal("0") + if return_weight > current_line_returnable_weight or return_weight > to_decimal(matched_link["returnable_weight_kg"]): + db.rollback() + raise HTTPException(status_code=400, detail="退货重量不能超过该采购明细可退重量") + prepared["unit_cost"] = to_decimal(matched_link["unit_cost"], "0.0001") + + +def _purchase_return_read(db: Session, purchase_return_id: int) -> PurchaseReturnRead: + header = db.execute( + select( + PurchaseReturn.id.label("purchase_return_id"), + PurchaseReturn.return_no.label("return_no"), + PurchaseReturn.purchase_order_id.label("purchase_order_id"), + PurchaseOrder.po_no.label("po_no"), + PurchaseReturn.warehouse_id.label("warehouse_id"), + Warehouse.warehouse_name.label("warehouse_name"), + PurchaseReturn.return_date.label("return_date"), + PurchaseReturn.logistics_waybill_no.label("logistics_waybill_no"), + PurchaseReturn.logistics_freight_amount.label("logistics_freight_amount"), + PurchaseReturn.logistics_photo_url.label("logistics_photo_url"), + PurchaseReturn.reason.label("reason"), + PurchaseReturn.status.label("status"), + PurchaseReturn.remark.label("remark"), + ) + .join(PurchaseOrder, PurchaseOrder.id == PurchaseReturn.purchase_order_id) + .join(Warehouse, Warehouse.id == PurchaseReturn.warehouse_id) + .where(PurchaseReturn.id == purchase_return_id) + ).mappings().first() + if not header: + raise HTTPException(status_code=404, detail="采购退货单不存在") + + item_rows = db.execute( + select( + PurchaseReturnItem.id.label("purchase_return_item_id"), + PurchaseReturnItem.purchase_return_id.label("purchase_return_id"), + PurchaseReturnItem.line_no.label("line_no"), + PurchaseReturnItem.purchase_order_item_id.label("purchase_order_item_id"), + PurchaseReturnItem.material_item_id.label("material_item_id"), + Item.item_code.label("material_code"), + Item.item_name.label("material_name"), + PurchaseReturnItem.lot_id.label("lot_id"), + StockLot.lot_no.label("lot_no"), + PurchaseReturnItem.return_weight_kg.label("return_weight_kg"), + PurchaseReturnItem.unit_cost.label("unit_cost"), + PurchaseReturnItem.amount.label("amount"), + PurchaseReturnItem.reason.label("reason"), + PurchaseReturnItem.status.label("status"), + PurchaseReturnItem.remark.label("remark"), + ) + .join(Item, Item.id == PurchaseReturnItem.material_item_id) + .join(StockLot, StockLot.id == PurchaseReturnItem.lot_id) + .where(PurchaseReturnItem.purchase_return_id == purchase_return_id) + .order_by(PurchaseReturnItem.line_no.asc(), PurchaseReturnItem.id.asc()) + ).mappings().all() + + total_return_weight = sum((to_decimal(row["return_weight_kg"]) for row in item_rows), Decimal("0")) + total_amount = sum((to_decimal(row["amount"], "0.01") for row in item_rows), Decimal("0")) + return PurchaseReturnRead.model_validate( + { + **dict(header), + "total_return_weight_kg": total_return_weight, + "total_amount": total_amount, + "items": [dict(row) for row in item_rows], + } + ) + + +@router.post("/stocktakes", response_model=StocktakeRead) +def start_inventory_stocktake( + payload: StocktakeStartCreate, + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> StocktakeRead: + stocktake = start_stocktake(db, warehouse_ids=payload.warehouse_ids, user_id=context.user.id, remark=payload.remark) + return _stocktake_read(db, stocktake.id) + + +@router.get("/stocktakes/{stocktake_id}/export") +def export_inventory_stocktake(stocktake_id: int, db: Session = Depends(get_db)) -> Response: + workbook = build_stocktake_workbook(db, stocktake_id) + stocktake = db.get(Stocktake, stocktake_id) + if not stocktake: + raise HTTPException(status_code=404, detail="盘库单不存在") + return _excel_response(workbook, f"{stocktake.stocktake_no}_盘库清单.xlsx") + + +@router.get("/stocktakes", response_model=list[StocktakeRead]) +def list_inventory_stocktakes( + limit: int = Query(default=100, ge=1, le=500), + warehouse_id: int | None = Query(default=None), + db: Session = Depends(get_db), +) -> list[StocktakeRead]: + stmt = select(Stocktake).order_by(Stocktake.started_at.desc(), Stocktake.id.desc()).limit(limit) + if warehouse_id: + stmt = ( + select(Stocktake) + .join(StocktakeWarehouse, StocktakeWarehouse.stocktake_id == Stocktake.id) + .where(StocktakeWarehouse.warehouse_id == warehouse_id) + .order_by(Stocktake.started_at.desc(), Stocktake.id.desc()) + .limit(limit) + ) + rows = db.scalars(stmt).all() + return [_stocktake_read(db, row.id, warehouse_id=warehouse_id) for row in rows] + + +@router.get("/stocktakes/{stocktake_id}", response_model=StocktakeRead) +def get_inventory_stocktake(stocktake_id: int, db: Session = Depends(get_db)) -> StocktakeRead: + return _stocktake_read(db, stocktake_id) + + +@router.get("/stocktakes/{stocktake_id}/lines", response_model=list[StocktakeLineRead]) +def list_inventory_stocktake_lines( + stocktake_id: int, + warehouse_id: int | None = Query(default=None), + db: Session = Depends(get_db), +) -> list[StocktakeLineRead]: + lines = stocktake_lines_for_read(db, stocktake_id) + if warehouse_id: + lines = [line for line in lines if int(line.warehouse_id) == int(warehouse_id)] + return [StocktakeLineRead.model_validate(_stocktake_line_read(line)) for line in lines] + + +@router.post("/stocktakes/{stocktake_id}/import", response_model=StocktakeImportPreviewRead) +async def import_inventory_stocktake( + stocktake_id: int, + file: UploadFile = File(...), + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> StocktakeImportPreviewRead: + content = await file.read() + try: + result = import_stocktake_workbook(db, stocktake_id, content, user_id=context.user.id) + except ValueError as exc: + db.rollback() + raise HTTPException(status_code=400, detail=str(exc)) from exc + return _stocktake_preview_read(db, result) + + +@router.post("/stocktakes/{stocktake_id}/confirm", response_model=StocktakeRead) +def confirm_inventory_stocktake( + stocktake_id: int, + payload: StocktakeConfirmCreate, + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> StocktakeRead: + stocktake = confirm_stocktake( + db, + stocktake_id, + user_id=context.user.id, + confirm_text=payload.confirm_text, + remark=payload.remark, + ) + return _stocktake_read(db, stocktake.id) + + +@router.post("/stocktakes/{stocktake_id}/cancel", response_model=StocktakeRead) +def cancel_inventory_stocktake( + stocktake_id: int, + payload: StocktakeConfirmCreate, + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> StocktakeRead: + stocktake = cancel_stocktake(db, stocktake_id, user_id=context.user.id, remark=payload.remark) + return _stocktake_read(db, stocktake.id) + + +def _normalize_warehouse_type(value: str | None) -> str: + text = str(value or "").strip().upper() + if text in WAREHOUSE_TYPE_LABELS: + return text + return WAREHOUSE_LABEL_TO_TYPE.get(str(value or "").strip(), "") + + +def _opening_cell_text(value: object) -> str: + return str(value or "").strip() + + +def _opening_decimal(value: object, default: Decimal = Decimal("0"), places: str = "0.000001") -> Decimal: + if value is None or _opening_cell_text(value) == "": + return default + try: + return Decimal(str(value)).quantize(Decimal(places)) + except Exception as exc: + raise ValueError(f"数值格式错误:{value}") from exc + + +def _opening_row_is_empty(row: dict[str, object]) -> bool: + return not any(_opening_cell_text(value) for key, value in row.items() if not str(key).startswith("_")) + + +def _opening_row_is_sample(row: dict[str, object]) -> bool: + lot_no = _opening_cell_text(row.get("批次号")) + item_code = _opening_cell_text(row.get("物料编码")) + remark = _opening_cell_text(row.get("备注")) + return lot_no.startswith("示例-") and item_code.startswith("示例") and "示例行" in remark + + +def _opening_sheet_title(warehouse_type: str) -> str: + return f"{WAREHOUSE_TYPE_LABELS.get(warehouse_type, warehouse_type)}期初导入"[:31] + + +def build_opening_inventory_template(db: Session, warehouse_type: str) -> Workbook: + normalized_type = _normalize_warehouse_type(warehouse_type) + if normalized_type not in WAREHOUSE_TYPE_LABELS: + raise HTTPException(status_code=400, detail="未知仓库类型,无法导出期初模版") + workbook = Workbook() + worksheet = workbook.active + worksheet.title = _opening_sheet_title(normalized_type) + worksheet.freeze_panes = "A2" + headers = _opening_inventory_headers_for_type(normalized_type) + worksheet.append(headers) + for row in OPENING_INVENTORY_SAMPLE_ROWS.get(normalized_type, []): + worksheet.append(_opening_inventory_row_for_headers(row, headers)) + widths_by_header = { + "仓库类型": 14, + "仓库名称": 18, + "库位": 18, + "物料编码": 20, + "物料名称": 28, + "批次号": 24, + "数量": 12, + "重量(kg)": 14, + "单价": 12, + "来源说明": 24, + "备注": 24, + } + for index, header in enumerate(headers, start=1): + width = widths_by_header.get(header, 14) + worksheet.column_dimensions[worksheet.cell(row=1, column=index).column_letter].width = width + return workbook + + +def _read_opening_inventory_rows(content: bytes) -> list[dict[str, object]]: + try: + workbook = load_workbook(BytesIO(content), read_only=True, data_only=True) + except Exception as exc: + raise ValueError("Excel 文件读取失败,请确认上传的是期初导入 xlsx 文件") from exc + rows: list[dict[str, object]] = [] + for worksheet in workbook.worksheets: + values = list(worksheet.iter_rows(values_only=True)) + if not values: + continue + headers = [_opening_cell_text(value) for value in values[0]] + required_headers = [header for header in OPENING_INVENTORY_HEADERS if header != "数量"] + missing = [header for header in required_headers if header not in headers] + if missing: + raise ValueError(f"{worksheet.title} 缺少必要列:{'、'.join(missing)}") + for row_no, raw_row in enumerate(values[1:], start=2): + payload = {headers[index]: value for index, value in enumerate(raw_row) if index < len(headers)} + if _opening_row_is_empty(payload) or _opening_row_is_sample(payload): + continue + payload["_row_no"] = row_no + payload["_sheet_name"] = worksheet.title + rows.append(payload) + return rows + + +def _find_opening_warehouse(db: Session, warehouse_type: str, warehouse_name: str, row_no: int) -> Warehouse: + query = select(Warehouse).where(Warehouse.warehouse_type == warehouse_type, Warehouse.status == "ACTIVE") + if warehouse_name: + query = query.where(Warehouse.warehouse_name == warehouse_name) + warehouses = db.scalars(query.order_by(Warehouse.id.asc())).all() + if not warehouses: + raise ValueError(f"第 {row_no} 行仓库不存在或未启用:{warehouse_name or WAREHOUSE_TYPE_LABELS[warehouse_type]}") + if len(warehouses) > 1: + raise ValueError(f"第 {row_no} 行仓库名称不能为空,该类型存在多个仓库") + return warehouses[0] + + +def _find_opening_location(db: Session, warehouse_id: int, location_name: str, row_no: int) -> WarehouseLocation | None: + if not location_name: + return get_location_or_default(db, warehouse_id, None) + location = db.scalar( + select(WarehouseLocation).where( + WarehouseLocation.warehouse_id == warehouse_id, + WarehouseLocation.location_name == location_name, + WarehouseLocation.status == "ACTIVE", + ) + ) + if not location: + raise ValueError(f"第 {row_no} 行库位不存在或未启用:{location_name}") + return location + + +def _create_opening_inventory_lot( + db: Session, + *, + item: Item, + warehouse: Warehouse, + location: WarehouseLocation | None, + qty: Decimal, + weight: Decimal, + unit_cost: Decimal, + lot_no: str | None, + source_summary: str | None, + remark: str | None, + user_id: int | None, +) -> StockLot: + warehouse_type = str(warehouse.warehouse_type or "").upper() + raw_weight_only = warehouse_type in {"RAW", "SCRAP"} + inbound_qty = Decimal("0") if raw_weight_only else qty + if warehouse_type == "RAW": + actual_lot_no = build_inventory_lot_no(db, item.id) + else: + actual_lot_no = lot_no or _build_opening_lot_no(db, item, warehouse) + if db.scalar(select(StockLot).where(StockLot.lot_no == actual_lot_no)): + raise ValueError(f"批次号 {actual_lot_no} 已存在,请更换后重试") + now = datetime.now() + lot = StockLot( + lot_no=actual_lot_no, + parent_lot_id=None, + lot_role="OPENING_STOCK", + material_sub_batch_no=None, + item_id=item.id, + warehouse_id=warehouse.id, + location_id=location.id if location else None, + source_doc_type="OPENING_STOCK", + source_doc_id=0, + source_line_id=None, + source_material_lot_id=None, + source_material_sub_batch_no=None, + source_material_summary=source_summary or "期初导入", + inbound_qty=inbound_qty, + inbound_weight_kg=weight, + remaining_qty=inbound_qty, + remaining_weight_kg=weight, + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=unit_cost, + production_date=now.date(), + expire_date=None, + quality_status="PASS", + status="AVAILABLE", + remark=remark or "期初导入", + ) + db.add(lot) + db.flush() + lot.source_doc_id = lot.id + db.add(lot) + + upsert_stock_balance( + db, + item_id=item.id, + warehouse_id=warehouse.id, + location_id=location.id if location else None, + qty_delta=inbound_qty, + weight_delta=weight, + available_qty_delta=inbound_qty, + available_weight_delta=weight, + unit_cost=unit_cost, + ) + create_inventory_txn( + db, + txn_type="OPENING_IN", + item_id=item.id, + warehouse_id=warehouse.id, + location_id=location.id if location else None, + lot_id=lot.id, + qty_change=inbound_qty, + weight_change=weight, + unit_cost=unit_cost, + source_doc_type="OPENING_STOCK", + source_doc_id=lot.id, + source_line_id=None, + biz_time=now, + operator_user_id=user_id, + remark=remark or "期初导入", + amount_basis="WEIGHT" if raw_weight_only else "QTY", + ) + return lot + + +def import_opening_inventory_workbook(db: Session, warehouse_type: str, content: bytes, user_id: int | None) -> dict[str, object]: + normalized_type = _normalize_warehouse_type(warehouse_type) + if normalized_type not in WAREHOUSE_TYPE_LABELS: + raise ValueError("未知仓库类型,无法导入期初库存") + rows = _read_opening_inventory_rows(content) + if not rows: + raise ValueError("导入文件没有可导入的期初库存明细") + + imported = 0 + for row in rows: + row_no = int(row["_row_no"]) + row_type = _normalize_warehouse_type(_opening_cell_text(row.get("仓库类型"))) + if row_type and row_type != normalized_type: + raise ValueError(f"第 {row_no} 行仓库类型与当前导入类型不一致") + warehouse = _find_opening_warehouse(db, normalized_type, _opening_cell_text(row.get("仓库名称")), row_no) + ensure_warehouses_unlocked(db, [warehouse.id], "期初导入") + item_code = _opening_cell_text(row.get("物料编码")) + item_name = _opening_cell_text(row.get("物料名称")) + if not item_code: + raise ValueError(f"第 {row_no} 行物料编码不能为空") + item = db.scalar(select(Item).where(Item.item_code == item_code).limit(1)) + if not item: + raise ValueError(f"第 {row_no} 行物料不存在:{item_code}") + if item_name and item.item_name != item_name: + raise ValueError(f"第 {row_no} 行物料名称与系统不一致:{item_code} / {item_name}") + _validate_warehouse_item(warehouse, item) + location = _find_opening_location(db, warehouse.id, _opening_cell_text(row.get("库位")), row_no) + qty = _opening_decimal(row.get("数量")) + weight = _opening_decimal(row.get("重量(kg)")) + unit_cost = _opening_decimal(row.get("单价"), places="0.0001") + if normalized_type == "AUX": + if qty <= 0: + raise ValueError(f"第 {row_no} 行辅料库数量必须大于0") + weight = Decimal("0") + elif weight <= 0: + raise ValueError(f"第 {row_no} 行重量(kg)必须大于0") + if normalized_type in {"SEMI", "FINISHED", "RETURN"} and qty <= 0: + raise ValueError(f"第 {row_no} 行{WAREHOUSE_TYPE_LABELS[normalized_type]}数量必须大于0") + if unit_cost < 0: + raise ValueError(f"第 {row_no} 行单价不能小于0") + _create_opening_inventory_lot( + db, + item=item, + warehouse=warehouse, + location=location, + qty=qty, + weight=weight, + unit_cost=unit_cost, + lot_no=_opening_cell_text(row.get("批次号")) or None, + source_summary=_opening_cell_text(row.get("来源说明")) or None, + remark=_opening_cell_text(row.get("备注")) or None, + user_id=user_id, + ) + imported += 1 + + db.commit() + return { + "imported": imported, + "warehouse_type": normalized_type, + "message": f"期初库存导入完成:新增 {imported} 条", + } + + +@router.get("/opening/template") +def export_opening_inventory_template( + warehouse_type: str = Query(...), + db: Session = Depends(get_db), +) -> Response: + normalized_type = _normalize_warehouse_type(warehouse_type) + workbook = build_opening_inventory_template(db, normalized_type) + filename = f"{WAREHOUSE_TYPE_LABELS[normalized_type]}期初导入模版.xlsx" + return _excel_response(workbook, filename) + + +@router.post("/opening/import", response_model=OpeningInventoryImportResult) +async def import_opening_inventory( + warehouse_type: str = Query(...), + file: UploadFile = File(...), + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> OpeningInventoryImportResult: + content = await file.read() + try: + result = import_opening_inventory_workbook(db, warehouse_type, content, user_id=context.user.id) + except ValueError as exc: + db.rollback() + raise HTTPException(status_code=400, detail=str(exc)) from exc + return OpeningInventoryImportResult.model_validate(result) + + +@router.get("/stock-lots", response_model=list[StockLotRead]) +def list_stock_lots( + item_type: str | None = Query(default=None), + limit: int = Query(default=200, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[StockLotRead]: + rows = db.execute(get_stock_lots_query(limit=limit, item_type=item_type)).mappings().all() + return [StockLotRead.model_validate(dict(row)) for row in rows] + + +@router.get("/stock-lots/{lot_id}/purchase-links", response_model=list[StockLotPurchaseLinkRead]) +def list_stock_lot_purchase_links(lot_id: int, db: Session = Depends(get_db)) -> list[StockLotPurchaseLinkRead]: + return [StockLotPurchaseLinkRead.model_validate(row) for row in get_stock_lot_purchase_links(db, lot_id)] + + +@router.get("/stock-lot-options", response_model=list[StockLotOptionRead]) +def list_stock_lot_options( + material_item_id: int | None = Query(default=None, ge=1), + limit: int = Query(default=500, ge=1, le=1000), + db: Session = Depends(get_db), +) -> list[StockLotOptionRead]: + rows = db.execute(get_stock_lot_options_query(material_item_id=material_item_id).limit(limit)).mappings().all() + return [StockLotOptionRead.model_validate(dict(row)) for row in rows] + + +@router.get("/stock-balances", response_model=list[StockBalanceRead]) +def list_stock_balances( + limit: int = Query(default=200, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[StockBalanceRead]: + rows = db.execute(get_stock_balances_query(limit=limit)).mappings().all() + return [StockBalanceRead.model_validate(dict(row)) for row in rows] + + +@router.get("/transactions", response_model=list[InventoryTxnRead]) +def list_inventory_transactions( + limit: int = Query(default=300, ge=1, le=800), + db: Session = Depends(get_db), +) -> list[InventoryTxnRead]: + rows = _apply_production_inbound_archive_links( + db, + [dict(row) for row in db.execute(get_inventory_txn_query(limit=limit)).mappings().all()], + ) + return [InventoryTxnRead.model_validate(row) for row in rows] + + +PRODUCTION_LEDGER_INBOUND_TXN_ORDER = { + "成品入库": 1, + "生产余料入库": 2, + "生产废料入库": 3, +} + + +def _latest_inventory_archive_map(db: Session, document_type: str, business_ids: list[int]) -> dict[int, DocumentArchive]: + normalized_ids = sorted({int(business_id) for business_id in business_ids if business_id}) + if not normalized_ids: + return {} + latest_version_subquery = ( + select( + DocumentArchive.business_id.label("business_id"), + func.max(DocumentArchive.archive_version).label("archive_version"), + ) + .where( + DocumentArchive.document_type == document_type, + DocumentArchive.file_format == FILE_FORMAT_PDF, + DocumentArchive.business_id.in_(normalized_ids), + ) + .group_by(DocumentArchive.business_id) + .subquery() + ) + latest_archive_id_subquery = ( + select( + DocumentArchive.business_id.label("business_id"), + func.max(DocumentArchive.id).label("archive_id"), + ) + .join( + latest_version_subquery, + and_( + latest_version_subquery.c.business_id == DocumentArchive.business_id, + latest_version_subquery.c.archive_version == DocumentArchive.archive_version, + ), + ) + .where( + DocumentArchive.document_type == document_type, + DocumentArchive.file_format == FILE_FORMAT_PDF, + DocumentArchive.business_id.in_(normalized_ids), + ) + .group_by(DocumentArchive.business_id) + .subquery() + ) + archives = db.scalars( + select(DocumentArchive).join( + latest_archive_id_subquery, + latest_archive_id_subquery.c.archive_id == DocumentArchive.id, + ) + ).all() + return {int(archive.business_id): archive for archive in archives} + + +def _production_inbound_group_key(txn: ProductionBatchLedgerTxn) -> tuple[int, object]: + batch_no = production_document_batch_no_from_text(txn.remark) + return ( + int(txn.production_ledger_id), + f"批次:{batch_no}" if batch_no else txn.biz_time, + ) + + +def _apply_production_inbound_archive_links(db: Session, rows: list[dict[str, object]]) -> list[dict[str, object]]: + inventory_txn_ids = [ + int(row["inventory_txn_id"]) + for row in rows + if row.get("archive_document_type") == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT and row.get("inventory_txn_id") + ] + if not inventory_txn_ids: + return rows + + page_ledger_txns = db.scalars( + select(ProductionBatchLedgerTxn).where( + ProductionBatchLedgerTxn.source_doc_type == "库存流水", + ProductionBatchLedgerTxn.source_doc_id.in_(inventory_txn_ids), + ProductionBatchLedgerTxn.txn_type.in_(PRODUCTION_LEDGER_INBOUND_TXN_ORDER.keys()), + ) + ).all() + ledger_ids = sorted({int(txn.production_ledger_id) for txn in page_ledger_txns}) + if not ledger_ids: + return rows + + all_ledger_txns = db.scalars( + select(ProductionBatchLedgerTxn).where( + ProductionBatchLedgerTxn.production_ledger_id.in_(ledger_ids), + ProductionBatchLedgerTxn.source_doc_type == "库存流水", + ProductionBatchLedgerTxn.source_doc_id.is_not(None), + ProductionBatchLedgerTxn.txn_type.in_(PRODUCTION_LEDGER_INBOUND_TXN_ORDER.keys()), + ) + ).all() + grouped_txns: dict[tuple[int, object], list[ProductionBatchLedgerTxn]] = {} + for txn in all_ledger_txns: + grouped_txns.setdefault(_production_inbound_group_key(txn), []).append(txn) + + settlement_business_id_by_inventory_txn_id: dict[int, int] = {} + for group_txns in grouped_txns.values(): + main_txn = sorted( + group_txns, + key=lambda item: ( + PRODUCTION_LEDGER_INBOUND_TXN_ORDER.get(str(item.txn_type or ""), 99), + int(item.source_doc_id or 0), + ), + )[0] + if not main_txn.source_doc_id: + continue + main_business_id = int(main_txn.source_doc_id) + for txn in group_txns: + if txn.source_doc_id: + settlement_business_id_by_inventory_txn_id[int(txn.source_doc_id)] = main_business_id + + if not settlement_business_id_by_inventory_txn_id: + return rows + + archive_map = _latest_inventory_archive_map( + db, + DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + list(set(settlement_business_id_by_inventory_txn_id.values())), + ) + for row in rows: + inventory_txn_id = int(row.get("inventory_txn_id") or 0) + main_business_id = settlement_business_id_by_inventory_txn_id.get(inventory_txn_id) + if not main_business_id: + continue + archive = archive_map.get(main_business_id) + row["archive_document_type"] = DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT + row["archive_business_id"] = main_business_id + row["archive_status"] = archive.status if archive else ARCHIVE_STATUS_MISSING + row["archive_error_message"] = archive.error_message if archive else None + return rows + + +@router.get("/transaction-ledger", response_model=InventoryTxnLedgerResponse) +def list_inventory_transaction_ledger( + warehouse_type: str = Query(...), + warehouse_id: int | None = None, + keyword: str | None = None, + direction: str = "ALL", + txn_type: str | None = None, + item_id: int | None = None, + lot_no: str | None = None, + start_date: date | None = None, + end_date: date | None = None, + page: int = 1, + page_size: int = 10, + sort_key: str = "biz_time", + sort_direction: str = "desc", + db: Session = Depends(get_db), +) -> InventoryTxnLedgerResponse: + normalized_warehouse_type = str(warehouse_type or "").upper() + if normalized_warehouse_type not in WAREHOUSE_LEDGER_TYPES: + raise HTTPException(status_code=400, detail="未知仓库类型,无法查询出入库流水") + normalized_direction = str(direction or "ALL").upper() + if normalized_direction not in {"ALL", "IN", "OUT", "ADJUST"}: + raise HTTPException(status_code=400, detail="流水方向只能是 ALL、IN、OUT、ADJUST") + if page < 1: + raise HTTPException(status_code=400, detail="页码必须大于等于 1") + if page_size < 1 or page_size > 100: + raise HTTPException(status_code=400, detail="每页条数必须在 1 到 100 之间") + if warehouse_id is not None and warehouse_id < 1: + raise HTTPException(status_code=400, detail="仓库 ID 必须大于等于 1") + if item_id is not None and item_id < 1: + raise HTTPException(status_code=400, detail="物料 ID 必须大于等于 1") + + stmt = get_inventory_txn_ledger_query( + warehouse_type=normalized_warehouse_type, + warehouse_id=warehouse_id, + keyword=keyword, + direction=normalized_direction, + txn_type=txn_type, + item_id=item_id, + lot_no=lot_no, + start_date=start_date, + end_date=end_date, + sort_key=sort_key, + sort_direction=sort_direction, + ) + total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0 + summary = summarize_inventory_txn_ledger(db, stmt) + offset = (page - 1) * page_size + rows = _apply_production_inbound_archive_links( + db, + [dict(row) for row in db.execute(stmt.offset(offset).limit(page_size)).mappings().all()], + ) + return InventoryTxnLedgerResponse( + items=rows, + total=total, + summary=summary, + ) + + +WAREHOUSE_LEDGER_EXPORT_HEADERS = [ + ("biz_time", "业务时间"), + ("warehouse_name", "仓库"), + ("location_name", "库位"), + ("direction_label", "方向"), + ("txn_type_label", "流水类型"), + ("txn_no", "流水号"), + ("item_code", "物料/产品编码"), + ("item_name", "物料/产品名称"), + ("lot_no", "库存批次号"), + ("source_material_lot_no", "来源库存批次号"), + ("qty_change", "数量变化"), + ("weight_change_kg", "重量变化(kg)"), + ("unit_cost", "单价"), + ("amount", "金额"), + ("source_doc_label", "来源单据"), + ("operator_name", "经办人"), + ("logistics_waybill_no", "运单号"), + ("logistics_freight_amount", "运费"), + ("logistics_photo_label", "辅助照片"), + ("remark", "备注"), + ("archive_status", "PDF归档状态"), + ("archive_business_id", "PDF归档业务ID"), +] + + +def _ledger_direction_label(value: str | None) -> str: + return { + "IN": "入库", + "OUT": "出库", + "ADJUST": "调整", + "ALL": "全部", + }.get(str(value or "").upper(), "未知方向") + + +def _ledger_source_doc_type_label(value: str | None) -> str: + normalized = str(value or "").strip() + upper_value = normalized.upper() + labels = { + "OPENING_STOCK": "期初入库", + "CUSTOMER_SUPPLIED_IN": "客料入库", + "PURCHASE_RECEIPT": "到货入库单", + "PURCHASE_RETURN": "采购退货", + "WORK_ORDER": "生产工单", + "FG_RECEIPT": "成品入库单", + "DELIVERY": "发货单", + "STOCKTAKE": "盘库", + "SPECIAL_ADJUSTMENT": "特殊调整", + "PRODUCT_ROUTE_OPERATION": "产品工序", + "PRODUCTION_LEDGER": "生产台账", + } + if normalized == "生产台账": + return "生产台账" + if upper_value in labels: + return labels[upper_value] + if not normalized: + return "" + if all(char.isalnum() or char == "_" for char in normalized): + return "系统单据" + return normalized + + +def _ledger_export_value(row: dict[str, object], key: str) -> object: + if key == "direction_label": + return _ledger_direction_label(row.get("direction")) + if key == "txn_type_label": + return _inventory_biz_type_label(row.get("txn_type")) + if key == "source_doc_label": + source_type = _ledger_source_doc_type_label(row.get("source_doc_type")) + source_id = row.get("source_doc_id") + return f"{source_type} #{source_id}" if source_type and source_id is not None else source_type + if key == "logistics_photo_label": + return "已上传" if row.get("logistics_photo_url") else "" + value = row.get(key) + return "" if value is None else value + + +@router.get("/transaction-ledger/export") +def export_inventory_transaction_ledger( + warehouse_type: str = Query(...), + warehouse_id: int | None = None, + keyword: str | None = None, + direction: str = "ALL", + txn_type: str | None = None, + item_id: int | None = None, + lot_no: str | None = None, + start_date: date | None = None, + end_date: date | None = None, + sort_key: str = "biz_time", + sort_direction: str = "desc", + db: Session = Depends(get_db), +) -> Response: + normalized_warehouse_type = str(warehouse_type or "").upper() + if normalized_warehouse_type not in WAREHOUSE_LEDGER_TYPES: + raise HTTPException(status_code=400, detail="未知仓库类型,无法导出出入库流水") + normalized_direction = str(direction or "ALL").upper() + if normalized_direction not in {"ALL", "IN", "OUT", "ADJUST"}: + raise HTTPException(status_code=400, detail="流水方向只能是 ALL、IN、OUT、ADJUST") + if warehouse_id is not None and warehouse_id < 1: + raise HTTPException(status_code=400, detail="仓库 ID 必须大于等于 1") + if item_id is not None and item_id < 1: + raise HTTPException(status_code=400, detail="物料 ID 必须大于等于 1") + + stmt = get_inventory_txn_ledger_query( + warehouse_type=normalized_warehouse_type, + warehouse_id=warehouse_id, + keyword=keyword, + direction=normalized_direction, + txn_type=txn_type, + item_id=item_id, + lot_no=lot_no, + start_date=start_date, + end_date=end_date, + sort_key=sort_key, + sort_direction=sort_direction, + ) + rows = _apply_production_inbound_archive_links( + db, + [dict(row) for row in db.execute(stmt).mappings().all()], + ) + + workbook = Workbook() + sheet = workbook.active + sheet.title = "出入库流水" + sheet.append([label for _, label in WAREHOUSE_LEDGER_EXPORT_HEADERS]) + for row in rows: + sheet.append([_ledger_export_value(row, key) for key, _ in WAREHOUSE_LEDGER_EXPORT_HEADERS]) + sheet.freeze_panes = "A2" + sheet.auto_filter.ref = sheet.dimensions + for column_cells in sheet.columns: + header = str(column_cells[0].value or "") + max_length = max(len(str(cell.value or "")) for cell in column_cells) + sheet.column_dimensions[column_cells[0].column_letter].width = min(max(max_length + 2, len(header) + 2), 42) + + date_range = f"{start_date or '全部'}_{end_date or '全部'}" + warehouse_label = WAREHOUSE_TYPE_LABELS.get(normalized_warehouse_type, normalized_warehouse_type) + return _excel_response(workbook, f"{warehouse_label}出入库流水_{date_range}.xlsx") + + +@router.post("/logistics-photos") +async def upload_logistics_photo(file: UploadFile = File(...)) -> dict[str, str]: + suffix = Path(file.filename or "").suffix.lower() + content_type = (file.content_type or "").lower() + if suffix not in LOGISTICS_ALLOWED_SUFFIXES or not content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="辅助照片仅支持 jpg、png、webp、heic 图片") + + content = await file.read() + if len(content) <= 0: + raise HTTPException(status_code=400, detail="上传的辅助照片为空") + if len(content) > LOGISTICS_MAX_BYTES: + raise HTTPException(status_code=400, detail="辅助照片不能超过10MB") + + LOGISTICS_UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + stored_name = f"{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid4().hex}{suffix}" + target = LOGISTICS_UPLOAD_DIR / stored_name + target.write_bytes(content) + return {"filename": stored_name, "url": f"/inventory/logistics-photos/{stored_name}"} + + +@router.get("/logistics-photos/{filename}") +def get_logistics_photo(filename: str) -> FileResponse: + if Path(filename).name != filename: + raise HTTPException(status_code=400, detail="照片路径不合法") + target = LOGISTICS_UPLOAD_DIR / filename + if not target.exists() or not target.is_file(): + raise HTTPException(status_code=404, detail="辅助照片不存在") + return FileResponse(target) + + +@router.get("/settlements", response_model=list[InventorySettlementRead]) +def list_inventory_settlements( + limit: int = Query(default=200, ge=1, le=800), + db: Session = Depends(get_db), +) -> list[InventorySettlementRead]: + return [InventorySettlementRead.model_validate(row) for row in get_inventory_settlements(db, limit=limit)] + + +@router.post("/special-adjustments", response_model=SpecialWarehouseAdjustmentRead) +def create_special_warehouse_adjustment( + payload: SpecialWarehouseAdjustmentCreate, + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> SpecialWarehouseAdjustmentRead: + try: + adjustment = create_special_inventory_adjustment(db, payload, user_id=context.user.id) + except ValueError as exc: + db.rollback() + raise HTTPException(status_code=400, detail=str(exc)) from exc + archive_business_ids = [ + int(inventory_txn_id) + for inventory_txn_id in db.scalars( + select(SpecialWarehouseAdjustmentLine.inventory_txn_id) + .where( + SpecialWarehouseAdjustmentLine.adjustment_id == adjustment.id, + SpecialWarehouseAdjustmentLine.inventory_txn_id.is_not(None), + ) + .order_by(SpecialWarehouseAdjustmentLine.line_no.asc()) + ).all() + ] + archive_fields = _generate_inventory_archives_for_txns( + db, + document_type=DOCUMENT_TYPE_WAREHOUSE_OPERATION, + business_ids=archive_business_ids, + created_by=context.user.id, + ) + result = SpecialWarehouseAdjustmentRead.model_validate(adjustment) + result.archive_status = archive_fields["archive_status"] + result.archive_business_id = archive_fields["archive_business_id"] + result.archive_document_type = archive_fields["archive_document_type"] + result.archive_error_message = archive_fields["archive_error_message"] + return result + + +@router.post("/customer-supplied-inbound", response_model=StockLotRead) +def create_customer_supplied_inbound( + payload: CustomerSuppliedInboundCreate, + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> StockLotRead: + material = db.get(Item, payload.material_item_id) + if not material or material.item_type != "RAW_MATERIAL": + raise HTTPException(status_code=404, detail="客供料入库只能选择原材料") + warehouse = db.get(Warehouse, payload.warehouse_id) + if not warehouse: + raise HTTPException(status_code=404, detail="仓库不存在") + ensure_warehouses_unlocked(db, [payload.warehouse_id], "客料入库") + warehouse_type = str(warehouse.warehouse_type or "").upper() + biz_type = "CUSTOMER_SUPPLIED" + if biz_type not in _ALLOWED_INBOUND_BY_WAREHOUSE_TYPE.get(warehouse_type, set()): + raise HTTPException( + status_code=400, + detail=f"{_warehouse_type_label(warehouse_type)}不允许执行该入库业务", + ) + _validate_warehouse_item(warehouse, material) + + inbound_weight = to_decimal(payload.inbound_weight_kg) + inbound_qty = Decimal("0") + if inbound_weight <= 0: + raise HTTPException(status_code=400, detail="客供料入库重量必须大于0") + + location = get_location_or_default(db, payload.warehouse_id, payload.location_id) + now = datetime.now() + lot = StockLot( + lot_no=_build_customer_supplied_lot_no(db, material), + parent_lot_id=None, + lot_role="CUSTOMER_SUPPLIED_RAW", + material_sub_batch_no=None, + item_id=payload.material_item_id, + warehouse_id=payload.warehouse_id, + location_id=location.id if location else None, + source_doc_type="CUSTOMER_SUPPLIED_IN", + source_doc_id=0, + source_line_id=None, + source_material_lot_id=None, + source_material_sub_batch_no=None, + source_material_summary=payload.provider_name or "客供料", + inbound_qty=inbound_qty, + inbound_weight_kg=inbound_weight, + remaining_qty=inbound_qty, + remaining_weight_kg=inbound_weight, + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("0"), + production_date=now.date(), + expire_date=None, + quality_status="PASS", + status="AVAILABLE", + remark=payload.remark or f"客供料入库:{payload.provider_name or '未填写提供方'}", + ) + db.add(lot) + db.flush() + lot.source_doc_id = lot.id + db.add(lot) + + upsert_stock_balance( + db, + item_id=payload.material_item_id, + warehouse_id=payload.warehouse_id, + location_id=location.id if location else None, + qty_delta=Decimal("0"), + weight_delta=inbound_weight, + available_qty_delta=Decimal("0"), + available_weight_delta=inbound_weight, + unit_cost=Decimal("0"), + ) + create_inventory_txn( + db, + txn_type="CUSTOMER_SUPPLIED_IN", + item_id=payload.material_item_id, + warehouse_id=payload.warehouse_id, + location_id=location.id if location else None, + lot_id=lot.id, + qty_change=Decimal("0"), + weight_change=inbound_weight, + unit_cost=Decimal("0"), + source_doc_type="CUSTOMER_SUPPLIED_IN", + source_doc_id=lot.id, + source_line_id=None, + biz_time=now, + operator_user_id=context.user.id, + remark=lot.remark, + amount_basis="WEIGHT", + ) + db.commit() + + row = db.execute(get_stock_lots_query(limit=1).where(StockLot.id == lot.id)).mappings().first() + if not row: + raise HTTPException(status_code=500, detail="客供料入库后读取失败") + return StockLotRead.model_validate(dict(row)) + + +# ---------- unified lot_no builders ---------- + +def _build_opening_lot_no(db: Session, item: Item, warehouse: Warehouse) -> str: + date_part = datetime.now().strftime("%Y%m%d") + item_part = _code_token(item.item_code or item.item_name, f"IT{item.id:04d}") + wh_part = _code_token(warehouse.warehouse_name or "", "WH")[:4] + prefix = f"QC-{wh_part}-{date_part}-{item_part}" + count = db.scalar(select(func.count(StockLot.id)).where(StockLot.lot_no.like(f"{prefix}-%"))) or 0 + return f"{prefix}-{count + 1:03d}" + + +def _build_surplus_lot_no(db: Session, item: Item, warehouse: Warehouse, prefix_tag: str) -> str: + date_part = datetime.now().strftime("%Y%m%d") + item_part = _code_token(item.item_code or item.item_name, f"IT{item.id:04d}") + prefix = f"{prefix_tag}-{date_part}-{item_part}" + count = db.scalar(select(func.count(StockLot.id)).where(StockLot.lot_no.like(f"{prefix}-%"))) or 0 + return f"{prefix}-{count + 1:03d}" + + +def _build_wip_lot_no(db: Session, item: Item, warehouse: Warehouse) -> str: + date_part = datetime.now().strftime("%Y%m%d") + item_part = _code_token(item.item_code or item.item_name, f"IT{item.id:04d}") + prefix = f"WIP-{date_part}-{item_part}" + count = db.scalar(select(func.count(StockLot.id)).where(StockLot.lot_no.like(f"{prefix}-%"))) or 0 + return f"{prefix}-{count + 1:03d}" + + +def _build_finished_lot_no(db: Session, item: Item, warehouse: Warehouse) -> str: + date_part = datetime.now().strftime("%Y%m%d") + item_part = _code_token(item.item_code or item.item_name, f"IT{item.id:04d}") + prefix = f"FG-{date_part}-{item_part}" + count = db.scalar(select(func.count(StockLot.id)).where(StockLot.lot_no.like(f"{prefix}-%"))) or 0 + return f"{prefix}-{count + 1:03d}" + + +# ---------- inbound biz_type -> (lot_role, txn_type, source_doc_type, default_cost) ---------- + +_INBOUND_BIZ_MAP = { + "OPENING": ("OPENING_STOCK", "OPENING_IN", "OPENING_STOCK", Decimal("0")), + "CUSTOMER_SUPPLIED": ("CUSTOMER_SUPPLIED_RAW","CUSTOMER_SUPPLIED_IN","CUSTOMER_SUPPLIED_IN", Decimal("0")), + "PRODUCTION_SURPLUS": ("PRODUCTION_SURPLUS", "PRODUCTION_SURPLUS_IN","PRODUCTION_SURPLUS_IN", None), + "OUTSOURCING_SURPLUS": ("OUTSOURCING_SURPLUS", "OUTSOURCING_SURPLUS_IN","OUTSOURCING_SURPLUS_IN", None), + "PRODUCTION_SCRAP_IN": ("PRODUCTION_SCRAP", "PRODUCTION_SCRAP_IN", "PRODUCTION_SCRAP_IN", None), + "REWORK_SCRAP_IN": ("REWORK_SCRAP", "REWORK_SCRAP_IN", "REWORK_SCRAP_IN", None), + "OUTSOURCING_SCRAP_IN":("OUTSOURCING_SCRAP", "OUTSOURCING_SCRAP_IN","OUTSOURCING_SCRAP_IN", None), + "WIP_IN": ("WIP_STAGING", "WIP_IN", "WIP_IN", None), + "OUTSOURCING_IN": ("OUTSOURCING_IN", "OUTSOURCING_IN", "OUTSOURCING_IN", None), + "PRODUCTION_FINISHED": ("INBOUND_FINISHED", "PRODUCTION_FINISHED_IN","PRODUCTION_FINISHED_IN", None), +} + +_ALLOWED_INBOUND_BY_WAREHOUSE_TYPE = { + "RAW": {"OPENING", "CUSTOMER_SUPPLIED", "PRODUCTION_SURPLUS", "OUTSOURCING_SURPLUS"}, + "SEMI": {"WIP_IN", "OPENING", "OUTSOURCING_IN"}, + "FINISHED": {"PRODUCTION_FINISHED", "OUTSOURCING_IN", "OPENING"}, + "AUX": {"OPENING"}, + "SCRAP": {"PRODUCTION_SCRAP_IN", "REWORK_SCRAP_IN", "OPENING", "OUTSOURCING_SCRAP_IN"}, +} + +_LOGISTICS_REQUIRED_INBOUND_BIZ_TYPES = { + "CUSTOMER_SUPPLIED", + "OUTSOURCING_SURPLUS", + "OUTSOURCING_IN", + "OUTSOURCING_SCRAP_IN", +} + +_SOURCE_LOT_RETURN_INBOUND_BIZ_TYPES = {"PRODUCTION_SURPLUS", "OUTSOURCING_SURPLUS"} +PRODUCTION_ARCHIVE_INBOUND_BIZ_TYPES = {"PRODUCTION_FINISHED", "PRODUCTION_SURPLUS", "PRODUCTION_SCRAP_IN"} +_PRODUCTION_WORK_ORDER_INBOUND_SETTLE_MARKERS = { + "PRODUCTION_SURPLUS": "生产余料已结单", + "PRODUCTION_SCRAP_IN": "生产废料已结单", +} +_PRODUCTION_WORK_ORDER_REQUIRED_SETTLE_MARKERS = ( + "生产余料已结单", + "生产废料已结单", +) +_PRODUCTION_FINISHED_SETTLE_MARKER = "生产入库已结单" + + +def _require_work_order_inbound_source(payload: WarehouseInboundCreate, label: str) -> int: + if str(payload.source_doc_type or "").upper() != "WORK_ORDER" or not payload.source_doc_id: + raise HTTPException(status_code=400, detail=f"{label}必须选择生产工单") + return int(payload.source_doc_id) + + +def _is_production_ledger_source(payload: WarehouseInboundCreate) -> bool: + source_type = str(payload.source_doc_type or "").strip() + return source_type.upper() == "PRODUCTION_LEDGER" or source_type == "生产台账" + + +def _require_production_ledger_inbound_source(payload: WarehouseInboundCreate, label: str) -> int: + if not _is_production_ledger_source(payload) or not payload.source_doc_id: + raise HTTPException(status_code=400, detail=f"{label}必须选择生产台账") + return int(payload.source_doc_id) + + +def _should_archive_production_inbound( + biz_type: str, + *, + production_ledger_id: int | None, + production_work_order_id: int | None, +) -> bool: + return ( + biz_type in PRODUCTION_ARCHIVE_INBOUND_BIZ_TYPES + and (production_ledger_id is not None or production_work_order_id is not None) + ) + + +def _get_active_work_order_for_inbound(db: Session, work_order_id: int, label: str, biz_type: str | None = None) -> WorkOrder: + work_order = db.get(WorkOrder, work_order_id) + if not work_order: + raise HTTPException(status_code=404, detail="生产工单不存在") + marker = _PRODUCTION_WORK_ORDER_INBOUND_SETTLE_MARKERS.get(str(biz_type or "").upper()) + if marker and marker in set(_work_order_remark_parts(work_order)): + raise HTTPException(status_code=400, detail=f"{label}选择的生产工单已完成本项结单,不能继续使用") + if str(work_order.status or "").upper() == "SETTLED": + if marker and _PRODUCTION_FINISHED_SETTLE_MARKER in set(_work_order_remark_parts(work_order)): + return work_order + raise HTTPException(status_code=400, detail=f"{label}选择的生产工单已结单,不能继续使用") + return work_order + + +def _append_work_order_settle_remark(work_order: WorkOrder, settle_remark: str | None) -> None: + remark = (settle_remark or "").strip() + if not remark: + return + existing = (work_order.remark or "").strip() + addition = f"结单说明:{remark}" + next_remark = f"{existing};{addition}" if existing else addition + work_order.remark = next_remark[-255:] + + +def _work_order_remark_parts(work_order: WorkOrder) -> list[str]: + return [part.strip() for part in str(work_order.remark or "").replace(";", ";").split(";") if part.strip()] + + +def _set_work_order_remark_parts(work_order: WorkOrder, parts: list[str]) -> None: + unique_parts: list[str] = [] + for part in parts: + normalized = str(part or "").strip() + if normalized and normalized not in unique_parts: + unique_parts.append(normalized) + marker_parts = [marker for marker in _PRODUCTION_WORK_ORDER_REQUIRED_SETTLE_MARKERS if marker in unique_parts] + other_parts = [part for part in unique_parts if part not in _PRODUCTION_WORK_ORDER_REQUIRED_SETTLE_MARKERS] + next_remark = ";".join(marker_parts + other_parts) + if len(next_remark) <= 255: + work_order.remark = next_remark + return + marker_text = ";".join(marker_parts) + remaining_length = max(255 - len(marker_text) - (1 if marker_text else 0), 0) + other_text = ";".join(other_parts) + if marker_text and remaining_length > 0 and other_text: + work_order.remark = f"{marker_text};{other_text[-remaining_length:]}" + else: + work_order.remark = marker_text[:255] if marker_text else other_text[-255:] + + +def _mark_production_work_order_inbound_settled( + work_order: WorkOrder, + *, + biz_type: str, + settle_remark: str | None, +) -> None: + marker = _PRODUCTION_WORK_ORDER_INBOUND_SETTLE_MARKERS.get(biz_type) + if not marker: + _append_work_order_settle_remark(work_order, settle_remark) + return + parts = _work_order_remark_parts(work_order) + if marker not in parts: + parts.append(marker) + remark = (settle_remark or "").strip() + if remark: + parts.append(f"{marker}说明:{remark}") + _set_work_order_remark_parts(work_order, parts) + + +def _production_work_order_inbound_settlements_complete(work_order: WorkOrder) -> bool: + parts = set(_work_order_remark_parts(work_order)) + return all(marker in parts for marker in _PRODUCTION_WORK_ORDER_REQUIRED_SETTLE_MARKERS) + + +def _apply_production_work_order_inbound_settlement( + work_order: WorkOrder, + *, + biz_type: str, + settle_remark: str | None, +) -> None: + _mark_production_work_order_inbound_settled(work_order, biz_type=biz_type, settle_remark=settle_remark) + if _production_work_order_inbound_settlements_complete(work_order): + work_order.status = "SETTLED" + work_order.actual_end_time = work_order.actual_end_time or datetime.now() + work_order.updated_at = datetime.now() + + +def _validate_surplus_settle_request( + payload: WarehouseInboundCreate, + *, + inbound_weight: Decimal, + expected_return_weight: Decimal, + returned_weight_before: Decimal, +) -> None: + final_return_weight = returned_weight_before + inbound_weight + lower_limit = expected_return_weight * Decimal("0.85") + upper_limit = expected_return_weight * INBOUND_TOLERANCE_MULTIPLIER + if lower_limit <= final_return_weight <= upper_limit: + return + if not (payload.settle_remark or "").strip(): + raise HTTPException(status_code=400, detail="该工单余料结单归还重量超出标准归还重量上下15%,请填写结单说明(余料结单说明)") + + +def _validate_surplus_non_settle_request( + *, + inbound_weight: Decimal, + expected_return_weight: Decimal, + returned_weight_before: Decimal, +) -> None: + if inbound_weight <= 0: + raise HTTPException(status_code=400, detail="生产余料入库重量必须大于0") + upper_limit = to_decimal(max(expected_return_weight, Decimal("0")) * INBOUND_TOLERANCE_MULTIPLIER, "0.001") + final_return_weight = returned_weight_before + inbound_weight + if final_return_weight > upper_limit: + raise HTTPException(status_code=400, detail=f"累计归还重量超过允许入库上限 {upper_limit}") + + +def _validate_production_scrap_settle_request( + payload: WarehouseInboundCreate, + *, + inbound_weight: Decimal, + expected_scrap_weight: Decimal, + scrap_inbound_before: Decimal, +) -> None: + final_scrap_weight = scrap_inbound_before + inbound_weight + lower_limit = expected_scrap_weight * Decimal("0.85") + upper_limit = expected_scrap_weight * INBOUND_TOLERANCE_MULTIPLIER + if lower_limit <= final_scrap_weight <= upper_limit: + return + if not (payload.settle_remark or "").strip(): + raise HTTPException(status_code=400, detail="该工单废料结单入库废料重量超出标准废料重量上下15%,请填写结单说明(废料结单说明)") + + +def _validate_production_scrap_non_settle_request( + *, + inbound_weight: Decimal, + expected_scrap_weight: Decimal, + scrap_inbound_before: Decimal, +) -> None: + if inbound_weight <= 0: + raise HTTPException(status_code=400, detail="生产废料入库重量必须大于0") + upper_limit = to_decimal(max(expected_scrap_weight, Decimal("0")) * INBOUND_TOLERANCE_MULTIPLIER, "0.001") + final_scrap_weight = scrap_inbound_before + inbound_weight + if final_scrap_weight > upper_limit: + raise HTTPException(status_code=400, detail=f"累计入库废料重量超过允许入库上限 {upper_limit}") + + +def _get_rework_work_order_for_scrap_inbound(db: Session, payload: WarehouseInboundCreate) -> WorkOrder: + if str(payload.source_doc_type or "").upper() != "WORK_ORDER" or not payload.source_doc_id: + raise HTTPException(status_code=400, detail="返工废料入库请选择返工工单") + work_order = db.get(WorkOrder, int(payload.source_doc_id)) + if not work_order: + raise HTTPException(status_code=404, detail="返工工单不存在") + if str(work_order.work_order_type or "").upper() != "REWORK": + raise HTTPException(status_code=400, detail="返工废料入库只能选择返工工单") + if str(work_order.status or "").upper() == "SETTLED": + raise HTTPException(status_code=400, detail="该返工工单已结单,不能继续入废料库") + return work_order + + +def _rework_report_scrap_qty(db: Session, work_order_id: int) -> Decimal: + return to_decimal( + db.scalar( + select(func.coalesce(func.sum(OperationReport.scrap_qty), 0)).where(OperationReport.work_order_id == work_order_id) + ), + "0.000001", + ) + + +def _rework_scrap_inbound_qty(db: Session, work_order_id: int) -> Decimal: + return to_decimal( + db.scalar( + select(func.coalesce(func.sum(StockLot.inbound_qty), 0)).where( + StockLot.lot_role == "REWORK_SCRAP", + StockLot.source_doc_type == "WORK_ORDER", + StockLot.source_doc_id == work_order_id, + ) + ), + "0.000001", + ) + + +def _rework_finished_inbound_qty(db: Session, work_order_id: int) -> Decimal: + return to_decimal( + db.scalar( + select(func.coalesce(func.sum(InventoryTxn.qty_change), 0)).where( + InventoryTxn.txn_type == "REWORK_FINISHED_IN", + InventoryTxn.source_doc_type == "WORK_ORDER", + InventoryTxn.source_doc_id == work_order_id, + ) + ), + "0.000001", + ) + + +def _settle_rework_work_order_if_ready( + db: Session, + work_order: WorkOrder, + *, + incoming_scrap_qty: Decimal = Decimal("0"), + settle_remark: str | None = None, +) -> None: + planned_qty = to_decimal(work_order.planned_qty) + processed_qty = _rework_finished_inbound_qty(db, work_order.id) + _rework_scrap_inbound_qty(db, work_order.id) + incoming_scrap_qty + if processed_qty < planned_qty: + raise HTTPException( + status_code=400, + detail=f"返工工单尚未闭环:返工出库 {planned_qty} 件,已回库/入废料 {processed_qty} 件", + ) + work_order.status = "SETTLED" + work_order.actual_end_time = work_order.actual_end_time or datetime.now() + parts = [part.strip() for part in str(work_order.remark or "").replace(";", ";").split(";") if part.strip()] + if "返工工单已结单" not in parts: + parts.append("返工工单已结单") + remark = str(settle_remark or "").strip() + if remark: + parts.append(f"返工结单说明:{remark}") + work_order.remark = ";".join(dict.fromkeys(parts))[-255:] + db.add(work_order) + + +@router.post("/inbound", response_model=StockLotRead) +def create_warehouse_inbound( + payload: WarehouseInboundCreate, + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> StockLotRead: + biz_type = (payload.biz_type or "").upper() + if biz_type not in _INBOUND_BIZ_MAP: + raise HTTPException(status_code=400, detail=f"未知的入库业务类型: {biz_type}") + + item = db.get(Item, payload.item_id) + if not item: + raise HTTPException(status_code=404, detail="物料不存在") + + warehouse = db.get(Warehouse, payload.warehouse_id) + if not warehouse: + raise HTTPException(status_code=404, detail="仓库不存在") + ensure_warehouses_unlocked(db, [payload.warehouse_id], "入库") + warehouse_type = str(warehouse.warehouse_type or "").upper() + if biz_type not in _ALLOWED_INBOUND_BY_WAREHOUSE_TYPE.get(warehouse_type, set()): + raise HTTPException( + status_code=400, + detail=f"{_warehouse_type_label(warehouse_type)}不允许执行该入库业务", + ) + _validate_warehouse_item(warehouse, item) + logistics_waybill_no, logistics_freight_amount, logistics_photo_url = normalize_logistics_fields( + payload.waybill_no, + payload.freight_amount, + required=biz_type in _LOGISTICS_REQUIRED_INBOUND_BIZ_TYPES, + order_photo_url=payload.order_photo_url, + ) + + inbound_weight = to_decimal(payload.inbound_weight_kg) + inbound_qty = to_decimal(payload.inbound_qty) + is_semi_source_lot_trace_inbound = warehouse_type == "SEMI" and biz_type in {"WIP_IN", "OUTSOURCING_IN"} + is_finished_outsourcing_quantity_inbound = warehouse_type == "FINISHED" and biz_type == "OUTSOURCING_IN" + is_finished_production_quantity_inbound = ( + warehouse_type == "FINISHED" + and biz_type == "PRODUCTION_FINISHED" + and (_is_production_ledger_source(payload) or str(payload.source_doc_type or "").upper() == "WORK_ORDER") + ) + is_outsourcing_scrap_source_lot_trace_inbound = warehouse_type == "SCRAP" and biz_type == "OUTSOURCING_SCRAP_IN" + is_rework_scrap_inbound = warehouse_type == "SCRAP" and biz_type == "REWORK_SCRAP_IN" + is_product_raw_source_lot_trace_inbound = ( + is_semi_source_lot_trace_inbound + or is_finished_outsourcing_quantity_inbound + or is_outsourcing_scrap_source_lot_trace_inbound + ) + is_aux_quantity_inbound = warehouse_type == "AUX" + semi_inbound_measure_basis: str | None = None + if inbound_weight < 0 or inbound_qty < 0: + raise HTTPException(status_code=400, detail="入库重量和入库数量不能小于0") + if is_semi_source_lot_trace_inbound: + semi_inbound_measure_basis = _semi_measure_basis_from_values(inbound_weight, inbound_qty, "入库") + elif is_finished_outsourcing_quantity_inbound or is_finished_production_quantity_inbound: + if inbound_qty <= 0: + label = "成品生产入库" if is_finished_production_quantity_inbound else "成品委外入库" + raise HTTPException(status_code=400, detail=f"{label}数量必须大于0") + if is_finished_production_quantity_inbound and inbound_weight <= 0: + inbound_weight = inbound_qty * to_decimal(item.unit_weight_kg) + if is_finished_outsourcing_quantity_inbound: + inbound_weight = Decimal("0") + elif is_aux_quantity_inbound: + if inbound_qty <= 0: + raise HTTPException(status_code=400, detail="辅料入库数量必须大于0") + inbound_weight = Decimal("0") + elif is_rework_scrap_inbound: + if inbound_qty <= 0: + raise HTTPException(status_code=400, detail="返工废料入库数量必须大于0") + if inbound_weight <= 0: + inbound_weight = inbound_qty * to_decimal(item.unit_weight_kg) + if inbound_weight <= 0: + raise HTTPException(status_code=400, detail="返工废料入库重量必须大于0") + else: + if inbound_weight <= 0: + raise HTTPException(status_code=400, detail="入库重量必须大于0") + inbound_qty = Decimal("0") + + lot_role, txn_type, source_doc_type, default_cost = _INBOUND_BIZ_MAP[biz_type] + production_ledger_id: int | None = None + production_work_order_id: int | None = None + production_work_order_material = None + production_work_order: WorkOrder | None = None + rework_scrap_work_order: WorkOrder | None = None + settle_production_work_order = False + if biz_type == "PRODUCTION_SURPLUS": + if _is_production_ledger_source(payload): + production_ledger_id = _require_production_ledger_inbound_source(payload, "生产余料入库") + else: + production_work_order_id = _require_work_order_inbound_source(payload, "生产余料入库") + production_work_order = _get_active_work_order_for_inbound(db, production_work_order_id, "生产余料入库", biz_type) + snapshot = get_work_order_limit_snapshot(db, production_work_order_id) + if payload.settle_work_order: + expected_return_weight = to_decimal(snapshot.issued_weight_kg) - to_decimal(snapshot.used_material_weight_kg) + _validate_surplus_settle_request( + payload, + inbound_weight=inbound_weight, + expected_return_weight=max(expected_return_weight, Decimal("0")), + returned_weight_before=to_decimal(snapshot.returned_material_weight_kg), + ) + settle_production_work_order = True + else: + expected_return_weight = to_decimal(snapshot.issued_weight_kg) - to_decimal(snapshot.used_material_weight_kg) + _validate_surplus_non_settle_request( + inbound_weight=inbound_weight, + expected_return_weight=max(expected_return_weight, Decimal("0")), + returned_weight_before=to_decimal(snapshot.returned_material_weight_kg), + ) + if payload.source_lot_id: + production_work_order_material = validate_work_order_source_lot( + db, + production_work_order_id, + int(payload.source_lot_id), + payload.item_id, + ) + elif biz_type == "PRODUCTION_SCRAP_IN": + if _is_production_ledger_source(payload): + production_ledger_id = _require_production_ledger_inbound_source(payload, "生产废料入库") + else: + production_work_order_id = _require_work_order_inbound_source(payload, "生产废料入库") + production_work_order = _get_active_work_order_for_inbound(db, production_work_order_id, "生产废料入库", biz_type) + snapshot = get_work_order_limit_snapshot(db, production_work_order_id) + expected_scrap_weight = to_decimal(snapshot.standard_scrap_weight_kg) + if payload.settle_work_order: + _validate_production_scrap_settle_request( + payload, + inbound_weight=inbound_weight, + expected_scrap_weight=expected_scrap_weight, + scrap_inbound_before=to_decimal(snapshot.scrap_inbound_weight_kg), + ) + settle_production_work_order = True + else: + _validate_production_scrap_non_settle_request( + inbound_weight=inbound_weight, + expected_scrap_weight=expected_scrap_weight, + scrap_inbound_before=to_decimal(snapshot.scrap_inbound_weight_kg), + ) + elif biz_type == "PRODUCTION_FINISHED": + if _is_production_ledger_source(payload): + production_ledger_id = _require_production_ledger_inbound_source(payload, "生产入库") + production_ledger = db.get(ProductionBatchLedger, production_ledger_id) + if not production_ledger: + raise HTTPException(status_code=404, detail="生产台账不存在") + if int(production_ledger.product_item_id) != int(payload.item_id): + raise HTTPException(status_code=400, detail="生产台账产品与入库产品不一致") + elif str(payload.source_doc_type or "").upper() == "WORK_ORDER" and payload.source_doc_id: + production_work_order_id = _require_work_order_inbound_source(payload, "生产入库") + production_work_order = _get_active_work_order_for_inbound(db, production_work_order_id, "生产入库", biz_type) + if int(production_work_order.product_item_id) != int(payload.item_id): + raise HTTPException(status_code=400, detail="生产工单产品与入库产品不一致") + elif biz_type == "REWORK_SCRAP_IN": + rework_scrap_work_order = _get_rework_work_order_for_scrap_inbound(db, payload) + reported_scrap_qty = _rework_report_scrap_qty(db, rework_scrap_work_order.id) + already_scrap_inbound_qty = _rework_scrap_inbound_qty(db, rework_scrap_work_order.id) + max_rework_scrap_qty = reported_scrap_qty - already_scrap_inbound_qty + if inbound_qty > max_rework_scrap_qty: + raise HTTPException(status_code=400, detail=f"返工废料入库数量不能超过当前可入库废品数量 {max_rework_scrap_qty}") + if payload.settle_work_order: + _settle_rework_work_order_if_ready( + db, + rework_scrap_work_order, + incoming_scrap_qty=inbound_qty, + settle_remark=payload.settle_remark, + ) + + if biz_type in _SOURCE_LOT_RETURN_INBOUND_BIZ_TYPES: + if not payload.source_lot_id: + raise HTTPException(status_code=400, detail="余料入库请选择来源库存批次号") + source_lot = db.get(StockLot, payload.source_lot_id) + if not source_lot: + raise HTTPException(status_code=404, detail="来源库存批次号不存在") + if source_lot.item_id != payload.item_id: + raise HTTPException(status_code=400, detail="来源库存批次号与入库物料不一致") + if source_lot.warehouse_id != payload.warehouse_id: + raise HTTPException(status_code=400, detail="来源库存批次号不属于当前仓库") + if source_lot.quality_status != "PASS": + raise HTTPException(status_code=400, detail="来源库存批次号未质检放行,不能归还余料") + + lot_location_id = source_lot.location_id + if lot_location_id is None: + lot_location = get_location_or_default(db, payload.warehouse_id, payload.location_id) + lot_location_id = lot_location.id if lot_location else None + source_lot.location_id = lot_location_id + + unit_cost = to_decimal(source_lot.unit_cost, "0.0001") + source_lot.remaining_weight_kg = to_decimal(source_lot.remaining_weight_kg) + inbound_weight + if source_lot.status == "DEPLETED" and to_decimal(source_lot.remaining_weight_kg) > 0: + source_lot.status = "AVAILABLE" + source_lot.updated_at = datetime.now() + db.add(source_lot) + if production_work_order_material is not None: + production_work_order_material.returned_weight_kg = ( + to_decimal(production_work_order_material.returned_weight_kg) + inbound_weight + ) + production_work_order_material.updated_at = datetime.now() + db.add(production_work_order_material) + if settle_production_work_order and production_work_order is not None: + _apply_production_work_order_inbound_settlement( + production_work_order, + biz_type=biz_type, + settle_remark=payload.settle_remark, + ) + db.add(production_work_order) + + upsert_stock_balance( + db, + item_id=payload.item_id, + warehouse_id=payload.warehouse_id, + location_id=lot_location_id, + qty_delta=Decimal("0"), + weight_delta=inbound_weight, + available_qty_delta=Decimal("0"), + available_weight_delta=inbound_weight, + unit_cost=unit_cost, + ) + return_remark = payload.remark or _inventory_biz_type_label(biz_type) + if payload.provider_name: + return_remark = f"来源/提供方:{payload.provider_name};{return_remark}" + + return_txn = create_inventory_txn( + db, + txn_type=txn_type, + item_id=payload.item_id, + warehouse_id=payload.warehouse_id, + location_id=lot_location_id, + lot_id=source_lot.id, + qty_change=Decimal("0"), + weight_change=inbound_weight, + unit_cost=unit_cost, + source_doc_type="生产台账" if production_ledger_id else "WORK_ORDER" if production_work_order_id else source_doc_type, + source_doc_id=production_ledger_id or production_work_order_id or source_lot.id, + source_line_id=source_lot.id if (production_work_order_id or production_ledger_id) else None, + biz_time=datetime.now(), + operator_user_id=context.user.id, + remark=return_remark, + amount_basis="WEIGHT", + logistics_waybill_no=logistics_waybill_no, + logistics_freight_amount=logistics_freight_amount, + logistics_photo_url=logistics_photo_url, + ) + if production_ledger_id: + post_surplus_inbound_to_ledger( + db, + production_ledger_id=production_ledger_id, + surplus_weight_kg=inbound_weight, + operator_user_id=context.user.id, + source_doc_type="库存流水", + source_doc_id=return_txn.id, + source_line_id=source_lot.id, + biz_time=datetime.now(), + remark=return_remark, + ) + if payload.settle_work_order: + lock_production_batch_ledger( + db, + production_ledger_id=production_ledger_id, + operator_user_id=context.user.id, + biz_time=datetime.now(), + remark=payload.settle_remark or "生产余料入库后该批材料结单", + ) + use_production_archive = _should_archive_production_inbound( + biz_type, + production_ledger_id=production_ledger_id, + production_work_order_id=production_work_order_id, + ) + archive_document_type = DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT if use_production_archive else DOCUMENT_TYPE_WAREHOUSE_OPERATION + archive_business_id = int(return_txn.id) + db.commit() + + archive_fields = _generate_inventory_archive_fields( + db, + document_type=archive_document_type, + business_id=archive_business_id, + created_by=context.user.id, + ) + row = db.execute(get_stock_lots_query(limit=1).where(StockLot.id == source_lot.id)).mappings().first() + if not row: + raise HTTPException(status_code=500, detail="余料入库后读取失败") + return _stock_lot_read_with_archive(dict(row), **archive_fields) + + unit_cost = to_decimal(payload.unit_cost, "0.0001") if payload.unit_cost is not None else default_cost + if unit_cost is None: + # try to look up from latest stock balance + latest = db.scalar( + select(StockBalance.avg_unit_cost) + .where(StockBalance.item_id == payload.item_id, StockBalance.warehouse_id == payload.warehouse_id) + .order_by(StockBalance.id.desc()) + .limit(1) + ) + unit_cost = to_decimal(latest, "0.0001") if latest else Decimal("0") + + location = get_location_or_default(db, payload.warehouse_id, payload.location_id) + now = datetime.now() + operation_source_doc_type = source_doc_type + operation_source_doc_id = payload.source_doc_id or 0 + operation_source_line_id = payload.source_line_id + if biz_type == "PRODUCTION_FINISHED" and production_ledger_id: + operation_source_doc_type = "生产台账" + operation_source_doc_id = production_ledger_id + operation_source_line_id = None + if biz_type == "PRODUCTION_FINISHED" and production_work_order_id: + operation_source_doc_type = "WORK_ORDER" + operation_source_doc_id = production_work_order_id + operation_source_line_id = None + if biz_type == "PRODUCTION_SCRAP_IN" and production_ledger_id: + operation_source_doc_type = "生产台账" + operation_source_doc_id = production_ledger_id + operation_source_line_id = None + if biz_type == "PRODUCTION_SCRAP_IN" and production_work_order_id: + operation_source_doc_type = "WORK_ORDER" + operation_source_doc_id = production_work_order_id + if biz_type == "REWORK_SCRAP_IN" and rework_scrap_work_order is not None: + operation_source_doc_type = "WORK_ORDER" + operation_source_doc_id = rework_scrap_work_order.id + if warehouse_type == "SEMI" and biz_type in {"WIP_IN", "OUTSOURCING_IN"}: + route_operation = _validate_semi_product_operation(db, payload.item_id, payload.source_line_id) + operation_source_doc_type = payload.source_doc_type or "PRODUCT_ROUTE_OPERATION" + operation_source_doc_id = payload.source_doc_id or route_operation.route_id + operation_source_line_id = route_operation.id + existing_measure_basis = _get_existing_semi_measure_basis(db, payload.item_id, payload.warehouse_id, route_operation.id) + if existing_measure_basis == "MIXED": + raise HTTPException(status_code=400, detail="该半成品已有历史混合库存,请先清理历史库存后再入库") + if existing_measure_basis and semi_inbound_measure_basis and existing_measure_basis != semi_inbound_measure_basis: + raise HTTPException( + status_code=400, + detail=f"该半成品已有库存{_semi_measure_basis_label(existing_measure_basis)}管理,后续入库必须沿用该口径", + ) + product_source_lots = _resolve_product_raw_source_lots(db, payload.item_id, payload.source_lot_ids) if is_product_raw_source_lot_trace_inbound else [] + product_source_lot_nos = [lot.lot_no for lot in product_source_lots] + source_material_sub_batch_no = payload.source_material_sub_batch_no + source_material_summary = payload.source_material_summary or payload.provider_name or _inventory_biz_type_label(biz_type) + if biz_type == "REWORK_SCRAP_IN" and rework_scrap_work_order is not None: + source_material_summary = payload.source_material_summary or f"返工废料入库;工单:{rework_scrap_work_order.work_order_no}" + if biz_type == "CUSTOMER_SUPPLIED": + source_material_sub_batch_no = None + source_material_summary = payload.provider_name or "客料入库" + if product_source_lot_nos: + source_material_sub_batch_no = "、".join(product_source_lot_nos) + source_lot_summary = f"来源库存批次号:{source_material_sub_batch_no}" + source_material_summary = f"{source_material_summary};{source_lot_summary}" if source_material_summary else source_lot_summary + + # build lot_no + lot_no = payload.lot_no + if warehouse_type == "RAW" and biz_type in {"OPENING", "CUSTOMER_SUPPLIED"}: + lot_no = None + if not lot_no: + if warehouse_type == "RAW" and biz_type in {"OPENING", "CUSTOMER_SUPPLIED"}: + lot_no = build_inventory_lot_no(db, item.id) + elif biz_type in ("OPENING",): + lot_no = _build_opening_lot_no(db, item, warehouse) + elif biz_type in ("PRODUCTION_SURPLUS", "OUTSOURCING_SURPLUS"): + tag = "SCSY" if biz_type == "PRODUCTION_SURPLUS" else "WWSY" + lot_no = _build_surplus_lot_no(db, item, warehouse, tag) + elif biz_type in ("PRODUCTION_SCRAP_IN", "OUTSOURCING_SCRAP_IN", "REWORK_SCRAP_IN"): + tag = "SCFL" if biz_type == "PRODUCTION_SCRAP_IN" else "FWFL" if biz_type == "REWORK_SCRAP_IN" else "WWFL" + lot_no = _build_surplus_lot_no(db, item, warehouse, tag) + elif biz_type == "WIP_IN": + lot_no = _build_wip_lot_no(db, item, warehouse) + elif biz_type == "PRODUCTION_FINISHED": + lot_no = _build_finished_lot_no(db, item, warehouse) + elif biz_type == "OUTSOURCING_IN": + lot_no = _build_surplus_lot_no(db, item, warehouse, "WWJK") + else: + lot_no = _build_opening_lot_no(db, item, warehouse) + + existing_lot = db.scalar(select(StockLot).where(StockLot.lot_no == lot_no)) + if existing_lot: + raise HTTPException(status_code=400, detail=f"批次号 {lot_no} 已存在,请更换后重试") + + lot = StockLot( + lot_no=lot_no, + parent_lot_id=None, + lot_role=lot_role, + material_sub_batch_no=None, + item_id=payload.item_id, + warehouse_id=payload.warehouse_id, + location_id=location.id if location else None, + source_doc_type=operation_source_doc_type, + source_doc_id=operation_source_doc_id, + source_line_id=operation_source_line_id, + source_material_lot_id=product_source_lots[0].id if product_source_lots else None, + source_material_sub_batch_no=source_material_sub_batch_no, + source_material_summary=source_material_summary, + logistics_waybill_no=logistics_waybill_no, + logistics_freight_amount=logistics_freight_amount, + logistics_photo_url=logistics_photo_url, + inbound_qty=inbound_qty, + inbound_weight_kg=inbound_weight, + remaining_qty=inbound_qty, + remaining_weight_kg=inbound_weight, + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=unit_cost, + production_date=now.date(), + expire_date=None, + quality_status="PASS", + status="AVAILABLE", + remark=payload.remark or _inventory_biz_type_label(biz_type), + ) + db.add(lot) + db.flush() + if not operation_source_doc_id: + lot.source_doc_id = lot.id + db.add(lot) + + balance_qty_delta = Decimal("0") if warehouse_type in WEIGHT_ONLY_WAREHOUSE_TYPES else inbound_qty + upsert_stock_balance( + db, + item_id=payload.item_id, + warehouse_id=payload.warehouse_id, + location_id=location.id if location else None, + qty_delta=balance_qty_delta, + weight_delta=inbound_weight, + available_qty_delta=balance_qty_delta, + available_weight_delta=inbound_weight, + unit_cost=unit_cost, + ) + inbound_txn = create_inventory_txn( + db, + txn_type=txn_type, + item_id=payload.item_id, + warehouse_id=payload.warehouse_id, + location_id=location.id if location else None, + lot_id=lot.id, + qty_change=inbound_qty, + weight_change=inbound_weight, + unit_cost=unit_cost, + source_doc_type=operation_source_doc_type, + source_doc_id=lot.source_doc_id, + source_line_id=operation_source_line_id, + biz_time=now, + operator_user_id=context.user.id, + remark=payload.remark or _inventory_biz_type_label(biz_type), + amount_basis="QTY" if (is_aux_quantity_inbound or is_finished_outsourcing_quantity_inbound or is_finished_production_quantity_inbound) else "WEIGHT", + logistics_waybill_no=logistics_waybill_no, + logistics_freight_amount=logistics_freight_amount, + logistics_photo_url=logistics_photo_url, + ) + if biz_type == "PRODUCTION_SCRAP_IN" and production_ledger_id: + post_scrap_inbound_to_ledger( + db, + production_ledger_id=production_ledger_id, + scrap_weight_kg=inbound_weight, + operator_user_id=context.user.id, + source_doc_type="库存流水", + source_doc_id=inbound_txn.id, + source_line_id=lot.id, + biz_time=now, + remark=payload.remark or "生产废料入库联动生产台账", + ) + if payload.settle_work_order: + lock_production_batch_ledger( + db, + production_ledger_id=production_ledger_id, + operator_user_id=context.user.id, + biz_time=now, + remark=payload.settle_remark or "生产废料入库后该批材料结单", + ) + if biz_type == "PRODUCTION_FINISHED" and production_ledger_id: + post_finished_inbound_to_ledger( + db, + production_ledger_id=production_ledger_id, + finished_qty=inbound_qty, + operator_user_id=context.user.id, + source_doc_type="库存流水", + source_doc_id=inbound_txn.id, + source_line_id=lot.id, + biz_time=now, + remark=payload.remark or "生产入库联动生产台账", + ) + if payload.settle_work_order: + lock_production_batch_ledger( + db, + production_ledger_id=production_ledger_id, + operator_user_id=context.user.id, + biz_time=now, + remark=payload.settle_remark or "生产入库后该批材料结单", + ) + if biz_type == "PRODUCTION_FINISHED" and production_work_order is not None: + production_work_order.finished_qty = to_decimal(production_work_order.finished_qty) + inbound_qty + if payload.settle_work_order: + _apply_production_work_order_inbound_settlement( + production_work_order, + biz_type=biz_type, + settle_remark=payload.settle_remark, + ) + db.add(production_work_order) + if settle_production_work_order and production_work_order is not None: + _apply_production_work_order_inbound_settlement( + production_work_order, + biz_type=biz_type, + settle_remark=payload.settle_remark, + ) + db.add(production_work_order) + use_production_archive = _should_archive_production_inbound( + biz_type, + production_ledger_id=production_ledger_id, + production_work_order_id=production_work_order_id, + ) + archive_document_type = DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT if use_production_archive else DOCUMENT_TYPE_WAREHOUSE_OPERATION + archive_business_id = int(inbound_txn.id) + db.commit() + + archive_fields = _generate_inventory_archive_fields( + db, + document_type=archive_document_type, + business_id=archive_business_id, + created_by=context.user.id, + ) + row = db.execute(get_stock_lots_query(limit=1).where(StockLot.id == lot.id)).mappings().first() + if not row: + raise HTTPException(status_code=500, detail="入库后读取失败") + return _stock_lot_read_with_archive(dict(row), **archive_fields) + + +# ---------- outbound biz_type -> (txn_type, source_doc_type) ---------- + +_OUTBOUND_BIZ_MAP = { + "PRODUCTION_OUT": ("PRODUCTION_OUT", "PRODUCTION_OUT"), + "OUTSOURCING_OUT": ("OUTSOURCING_OUT", "OUTSOURCING_OUT"), + "SCRAP_OUT": ("SCRAP_OUT", "SCRAP_OUT"), + "SCRAP_SALE_OUT": ("SCRAP_SALE_OUT", "SCRAP_SALE_OUT"), + "WIP_OUT": ("WIP_OUT", "WIP_OUT"), + "SALES_OUT": ("SALES_OUT", "SALES_OUT"), + "PURCHASE_RETURN_OUT": ("PURCHASE_RETURN_OUT", "PURCHASE_RETURN"), +} + +_ALLOWED_OUTBOUND_BY_WAREHOUSE_TYPE = { + "RAW": {"OUTSOURCING_OUT", "SCRAP_OUT", "PURCHASE_RETURN_OUT"}, + "SEMI": {"WIP_OUT", "OUTSOURCING_OUT"}, + "FINISHED": {"SALES_OUT"}, + "AUX": {"PRODUCTION_OUT"}, + "SCRAP": {"SCRAP_SALE_OUT"}, +} + +_LOGISTICS_REQUIRED_OUTBOUND_BIZ_TYPES = { + "OUTSOURCING_OUT", + "SCRAP_SALE_OUT", + "SALES_OUT", +} + +_SELECTED_SOURCE_LOT_OUTBOUND_BIZ_TYPES = { + "OUTSOURCING_OUT", + "SCRAP_OUT", + "SCRAP_SALE_OUT", +} + +_INVENTORY_BIZ_TYPE_LABELS = { + "OPENING": "期初入库", + "CUSTOMER_SUPPLIED": "客料入库", + "PRODUCTION_SURPLUS": "生产余料入库", + "OUTSOURCING_SURPLUS": "委外余料入库", + "PRODUCTION_SCRAP_IN": "生产废料入库", + "REWORK_SCRAP_IN": "返工废料入库", + "OUTSOURCING_SCRAP_IN": "委外废料入库", + "WIP_IN": "产中入库", + "OUTSOURCING_IN": "委外入库", + "PRODUCTION_FINISHED": "生产入库", + "PRODUCTION_OUT": "生产出库", + "OUTSOURCING_OUT": "委外出库", + "SCRAP_OUT": "报废出库", + "SCRAP_SALE_OUT": "售卖出库", + "WIP_OUT": "产中出库", + "SALES_OUT": "销售出库", + "PURCHASE_RETURN_OUT": "退货出库", + "SPECIAL_IN": "特殊入库", + "SPECIAL_OUT": "特殊出库", +} + + +def _inventory_biz_type_label(value: str | None) -> str: + normalized = str(value or "").upper() + return _INVENTORY_BIZ_TYPE_LABELS.get(normalized, "库存业务") + + +def _warehouse_type_label(value: str | None) -> str: + return { + "RAW": "原材料库", + "SEMI": "半成品库", + "FINISHED": "成品库", + "AUX": "辅料库", + "SCRAP": "废料库", + "RETURN": "退货库", + }.get(str(value or "").upper(), str(value or "未知仓库")) + + +def _validate_warehouse_item(warehouse: Warehouse, item: Item) -> None: + warehouse_type = str(warehouse.warehouse_type or "").upper() + if warehouse_type in {"RAW", "AUX"} and item.item_type != "RAW_MATERIAL": + raise HTTPException(status_code=400, detail=f"{_warehouse_type_label(warehouse_type)}只能选择原材料/辅料类物料") + if warehouse_type in {"SEMI", "FINISHED"} and item.item_type not in {"PRODUCT", "FINISHED_GOOD"}: + raise HTTPException(status_code=400, detail=f"{_warehouse_type_label(warehouse_type)}只能选择产品类物料") + if warehouse_type == "SCRAP" and item.item_type not in {"RAW_MATERIAL", "PRODUCT", "FINISHED_GOOD"}: + raise HTTPException(status_code=400, detail="废料库只能选择原材料、辅料、产品或报废品类物料") + if warehouse_type == "RETURN" and item.item_type not in {"PRODUCT", "FINISHED_GOOD"}: + raise HTTPException(status_code=400, detail="退货库只能选择客户退回且可返工的产品类物料") + + +def _validate_semi_product_operation(db: Session, item_id: int, source_line_id: int | None) -> ProcessRouteOperation: + if not source_line_id: + raise HTTPException(status_code=400, detail="半成品入出库必须选择半成品") + operation = db.get(ProcessRouteOperation, source_line_id) + if not operation: + raise HTTPException(status_code=404, detail="半成品不存在") + route = db.get(ProcessRoute, operation.route_id) + if not route or route.product_item_id != item_id: + raise HTTPException(status_code=400, detail="半成品与所选产品不一致") + if str(route.status or "").upper() != "ACTIVE": + raise HTTPException(status_code=400, detail="停用的产品需规不能用于半成品入出库") + if str(operation.status or "").upper() != "ACTIVE": + raise HTTPException(status_code=400, detail="停用的半成品不能用于半成品入出库") + return operation + + +def _semi_measure_basis_label(value: str | None) -> str: + return {"PIECE": "按件", "WEIGHT": "按重", "MIXED": "历史混合"}.get(str(value or "").upper(), "未确定") + + +def _semi_measure_basis_from_values(weight: Decimal, qty: Decimal, action_label: str) -> str: + if weight <= 0 and qty <= 0: + raise HTTPException(status_code=400, detail=f"半成品{action_label}必须选择按件或按重一种口径,{action_label}重量和{action_label}数量至少填写一项") + if weight > 0 and qty > 0: + raise HTTPException(status_code=400, detail=f"半成品{action_label}只能选择按件或按重一种口径") + return "PIECE" if qty > 0 else "WEIGHT" + + +def _derive_semi_measure_basis_from_lots(lots: list[StockLot]) -> str | None: + basis_set: set[str] = set() + for lot in lots: + has_qty = to_decimal(lot.remaining_qty) > 0 + has_weight = to_decimal(lot.remaining_weight_kg) > 0 + if has_qty and has_weight: + return "MIXED" + if has_qty: + basis_set.add("PIECE") + if has_weight: + basis_set.add("WEIGHT") + if len(basis_set) > 1: + return "MIXED" + return next(iter(basis_set), None) + + +def _get_existing_semi_measure_basis(db: Session, item_id: int, warehouse_id: int, source_line_id: int) -> str | None: + lots = db.scalars( + select(StockLot).where( + StockLot.item_id == item_id, + StockLot.warehouse_id == warehouse_id, + StockLot.source_line_id == source_line_id, + or_(StockLot.remaining_qty > 0, StockLot.remaining_weight_kg > 0), + ) + ).all() + return _derive_semi_measure_basis_from_lots(lots) + + +def _get_product_material_item_ids(db: Session, product_item_id: int) -> set[int]: + bom_ids = db.scalars( + select(Bom.id) + .where(Bom.product_item_id == product_item_id, Bom.status == "ACTIVE") + .order_by(Bom.is_default.desc(), Bom.id.desc()) + ).all() + if not bom_ids: + return set() + rows = db.scalars( + select(BomItem.material_item_id) + .where(BomItem.bom_id.in_(bom_ids)) + .order_by(BomItem.seq_no.asc(), BomItem.id.asc()) + ).all() + return {int(row) for row in rows if row} + + +def _resolve_product_raw_source_lots(db: Session, product_item_id: int, source_lot_ids: list[int]) -> list[StockLot]: + ordered_lot_ids = [int(row) for row in source_lot_ids if int(row or 0) > 0] + if not ordered_lot_ids: + return [] + if len(ordered_lot_ids) != len(set(ordered_lot_ids)): + raise HTTPException(status_code=400, detail="来源库存批次号不能重复选择") + + material_item_ids = _get_product_material_item_ids(db, product_item_id) + if not material_item_ids: + raise HTTPException(status_code=400, detail="该产品未维护默认用料,不能选择来源库存批次号") + + source_lots = db.scalars(select(StockLot).where(StockLot.id.in_(ordered_lot_ids))).all() + source_lot_by_id = {int(lot.id): lot for lot in source_lots} + ordered_lots: list[StockLot] = [] + for source_lot_id in ordered_lot_ids: + source_lot = source_lot_by_id.get(source_lot_id) + if not source_lot: + raise HTTPException(status_code=404, detail="来源库存批次号不存在") + source_warehouse = db.get(Warehouse, source_lot.warehouse_id) + if str(source_warehouse.warehouse_type if source_warehouse else "").upper() != "RAW": + raise HTTPException(status_code=400, detail=f"来源库存批次号 {source_lot.lot_no} 不属于原材料库") + if int(source_lot.item_id) not in material_item_ids: + raise HTTPException(status_code=400, detail=f"来源库存批次号 {source_lot.lot_no} 不是该产品需用清单中的默认用料") + ordered_lots.append(source_lot) + return ordered_lots + + +def _source_material_lot_note(db: Session, lot: StockLot) -> str | None: + if lot.source_material_lot_id: + source_lot_no = db.scalar(select(StockLot.lot_no).where(StockLot.id == lot.source_material_lot_id)) + if source_lot_no: + return str(source_lot_no) + if lot.source_material_sub_batch_no: + return str(lot.source_material_sub_batch_no) + source_summary = str(lot.source_material_summary or "") + marker = "来源库存批次号:" + for part in source_summary.split(";"): + if part.strip().startswith(marker): + return part.strip()[len(marker):].strip() or None + return lot.lot_no + + +@router.post("/outbound", response_model=StockLotRead) +def create_warehouse_outbound( + payload: WarehouseOutboundCreate, + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> StockLotRead: + biz_type = (payload.biz_type or "").upper() + if biz_type not in _OUTBOUND_BIZ_MAP: + raise HTTPException(status_code=400, detail=f"未知的出库业务类型: {biz_type}") + + item = db.get(Item, payload.item_id) + if not item: + raise HTTPException(status_code=404, detail="物料不存在") + + warehouse = db.get(Warehouse, payload.warehouse_id) + if not warehouse: + raise HTTPException(status_code=404, detail="仓库不存在") + ensure_warehouses_unlocked(db, [payload.warehouse_id], "出库") + warehouse_type = str(warehouse.warehouse_type or "").upper() + if biz_type not in _ALLOWED_OUTBOUND_BY_WAREHOUSE_TYPE.get(warehouse_type, set()): + raise HTTPException( + status_code=400, + detail=f"{_warehouse_type_label(warehouse_type)}不允许执行该出库业务", + ) + _validate_warehouse_item(warehouse, item) + logistics_waybill_no, logistics_freight_amount, logistics_photo_url = normalize_logistics_fields( + payload.waybill_no, + payload.freight_amount, + required=biz_type in _LOGISTICS_REQUIRED_OUTBOUND_BIZ_TYPES, + order_photo_url=payload.order_photo_url, + ) + + outbound_weight = to_decimal(payload.outbound_weight_kg) + outbound_qty = to_decimal(payload.outbound_qty) + is_semi_quantity_weight_outbound = warehouse_type == "SEMI" and biz_type in {"WIP_OUT", "OUTSOURCING_OUT"} + is_aux_quantity_outbound = warehouse_type == "AUX" and biz_type == "PRODUCTION_OUT" + semi_outbound_measure_basis: str | None = None + if outbound_weight < 0 or outbound_qty < 0: + raise HTTPException(status_code=400, detail="出库重量和出库数量不能小于0") + if is_semi_quantity_weight_outbound: + semi_outbound_measure_basis = _semi_measure_basis_from_values(outbound_weight, outbound_qty, "出库") + elif is_aux_quantity_outbound: + if outbound_qty <= 0: + raise HTTPException(status_code=400, detail="辅料生产出库数量必须大于0") + if not str(payload.remark or "").strip(): + raise HTTPException(status_code=400, detail="辅料生产出库必须填写用途") + outbound_weight = Decimal("0") + else: + if outbound_weight <= 0: + raise HTTPException(status_code=400, detail="出库重量必须大于0") + outbound_qty = Decimal("0") + sale_unit_price = Decimal("0") + if biz_type == "SCRAP_SALE_OUT": + sale_unit_price = to_decimal(payload.sale_unit_price, "0.0001") if payload.sale_unit_price is not None else Decimal("0") + if sale_unit_price <= 0: + raise HTTPException(status_code=400, detail="废料售卖出库必须填写大于0的售卖单价") + + txn_type, source_doc_type = _OUTBOUND_BIZ_MAP[biz_type] + now = datetime.now() + if warehouse_type == "SEMI" and biz_type in {"WIP_OUT", "OUTSOURCING_OUT"}: + _validate_semi_product_operation(db, payload.item_id, payload.target_doc_line_id) + selected_source_lines = payload.source_lots or [] + deduction_plan: list[tuple[StockLot, Decimal, Decimal]] = [] + + if selected_source_lines: + if biz_type not in _SELECTED_SOURCE_LOT_OUTBOUND_BIZ_TYPES: + raise HTTPException(status_code=400, detail="该出库业务不支持指定来源库存批次号") + + requested_weights_by_lot_id: dict[int, Decimal] = {} + for source_line in selected_source_lines: + source_lot_id = int(source_line.source_lot_id) + if source_lot_id in requested_weights_by_lot_id: + raise HTTPException(status_code=400, detail="来源库存批次号不能重复选择") + source_weight = to_decimal(source_line.outbound_weight_kg) + if source_weight <= 0: + raise HTTPException(status_code=400, detail="每个来源库存批次号的出库重量必须大于0") + requested_weights_by_lot_id[source_lot_id] = source_weight + + selected_total_weight = sum(requested_weights_by_lot_id.values(), Decimal("0")) + if selected_total_weight != outbound_weight: + raise HTTPException(status_code=400, detail="出库总重量必须等于各来源库存批次号出库重量之和") + + source_lots = db.scalars( + select(StockLot).where(StockLot.id.in_(requested_weights_by_lot_id.keys())) + ).all() + source_lot_by_id = {lot.id: lot for lot in source_lots} + for source_lot_id, selected_weight in requested_weights_by_lot_id.items(): + source_lot = source_lot_by_id.get(source_lot_id) + if not source_lot: + raise HTTPException(status_code=404, detail="来源库存批次号不存在") + if source_lot.item_id != payload.item_id or source_lot.warehouse_id != payload.warehouse_id: + raise HTTPException(status_code=400, detail=f"来源库存批次号 {source_lot.lot_no} 与本次出库物料或仓库不一致") + if payload.location_id and source_lot.location_id != payload.location_id: + raise HTTPException(status_code=400, detail=f"来源库存批次号 {source_lot.lot_no} 不属于所选库位") + if source_lot.quality_status != "PASS": + raise HTTPException(status_code=400, detail=f"来源库存批次号 {source_lot.lot_no} 未质检放行") + if source_lot.status != "AVAILABLE": + raise HTTPException(status_code=400, detail=f"来源库存批次号 {source_lot.lot_no} 当前状态不可用") + usable_weight = to_decimal(source_lot.remaining_weight_kg) - to_decimal(source_lot.locked_weight_kg) + if usable_weight < selected_weight: + raise HTTPException( + status_code=400, + detail=f"来源库存批次号 {source_lot.lot_no} 可用重量不足,无法完成本次出库", + ) + deduction_plan.append((source_lot, selected_weight, Decimal("0"))) + else: + lot_filters = [ + StockLot.item_id == payload.item_id, + StockLot.warehouse_id == payload.warehouse_id, + StockLot.status == "AVAILABLE", + or_(StockLot.remaining_weight_kg > 0, StockLot.remaining_qty > 0) if is_semi_quantity_weight_outbound else (StockLot.remaining_qty > 0 if is_aux_quantity_outbound else StockLot.remaining_weight_kg > 0), + ] + if payload.location_id: + lot_filters.append(StockLot.location_id == payload.location_id) + if warehouse_type == "SEMI" and payload.target_doc_line_id: + lot_filters.append(StockLot.source_line_id == payload.target_doc_line_id) + + available_lots = db.scalars( + select(StockLot) + .where(*lot_filters) + .order_by(StockLot.created_at.asc(), StockLot.id.asc()) + ).all() + if not available_lots: + raise HTTPException(status_code=400, detail="该物料在该仓库无可用库存批次") + + existing_measure_basis = _derive_semi_measure_basis_from_lots(available_lots) if is_semi_quantity_weight_outbound else None + if existing_measure_basis == "MIXED": + raise HTTPException(status_code=400, detail="该半成品已有历史混合库存,请先清理历史库存后再出库") + if existing_measure_basis and semi_outbound_measure_basis and existing_measure_basis != semi_outbound_measure_basis: + required_field = "出库数量" if existing_measure_basis == "PIECE" else "出库重量" + raise HTTPException( + status_code=400, + detail=f"该半成品现有库存{_semi_measure_basis_label(existing_measure_basis)}管理,请填写{required_field}", + ) + + total_available_weight = sum((to_decimal(lot.remaining_weight_kg) for lot in available_lots), Decimal("0")) + total_available_qty = sum((to_decimal(lot.remaining_qty) for lot in available_lots), Decimal("0")) + if outbound_weight > 0 and total_available_weight < outbound_weight: + raise HTTPException(status_code=400, detail="该物料可用重量不足,无法完成本次出库") + if (is_semi_quantity_weight_outbound or is_aux_quantity_outbound) and outbound_qty > 0 and total_available_qty < outbound_qty: + shortage_label = "该辅料" if is_aux_quantity_outbound else "该产品工序" + raise HTTPException(status_code=400, detail=f"{shortage_label}可用数量不足,无法完成本次{_inventory_biz_type_label(biz_type)}") + + remaining_weight = outbound_weight + remaining_qty = outbound_qty + for available_lot in available_lots: + if remaining_weight <= 0 and remaining_qty <= 0: + break + available_weight = to_decimal(available_lot.remaining_weight_kg) + available_qty = to_decimal(available_lot.remaining_qty) + if is_aux_quantity_outbound: + actual_qty = min(remaining_qty, available_qty) + actual_deduction = Decimal("0") + elif is_semi_quantity_weight_outbound and outbound_qty > 0: + actual_qty = min(remaining_qty, available_qty) + if actual_qty <= 0: + continue + if outbound_weight > 0: + actual_deduction = remaining_weight if remaining_qty == actual_qty else (outbound_weight * actual_qty / outbound_qty) + actual_deduction = min(actual_deduction, available_weight, remaining_weight) + else: + ratio = actual_qty / available_qty if available_qty > 0 else Decimal("0") + actual_deduction = min(available_weight * ratio, available_weight) + else: + actual_deduction = min(remaining_weight, available_weight) + if is_semi_quantity_weight_outbound: + ratio = actual_deduction / available_weight if available_weight > 0 else Decimal("0") + actual_qty = min(available_qty * ratio, available_qty) + else: + actual_qty = Decimal("0") + if actual_deduction <= 0 and actual_qty <= 0: + continue + deduction_plan.append((available_lot, actual_deduction, actual_qty)) + remaining_weight -= actual_deduction + remaining_qty -= actual_qty + + first_lot_id: int | None = None + first_inventory_txn_id: int | None = None + for available_lot, actual_deduction, actual_qty in deduction_plan: + available_weight = to_decimal(available_lot.remaining_weight_kg) + available_qty = to_decimal(available_lot.remaining_qty) + unit_cost = to_decimal(available_lot.unit_cost, "0.0001") + txn_unit_cost = sale_unit_price if biz_type == "SCRAP_SALE_OUT" else unit_cost + available_lot.remaining_weight_kg = available_weight - actual_deduction + available_lot.remaining_qty = available_qty - actual_qty if (is_semi_quantity_weight_outbound or is_aux_quantity_outbound) else Decimal("0") + if available_lot.remaining_weight_kg <= 0 and available_lot.remaining_qty <= 0: + available_lot.status = "DEPLETED" + db.add(available_lot) + first_lot_id = first_lot_id or available_lot.id + + upsert_stock_balance( + db, + item_id=payload.item_id, + warehouse_id=payload.warehouse_id, + location_id=available_lot.location_id, + qty_delta=-actual_qty, + weight_delta=-actual_deduction, + available_qty_delta=-actual_qty, + available_weight_delta=-actual_deduction, + unit_cost=unit_cost, + ) + + if is_aux_quantity_outbound: + remark = f"用途:{str(payload.remark or '').strip()}" + else: + remark = payload.remark or _inventory_biz_type_label(biz_type) + if biz_type == "SCRAP_SALE_OUT": + sale_parts = [] + if payload.buyer_name: + sale_parts.append(f"购买方 {payload.buyer_name}") + if payload.sale_remark: + sale_parts.append(payload.sale_remark) + if sale_parts: + remark = f"{remark};{';'.join(sale_parts)}" + if biz_type == "OUTSOURCING_OUT" and payload.outsourcing_party_name: + remark = f"{remark};委外方:{payload.outsourcing_party_name}" + if biz_type == "SCRAP_SALE_OUT": + source_lot_note = _source_material_lot_note(db, available_lot) + else: + source_lot_note = available_lot.lot_no if (selected_source_lines or is_semi_quantity_weight_outbound) else payload.target_material_sub_batch_no + if source_lot_note: + remark = f"{remark};来源库存批次号 {source_lot_note}" + outbound_txn = create_inventory_txn( + db, + txn_type=txn_type, + item_id=payload.item_id, + warehouse_id=payload.warehouse_id, + location_id=available_lot.location_id, + lot_id=available_lot.id, + qty_change=-actual_qty, + weight_change=-actual_deduction, + unit_cost=txn_unit_cost, + source_doc_type=source_doc_type, + source_doc_id=payload.target_doc_id or 0, + source_line_id=payload.target_doc_line_id, + biz_time=now, + operator_user_id=context.user.id, + remark=remark, + amount_basis="QTY" if is_aux_quantity_outbound else "WEIGHT", + logistics_waybill_no=logistics_waybill_no, + logistics_freight_amount=logistics_freight_amount, + logistics_photo_url=logistics_photo_url, + ) + first_inventory_txn_id = first_inventory_txn_id or int(outbound_txn.id) + db.commit() + + archive_fields = _generate_inventory_archive_fields( + db, + document_type=DOCUMENT_TYPE_WAREHOUSE_OPERATION, + business_id=first_inventory_txn_id, + created_by=context.user.id, + ) + row = db.execute(get_stock_lots_query(limit=1).where(StockLot.id == first_lot_id)).mappings().first() + if not row: + raise HTTPException(status_code=500, detail="出库后读取失败") + return _stock_lot_read_with_archive(dict(row), **archive_fields) + + +@router.post("/raw-material-return-outbound", response_model=PurchaseReturnRead) +def create_raw_material_return_outbound( + payload: RawMaterialReturnOutboundCreate, + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> PurchaseReturnRead: + if not payload.items: + raise HTTPException(status_code=400, detail="退货出库明细不能为空") + + warehouse = db.get(Warehouse, payload.warehouse_id) + if not warehouse: + raise HTTPException(status_code=404, detail="仓库不存在") + if str(warehouse.warehouse_type or "").upper() != "RAW": + raise HTTPException(status_code=400, detail="退货出库只能从原材料库发起") + ensure_warehouses_unlocked(db, [payload.warehouse_id], "退货出库") + + logistics_waybill_no, logistics_freight_amount, logistics_photo_url = normalize_logistics_fields( + payload.waybill_no, + payload.freight_amount, + required=True, + freight_required=False, + order_photo_url=payload.order_photo_url, + ) + + seen_lot_ids: set[int] = set() + purchase_order_id: int | None = None + prepared_lines: list[dict[str, object]] = [] + for line_no, item_payload in enumerate(payload.items, start=1): + if item_payload.lot_id in seen_lot_ids: + raise HTTPException(status_code=400, detail="同一张退货出库单不能重复选择同一库存批次") + seen_lot_ids.add(item_payload.lot_id) + + return_weight = to_decimal(item_payload.return_weight_kg) + if return_weight <= 0: + raise HTTPException(status_code=400, detail="退货重量必须大于0") + + lot = db.get(StockLot, item_payload.lot_id) + if not lot: + raise HTTPException(status_code=404, detail="库存批次不存在") + if lot.warehouse_id != payload.warehouse_id: + raise HTTPException(status_code=400, detail="库存批次不属于当前原材料库") + if lot.status != "AVAILABLE" or lot.quality_status != "PASS": + raise HTTPException(status_code=400, detail="只能选择可用且质检合格的原材料批次退货") + + purchase_order_item = db.get(PurchaseOrderItem, item_payload.purchase_order_item_id) + if not purchase_order_item: + raise HTTPException(status_code=404, detail="采购订单明细不存在") + if purchase_order_item.material_item_id != lot.item_id: + raise HTTPException(status_code=400, detail="采购订单明细物料与库存批次物料不一致") + if purchase_order_id is None: + purchase_order_id = purchase_order_item.purchase_order_id + elif purchase_order_id != purchase_order_item.purchase_order_id: + raise HTTPException(status_code=400, detail="一张退货出库单只能对应一个采购订单,请分开登记") + + usable_lot_weight = to_decimal(lot.remaining_weight_kg) - to_decimal(lot.locked_weight_kg) + if return_weight > usable_lot_weight: + raise HTTPException(status_code=400, detail="退货重量不能超过批次可用重量") + + matched_link = next( + ( + link + for link in get_stock_lot_purchase_links(db, lot.id) + if int(link["purchase_order_item_id"]) == int(item_payload.purchase_order_item_id) + ), + None, + ) + if not matched_link: + raise HTTPException(status_code=400, detail="该库存批次未关联所选采购订单明细") + if return_weight > to_decimal(matched_link["returnable_weight_kg"]): + raise HTTPException(status_code=400, detail="退货重量不能超过采购入库可退重量") + + prepared_lines.append( + { + "line_no": line_no, + "payload": item_payload, + "lot": lot, + "lot_id": lot.id, + "purchase_order_item": purchase_order_item, + "purchase_order_item_id": purchase_order_item.id, + "return_weight": return_weight, + "unit_cost": to_decimal(matched_link["unit_cost"], "0.0001"), + } + ) + + if purchase_order_id is None: + raise HTTPException(status_code=400, detail="退货出库明细不能为空") + + _lock_purchase_order_items_for_return( + db, + {int(prepared["purchase_order_item_id"]) for prepared in prepared_lines}, + ) + _recheck_raw_material_return_purchase_line_availability(db, prepared_lines) + + now = datetime.now() + purchase_return = PurchaseReturn( + return_no=_build_purchase_return_no(db), + purchase_order_id=purchase_order_id, + warehouse_id=payload.warehouse_id, + return_date=payload.return_date or now, + operator_user_id=context.user.id, + logistics_waybill_no=logistics_waybill_no, + logistics_freight_amount=logistics_freight_amount, + logistics_photo_url=logistics_photo_url, + reason=payload.reason, + status="CONFIRMED", + remark=payload.remark, + created_at=now, + updated_at=now, + ) + db.add(purchase_return) + db.flush() + + first_inventory_txn_id: int | None = None + for prepared in prepared_lines: + item_payload = prepared["payload"] + lot = prepared["lot"] + purchase_order_item = prepared["purchase_order_item"] + return_weight = prepared["return_weight"] + unit_cost = prepared["unit_cost"] + amount = to_decimal(return_weight * unit_cost, "0.01") + + purchase_return_item = PurchaseReturnItem( + purchase_return_id=purchase_return.id, + line_no=prepared["line_no"], + purchase_order_item_id=purchase_order_item.id, + material_item_id=lot.item_id, + lot_id=lot.id, + return_weight_kg=return_weight, + unit_cost=unit_cost, + amount=amount, + reason=item_payload.reason, + status="CONFIRMED", + remark=item_payload.remark, + created_at=now, + updated_at=now, + ) + db.add(purchase_return_item) + db.flush() + + deduction_result = db.execute( + update(StockLot) + .where( + StockLot.id == lot.id, + StockLot.remaining_weight_kg - StockLot.locked_weight_kg >= return_weight, + StockLot.status == "AVAILABLE", + StockLot.quality_status == "PASS", + ) + .values( + remaining_weight_kg=StockLot.remaining_weight_kg - return_weight, + remaining_qty=Decimal("0"), + status=case( + (StockLot.remaining_weight_kg - return_weight <= 0, "DEPLETED"), + else_="AVAILABLE", + ), + updated_at=now, + ) + .execution_options(synchronize_session=False) + ) + if deduction_result.rowcount != 1: + db.rollback() + raise HTTPException(status_code=400, detail="退货重量不能超过批次可用重量") + db.refresh(lot) + + upsert_stock_balance( + db, + item_id=lot.item_id, + warehouse_id=payload.warehouse_id, + location_id=lot.location_id, + qty_delta=Decimal("0"), + weight_delta=-return_weight, + available_qty_delta=Decimal("0"), + available_weight_delta=-return_weight, + unit_cost=unit_cost, + ) + + txn = create_inventory_txn( + db, + txn_type="PURCHASE_RETURN_OUT", + item_id=lot.item_id, + warehouse_id=payload.warehouse_id, + location_id=lot.location_id, + lot_id=lot.id, + qty_change=Decimal("0"), + weight_change=-return_weight, + unit_cost=unit_cost, + source_doc_type="PURCHASE_RETURN", + source_doc_id=purchase_return.id, + source_line_id=purchase_return_item.id, + biz_time=purchase_return.return_date, + operator_user_id=context.user.id, + remark=item_payload.remark or item_payload.reason or payload.remark, + amount_basis="WEIGHT", + logistics_waybill_no=logistics_waybill_no, + logistics_freight_amount=logistics_freight_amount, + logistics_photo_url=logistics_photo_url, + ) + if first_inventory_txn_id is None: + first_inventory_txn_id = int(txn.id) + + db.commit() + archive_fields = _generate_inventory_archive_fields( + db, + document_type=DOCUMENT_TYPE_WAREHOUSE_OPERATION, + business_id=first_inventory_txn_id, + created_by=context.user.id, + ) + result = _purchase_return_read(db, purchase_return.id) + result.archive_status = archive_fields["archive_status"] + result.archive_business_id = archive_fields["archive_business_id"] + result.archive_document_type = archive_fields["archive_document_type"] + result.archive_error_message = archive_fields["archive_error_message"] + return result diff --git a/backend/app/api/routes/master_data.py b/backend/app/api/routes/master_data.py new file mode 100644 index 0000000..1b24c17 --- /dev/null +++ b/backend/app/api/routes/master_data.py @@ -0,0 +1,4389 @@ +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="工艺路线不存在") diff --git a/backend/app/api/routes/planning.py b/backend/app/api/routes/planning.py new file mode 100644 index 0000000..be41e85 --- /dev/null +++ b/backend/app/api/routes/planning.py @@ -0,0 +1,31 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.schemas.database import MaterialDemandRead, MrpGenerationResult +from app.services.auth import require_authenticated_user +from app.services.sales_planning import generate_mrp_for_order, get_mrp_demands_query + +router = APIRouter(dependencies=[Depends(require_authenticated_user)]) + + +@router.get("/mrp-demands", response_model=list[MaterialDemandRead]) +def list_mrp_demands( + sales_order_id: int | None = Query(default=None), + limit: int = Query(default=50, ge=1, le=200), + db: Session = Depends(get_db), +) -> list[MaterialDemandRead]: + stmt = get_mrp_demands_query(limit=limit, sales_order_id=sales_order_id) + rows = db.execute(stmt).mappings().all() + return [MaterialDemandRead.model_validate(dict(row)) for row in rows] + + +@router.post("/generate-mrp/{sales_order_id}", response_model=MrpGenerationResult) +def generate_mrp(sales_order_id: int, db: Session = Depends(get_db)) -> MrpGenerationResult: + sales_order, demand_rows = generate_mrp_for_order(db, sales_order_id) + return MrpGenerationResult( + sales_order_id=sales_order.id, + order_no=sales_order.order_no, + generated_count=len(demand_rows), + demand_rows=demand_rows, + ) diff --git a/backend/app/api/routes/production.py b/backend/app/api/routes/production.py new file mode 100644 index 0000000..886ec0c --- /dev/null +++ b/backend/app/api/routes/production.py @@ -0,0 +1,2683 @@ +from datetime import date, datetime, timedelta +from decimal import Decimal, ROUND_FLOOR +import re +from uuid import uuid4 + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session, aliased + +from app.db.session import get_db +from app.models.document_archive import DocumentArchive +from app.models.master_data import BomItem, Item, Warehouse +from app.models.miniapp import MiniAppPersonnel, MiniAppProductionReport, MiniAppProductionReportItem +from app.models.operations import ( + CompletionReceipt, + CompletionReceiptItem, + Equipment, + InventoryTxn, + OperationReport, + ProcessRouteOperation, + ProductionBatchLedger, + ProductionBatchLedgerTxn, + ReturnDisposition, + ReturnItem, + ScrapRecord, + StockLot, + WorkOrder, + WorkOrderMaterial, + WorkOrderMaterialIssue, + WorkOrderOperation, +) +from app.models.org import Department, Employee +from app.models.sales import SalesOrderItem +from app.schemas.operations import ( + CompletionReceiptCreate, + CompletionReceiptItemRead, + CompletionReceiptRead, + MiniAppOperationReportRead, + MiniAppOperationReportSyncRead, + MiniAppOperationReportSyncStateRead, + MiniAppScrapRecordCreate, + OperationReportCreate, + OperationReportRead, + ProductionBatchLedgerRead, + ProductionBatchLedgerInboundCreate, + ProductionBatchLedgerInboundPreviewRead, + ProductionBatchLedgerInboundRead, + ProductionWorkOrderInboundCreate, + ProductionWorkOrderInboundPreviewRead, + ProductionWorkOrderInboundRead, + ScrapRecordCreate, + ScrapRecordRead, + WorkOrderCreate, + WorkOrderLedgerRead, + WorkOrderMaterialIssueRead, + WorkOrderMaterialRead, + WorkOrderOperationRead, + WorkOrderRead, +) +from app.schemas.document_archives import DocumentArchiveGenerateResult +from app.services.auth import AuthContext, require_authenticated_user +from app.services.auth import next_employee_code +from app.services.document_archives import ( + ARCHIVE_STATUS_MISSING, + DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, + FILE_FORMAT_PDF, + generate_document_archive, + production_document_batch_no_from_text, +) +from app.services.operations import ( + build_raw_lot_product_work_order_no, + build_yearly_product_work_order_no, + create_inventory_txn, + create_work_order_materials_and_operations, + clamp_non_negative, + get_completion_receipt_items_query, + get_completion_receipts_query, + get_default_bom, + get_default_route, + get_location_or_default, + get_operation_reports_query, + get_scrap_records_query, + get_work_order_materials_query, + get_work_order_material_issues_query, + get_work_order_operations_query, + get_work_orders_query, + issue_selected_stock_lots, + sync_recent_work_order_reference_qty, + sync_work_order_reference_qty, + sync_work_order_status, + to_decimal, + upsert_stock_balance, +) +from app.services.system_permissions import ensure_employee_has_permission +from app.services.system_config import ( + get_miniapp_operation_report_last_sync, + lock_miniapp_operation_report_last_sync, + upsert_miniapp_operation_report_last_sync, +) +from app.services.production_batch_ledger import ( + calculate_production_batch_ledger_snapshot, + lock_production_batch_ledger, + post_finished_inbound_to_ledger, + post_scrap_inbound_to_ledger, + post_surplus_inbound_to_ledger, + record_production_issue_to_ledger, +) +from app.services.production_work_order_ledger import ( + INBOUND_TOLERANCE_MULTIPLIER, + get_work_order_ledger_rows, + get_work_order_limit_snapshot, + validate_inbound_with_tolerance, +) +from app.services.production_work_order_inbound import ( + create_production_work_order_inbound, + preview_production_work_order_inbound, +) +from app.services.stocktake import ensure_warehouses_unlocked +router = APIRouter(dependencies=[Depends(require_authenticated_user)]) + +_MINIAPP_WORK_ORDER_SHORT_CODE_RE = re.compile(r"^JH(\d{4})$") +PRODUCTION_LEDGER_INBOUND_TXN_TYPES = {"成品入库", "生产余料入库", "生产废料入库"} + + +def _archive_fields_from_result( + result: DocumentArchiveGenerateResult | None, + *, + document_type: str, + business_id: int | None, +) -> dict[str, object]: + if result is None: + return { + "archive_status": "未生成", + "archive_business_id": business_id, + "archive_document_type": document_type if business_id else None, + "archive_error_message": None, + } + return { + "archive_status": result.archive_status, + "archive_business_id": business_id, + "archive_document_type": document_type, + "archive_error_message": result.archive_error_message, + } + + +def _failed_archive_result(document_type: str, business_id: int, document_no: str, exc: Exception) -> DocumentArchiveGenerateResult: + _ = exc + return DocumentArchiveGenerateResult( + business_id=business_id, + document_type=document_type, + document_no=document_no, + archive_status="归档失败", + archive_version=None, + archive_error_message="归档失败:PDF归档服务异常,请稍后重新生成", + ) + + +def _production_document_batch_no(now: datetime) -> str: + return f"生产结算{now.strftime('%Y%m%d%H%M%S')}{uuid4().int % 1_000_000:06d}" + + +def _with_document_batch_no(remark: str, batch_no: str | None) -> str: + if not batch_no: + return remark + return f"{remark};单据批次号:{batch_no}" + + +def _latest_archive_map(db: Session, document_type: str, business_ids: list[int]) -> dict[int, DocumentArchive]: + if not business_ids: + return {} + latest_version_subquery = ( + select( + DocumentArchive.business_id.label("business_id"), + func.max(DocumentArchive.archive_version).label("archive_version"), + ) + .where( + DocumentArchive.document_type == document_type, + DocumentArchive.file_format == FILE_FORMAT_PDF, + DocumentArchive.business_id.in_(business_ids), + ) + .group_by(DocumentArchive.business_id) + .subquery() + ) + latest_id_subquery = ( + select( + DocumentArchive.business_id.label("business_id"), + func.max(DocumentArchive.id).label("archive_id"), + ) + .join( + latest_version_subquery, + (latest_version_subquery.c.business_id == DocumentArchive.business_id) + & (latest_version_subquery.c.archive_version == DocumentArchive.archive_version), + ) + .where( + DocumentArchive.document_type == document_type, + DocumentArchive.file_format == FILE_FORMAT_PDF, + ) + .group_by(DocumentArchive.business_id) + .subquery() + ) + rows = db.scalars( + select(DocumentArchive).join(latest_id_subquery, latest_id_subquery.c.archive_id == DocumentArchive.id) + ).all() + return {int(row.business_id): row for row in rows} + + +def _code_token(value: str | None, fallback: str, max_length: int = 16) -> str: + token = "".join(ch for ch in str(value or "").upper() if ch.isalnum()) + return (token or fallback)[-max_length:] + + +def _next_doc_no(db: Session, model, column_name: str, prefix: str) -> str: + column = getattr(model, column_name) + count = db.scalar(select(func.count(model.id)).where(column.like(f"{prefix}-%"))) or 0 + return f"{prefix}-{count + 1:03d}" + + +def _next_optional_sequence_doc_no(db: Session, model, column_name: str, prefix: str, max_length: int = 80) -> str: + normalized_prefix = " ".join(str(prefix or "").split()).strip("- ") or "未命名" + sequence_prefix = normalized_prefix[: max(max_length - 4, 1)] + column = getattr(model, column_name) + if not db.scalar(select(model.id).where(column == sequence_prefix).limit(1)): + return sequence_prefix + count = db.scalar(select(func.count(model.id)).where(column.like(f"{sequence_prefix}-%"))) or 0 + return f"{sequence_prefix}-{count + 1:03d}" + + +def _next_yearly_sequence_work_order_no(db: Session, product: Item, now: datetime | None = None) -> str: + current_time = now or datetime.now() + year_part = current_time.strftime("%Y") + existing_nos = db.scalars(select(WorkOrder.work_order_no).where(WorkOrder.work_order_no.like(f"{year_part}____-%"))).all() + used_sequences = [ + int(work_order_no[4:8]) + for work_order_no in existing_nos + if len(work_order_no) > 8 and work_order_no[:4] == year_part and work_order_no[4:8].isdigit() and work_order_no[8] == "-" + ] + next_sequence = max(used_sequences, default=0) + 1 + product_name = " ".join(str(product.item_name or product.item_code or f"产品{product.id}").split()).strip("- ") or f"产品{product.id}" + max_product_name_length = max(80 - len(year_part) - 4 - 1, 1) + return f"{year_part}{next_sequence:04d}-{product_name[:max_product_name_length]}" + + +def _build_work_order_no(db: Session, product: Item) -> str: + return build_yearly_product_work_order_no(db, product) + + +def _miniapp_work_order_short_code_sequence(value: str | None) -> int | None: + match = _MINIAPP_WORK_ORDER_SHORT_CODE_RE.match(str(value or "").strip()) + if not match: + return None + return int(match.group(1)) + + +def _is_miniapp_work_order_short_code(value: str | None) -> bool: + return _miniapp_work_order_short_code_sequence(value) is not None + + +def _miniapp_syncable_work_order_status_filter(): + return func.upper(func.coalesce(WorkOrder.status, "")).notin_(("SETTLED", "CANCELED", "CANCELLED")) + + +def _resolve_work_order_by_miniapp_short_code(db: Session, short_code: str, row: dict) -> WorkOrder | None: + sequence = _miniapp_work_order_short_code_sequence(short_code) + if sequence is None: + return None + product_name = str(row.get("product_name") or "").strip() + project_no = str(row.get("project_no") or "").strip() + product_filters = [] + if product_name: + product_filters.append(Item.item_name == product_name) + if project_no: + product_filters.append(Item.item_code == project_no) + if not product_filters: + return None + + year_part = datetime.now().strftime("%Y") + work_order_prefix = f"{year_part}{sequence:04d}-" + candidates = db.scalars( + select(WorkOrder) + .join(Item, Item.id == WorkOrder.product_item_id) + .where( + WorkOrder.work_order_no.like(f"{work_order_prefix}%"), + _miniapp_syncable_work_order_status_filter(), + or_(*product_filters), + ) + .order_by(WorkOrder.id.desc()) + ).all() + if len(candidates) == 1: + return candidates[0] + return None + + + +def _build_scrap_no(db: Session, work_order: WorkOrder, disposal_type: str) -> str: + date_part = date.today().strftime("%Y%m%d") + type_part = "RWK" if disposal_type == "REWORK" else "SCRAP" + work_order_part = _code_token(work_order.work_order_no, f"WO{work_order.id:04d}", 18) + return _next_doc_no(db, ScrapRecord, "scrap_no", f"DSP-{type_part}-{date_part}-{work_order_part}") + + +def _build_completion_receipt_no(db: Session, work_order: WorkOrder) -> str: + date_part = date.today().strftime("%Y%m%d") + work_order_part = _code_token(work_order.work_order_no, f"WO{work_order.id:04d}", 18) + return _next_doc_no(db, CompletionReceipt, "receipt_no", f"FGI-NB-{date_part}-{work_order_part}") + + +def _build_operation_report_no(db: Session, work_order: WorkOrder, operation: WorkOrderOperation, employee: Employee) -> str: + date_part = date.today().strftime("%Y%m%d") + work_order_part = _code_token(work_order.work_order_no, f"WO{work_order.id:04d}", 12) + operation_part = f"OP{int(operation.seq_no or 0):02d}" + employee_part = _code_token(employee.employee_code or employee.employee_name, f"EMP{employee.id:04d}", 8) + return _next_doc_no(db, OperationReport, "report_no", f"RPT-NB-{date_part}-{work_order_part}-{operation_part}-{employee_part}") + + +def _production_ledger_txn_read( + txn: ProductionBatchLedgerTxn, + archive: DocumentArchive | None = None, + archive_business_id: int | None = None, +) -> dict: + return { + "production_ledger_txn_id": txn.id, + "production_ledger_id": txn.production_ledger_id, + "txn_type": txn.txn_type, + "qty_delta": float(to_decimal(txn.qty_delta)), + "weight_delta_kg": float(to_decimal(txn.weight_delta_kg)), + "outside_weight_after_kg": float(to_decimal(txn.outside_weight_after_kg)), + "source_doc_type": txn.source_doc_type, + "source_doc_id": txn.source_doc_id, + "source_line_id": txn.source_line_id, + "biz_time": txn.biz_time, + "operator_user_id": txn.operator_user_id, + "remark": txn.remark, + "archive_status": archive.status if archive else (ARCHIVE_STATUS_MISSING if archive_business_id else None), + "archive_business_id": archive_business_id, + "archive_document_type": DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT if archive_business_id else None, + "archive_error_message": archive.error_message if archive else None, + } + + +def _build_finished_lot_no(db: Session, work_order: WorkOrder, product: Item, receipt_time: datetime, line_no: int) -> str: + date_part = receipt_time.strftime("%Y%m%d") + product_part = _code_token(product.item_code or product.item_name, f"FG{product.id:04d}", 12) + work_order_part = _code_token(work_order.work_order_no, f"WO{work_order.id:04d}", 10) + base_lot_no = f"FGL-NB-{date_part}-{product_part}-{work_order_part}-L{line_no:02d}" + candidate = base_lot_no + suffix = 1 + while db.scalar(select(func.count(StockLot.id)).where(StockLot.lot_no == candidate)): + suffix += 1 + candidate = f"{base_lot_no}-{suffix:02d}" + return candidate + + +def _get_active_warehouse(db: Session, warehouse_type: str, operation_name: str) -> Warehouse: + warehouse = db.scalar( + select(Warehouse) + .where(Warehouse.warehouse_type == warehouse_type, Warehouse.status == "ACTIVE") + .order_by(Warehouse.id) + .limit(1) + ) + if not warehouse: + warehouse_name = {"FINISHED": "成品库", "SCRAP": "废料库", "RAW": "原材料库"}.get(warehouse_type, "目标仓库") + raise HTTPException(status_code=400, detail=f"{operation_name}缺少启用的{warehouse_name}") + return warehouse + + +def _build_production_ledger_lot_no(db: Session, prefix: str, ledger: ProductionBatchLedger, item: Item, biz_time: datetime) -> str: + item_part = _code_token(item.item_code or item.item_name, f"ITEM{item.id:04d}", 12) + source_part = _code_token(ledger.material_lot_no, f"LOT{ledger.material_lot_id:04d}", 10) + return _next_doc_no(db, StockLot, "lot_no", f"{prefix}-NB-{biz_time.strftime('%Y%m%d')}-{item_part}-{source_part}") + + +def _issue_raw_lot_to_production_ledger( + db: Session, + *, + lot: StockLot, + material_item_id: int, + issued_weight: Decimal, + production_ledger_id: int, + operator_user_id: int | None, + biz_time: datetime, +) -> InventoryTxn: + if issued_weight <= 0: + raise HTTPException(status_code=400, detail="出库重量必须大于0") + if not lot or int(lot.item_id) != int(material_item_id): + raise HTTPException(status_code=400, detail="库存批次不存在或物料不匹配") + warehouse = db.get(Warehouse, lot.warehouse_id) + if not warehouse or warehouse.warehouse_type != "RAW": + raise HTTPException(status_code=400, detail="库存批次不属于原材料库") + usable_weight = clamp_non_negative(to_decimal(lot.remaining_weight_kg) - to_decimal(lot.locked_weight_kg)) + if lot.quality_status != "PASS" or lot.status != "AVAILABLE" or usable_weight <= 0: + raise HTTPException(status_code=400, detail=f"库存批次 {lot.lot_no} 不可用") + if issued_weight > usable_weight: + raise HTTPException(status_code=400, detail=f"库存批次 {lot.lot_no} 剩余重量不足") + + lot.remaining_qty = Decimal("0") + lot.remaining_weight_kg = clamp_non_negative(to_decimal(lot.remaining_weight_kg) - issued_weight) + if to_decimal(lot.remaining_weight_kg) <= 0: + lot.status = "DEPLETED" + db.add(lot) + + unit_cost = to_decimal(lot.unit_cost, "0.0001") + upsert_stock_balance( + db, + item_id=material_item_id, + warehouse_id=lot.warehouse_id, + location_id=lot.location_id, + qty_delta=Decimal("0"), + weight_delta=-issued_weight, + available_qty_delta=Decimal("0"), + available_weight_delta=-issued_weight, + unit_cost=unit_cost, + ) + txn = create_inventory_txn( + db, + txn_type="MATERIAL_ISSUE", + item_id=material_item_id, + warehouse_id=lot.warehouse_id, + location_id=lot.location_id, + lot_id=lot.id, + qty_change=Decimal("0"), + weight_change=-issued_weight, + unit_cost=unit_cost, + source_doc_type="生产台账", + source_doc_id=production_ledger_id, + source_line_id=None, + biz_time=biz_time, + operator_user_id=operator_user_id, + remark=f"生产出库:库存批次号 {lot.lot_no}", + amount_basis="WEIGHT", + ) + db.add(txn) + db.flush() + return txn + + +def _link_latest_issue_ledger_txn_to_inventory_txn( + db: Session, + *, + production_ledger_id: int, + inventory_txn: InventoryTxn, +) -> None: + ledger_txn = db.scalar( + select(ProductionBatchLedgerTxn) + .where( + ProductionBatchLedgerTxn.production_ledger_id == production_ledger_id, + ProductionBatchLedgerTxn.txn_type == "生产出库", + ) + .order_by(ProductionBatchLedgerTxn.id.desc()) + .limit(1) + ) + if not ledger_txn: + return + ledger_txn.source_doc_type = "库存流水" + ledger_txn.source_doc_id = inventory_txn.id + ledger_txn.source_line_id = inventory_txn.id + db.add(ledger_txn) + + +def _build_production_ledger_work_order_compat_read( + *, + production_ledger_id: int, + material_lot_no: str, + product: Item, + route, + bom, + planned_qty: Decimal, + payload: WorkOrderCreate, + biz_time: datetime, + status: str, + operation_count: int, +) -> WorkOrderRead: + return WorkOrderRead( + work_order_id=production_ledger_id, + work_order_no=material_lot_no, + work_order_type="正常生产", + source_sales_order_item_id=payload.source_sales_order_item_id, + source_order_no=None, + product_item_id=product.id, + product_code=product.item_code, + product_name=product.item_name, + product_version_no=None, + route_id=route.id, + route_name=route.route_name, + bom_id=bom.id, + planned_qty=float(planned_qty), + released_qty=float(planned_qty), + finished_qty=0, + scrap_qty=0, + planned_start_time=payload.planned_start_time or biz_time, + planned_end_time=payload.planned_end_time, + actual_start_time=None, + actual_end_time=None, + priority_level=payload.priority_level or 3, + status=status, + remark=payload.remark, + operation_count=operation_count, + abnormal_operation_count=0, + reported_good_qty=0, + received_qty=0, + max_receivable_qty=float(planned_qty), + all_operations_done=False, + ) + + +def _calculate_actual_cycle_seconds(start_time: datetime, end_time: datetime, report_qty: Decimal) -> float | None: + if report_qty <= 0 or end_time <= start_time: + return None + return round((end_time - start_time).total_seconds() / float(report_qty), 2) + + +def _build_operation_abnormal_reason(row: dict, actual_cycle_seconds: float | None) -> str | None: + reasons: list[str] = [] + report_qty = to_decimal(row.get("report_qty")) + scrap_qty = to_decimal(row.get("scrap_qty")) + allowed_scrap_rate = to_decimal(row.get("allowed_scrap_rate"), "0.0001") + ref_cycle_seconds = to_decimal(row.get("ref_cycle_seconds"), "0.01") + + if report_qty > 0 and allowed_scrap_rate >= 0: + actual_scrap_rate = scrap_qty / report_qty if report_qty > 0 else Decimal("0") + if actual_scrap_rate > allowed_scrap_rate: + reasons.append( + f"报废率超标 {round(float(actual_scrap_rate * 100), 2)}% > {round(float(allowed_scrap_rate * 100), 2)}%" + ) + + if actual_cycle_seconds is not None and ref_cycle_seconds > 0 and Decimal(str(actual_cycle_seconds)) > ref_cycle_seconds: + reasons.append(f"实际节拍 {actual_cycle_seconds}s/件 慢于参考节拍 {float(ref_cycle_seconds):.2f}s/件") + + scrap_reason = row.get("scrap_reason") + if scrap_reason: + reasons.append(f"异常说明:{scrap_reason}") + + if not reasons: + return None + return ";".join(reasons) + + +def _build_operation_report_read(row: dict) -> OperationReportRead: + payload = dict(row) + actual_cycle_seconds = _calculate_actual_cycle_seconds( + payload["start_time"], + payload["end_time"], + to_decimal(payload.get("report_qty")), + ) + payload["ref_cycle_seconds"] = float(payload.get("ref_cycle_seconds") or 0) + payload["actual_cycle_seconds"] = actual_cycle_seconds + payload["abnormal_reason"] = _build_operation_abnormal_reason(payload, actual_cycle_seconds) + return OperationReportRead.model_validate(payload) + + +def _get_average_issued_weight_cost(db: Session, work_order_id: int) -> Decimal: + row = db.execute( + select( + func.coalesce(func.sum(InventoryTxn.amount), 0).label("total_amount"), + func.coalesce(func.sum(InventoryTxn.weight_change_kg), 0).label("total_weight"), + ).where( + InventoryTxn.txn_type == "MATERIAL_ISSUE", + InventoryTxn.source_doc_type == "WORK_ORDER", + InventoryTxn.source_doc_id == work_order_id, + ) + ).mappings().first() + if not row: + return Decimal("0") + total_amount = to_decimal(row["total_amount"], "0.01") + total_weight = abs(to_decimal(row["total_weight"])) + if total_weight <= 0: + return Decimal("0") + return total_amount / total_weight + + +def _get_active_scrap_record(db: Session, report_id: int) -> ScrapRecord | None: + return db.scalar( + select(ScrapRecord) + .where( + ScrapRecord.report_id == report_id, + ScrapRecord.status == "CONFIRMED", + ) + .order_by(ScrapRecord.id.desc()) + .limit(1) + ) + + +def _revert_scrap_record_effect(work_order: WorkOrder, record: ScrapRecord) -> None: + if record.disposal_type == "SCRAP": + work_order.scrap_qty = max(Decimal("0"), to_decimal(work_order.scrap_qty) - to_decimal(record.scrap_qty)) + + +MINIAPP_REPORT_STATUS_LABELS = { + "pending": "待审核", + "approved": "已审核", + "rejected": "已驳回", +} + + +def _miniapp_report_no(report_id: int, item_id: int) -> str: + return f"WRS-{report_id}-{item_id}" + + +def _miniapp_effective_scrap_qty(row: dict) -> Decimal: + scrap_qty = to_decimal(row.get("miniapp_scrap_qty")) + defect_qty = to_decimal(row.get("defect_qty")) + return scrap_qty if scrap_qty > 0 else defect_qty + + +def _new_miniapp_resolve_cache() -> dict[str, dict]: + return { + "production_ledger": {}, + "work_order": {}, + "operation": {}, + "employee": {}, + "report": {}, + } + + +def _miniapp_report_item_rows( + db: Session, + *, + limit: int | None = 300, + source_item_id: int | None = None, + start_date: date | None = None, + end_date: date | None = None, + updated_after: datetime | None = None, + updated_until: datetime | None = None, + order_by_updated_asc: bool = False, +) -> list[dict]: + stmt = ( + select( + MiniAppProductionReport.id.label("source_report_id"), + MiniAppProductionReportItem.id.label("source_item_id"), + func.coalesce( + MiniAppProductionReportItem.attendance_point_name, + MiniAppProductionReport.attendance_point_name, + ).label("attendance_point_name"), + MiniAppProductionReport.employee_phone.label("employee_phone"), + MiniAppPersonnel.name.label("employee_name"), + MiniAppProductionReport.report_date.label("report_date"), + MiniAppProductionReport.start_at.label("start_time"), + MiniAppProductionReport.end_at.label("end_time"), + MiniAppProductionReport.duration_minutes.label("duration_minutes"), + MiniAppProductionReport.effective_minutes.label("effective_minutes"), + MiniAppProductionReport.status.label("status"), + MiniAppProductionReport.reviewer_phone.label("reviewer_phone"), + MiniAppProductionReport.reviewed_at.label("reviewed_at"), + MiniAppProductionReport.reject_reason.label("reject_reason"), + MiniAppProductionReportItem.device_no.label("device_no"), + MiniAppProductionReportItem.project_no.label("project_no"), + MiniAppProductionReportItem.product_name.label("product_name"), + MiniAppProductionReportItem.material_code.label("material_code"), + MiniAppProductionReportItem.material_name.label("material_name"), + MiniAppProductionReportItem.raw_material_batch_no.label("raw_material_batch_no"), + MiniAppProductionReportItem.process_name.label("process_name"), + MiniAppProductionReportItem.stamping_method.label("stamping_method"), + MiniAppProductionReportItem.standard_beat.label("standard_beat"), + MiniAppProductionReportItem.standard_workload.label("standard_workload"), + MiniAppProductionReportItem.good_qty.label("good_qty"), + MiniAppProductionReportItem.defect_qty.label("defect_qty"), + MiniAppProductionReportItem.scrap_qty.label("miniapp_scrap_qty"), + MiniAppProductionReportItem.allocated_minutes.label("allocated_minutes"), + MiniAppProductionReportItem.started_at.label("started_at"), + ) + .join(MiniAppProductionReport, MiniAppProductionReport.id == MiniAppProductionReportItem.report_id) + .outerjoin(MiniAppPersonnel, MiniAppPersonnel.phone == MiniAppProductionReport.employee_phone) + ) + if source_item_id is not None: + stmt = stmt.where(MiniAppProductionReportItem.id == source_item_id) + if start_date is not None: + stmt = stmt.where(MiniAppProductionReport.report_date >= start_date) + if end_date is not None: + stmt = stmt.where(MiniAppProductionReport.report_date <= end_date) + if updated_after is not None: + stmt = stmt.where(MiniAppProductionReport.updated_at > updated_after) + if updated_until is not None: + stmt = stmt.where(MiniAppProductionReport.updated_at <= updated_until) + if order_by_updated_asc: + stmt = stmt.order_by(MiniAppProductionReport.updated_at.asc(), MiniAppProductionReportItem.id.asc()) + else: + stmt = stmt.order_by(MiniAppProductionReportItem.id.desc()) + if limit is not None: + stmt = stmt.limit(limit) + rows = db.execute(stmt).mappings().all() + return [dict(row) for row in rows] + + +def _preload_miniapp_operation_reports(db: Session, report_nos: list[str], cache: dict[str, dict]) -> None: + if not report_nos: + return + unique_report_nos = list(dict.fromkeys(report_nos)) + existing_reports = db.scalars(select(OperationReport).where(OperationReport.report_no.in_(unique_report_nos))).all() + cache["report"].update({report_no: None for report_no in unique_report_nos}) + cache["report"].update({report.report_no: report for report in existing_reports}) + + +def _get_miniapp_report_item_row(db: Session, source_item_id: int) -> dict: + rows = _miniapp_report_item_rows(db, limit=1, source_item_id=source_item_id) + if rows: + return rows[0] + raise HTTPException(status_code=404, detail="小程序报工明细不存在") + + +def _miniapp_report_matches_target( + report: OperationReport | None, + work_order: WorkOrder | None, + operation: WorkOrderOperation | None, +) -> bool: + return bool( + report + and work_order + and operation + and int(report.work_order_id) == int(work_order.id) + and int(report.work_order_operation_id) == int(operation.id) + ) + + +def _miniapp_report_matches_production_ledger( + report: OperationReport | None, + production_ledger: ProductionBatchLedger | None, +) -> bool: + return bool( + report + and production_ledger + and report.production_ledger_id + and int(report.production_ledger_id) == int(production_ledger.id) + ) + + +def _unlink_stale_miniapp_report( + db: Session, + report: OperationReport | None, + *, + cache: dict[str, dict] | None = None, +) -> None: + if not report: + return + + scrap_count = db.scalar(select(func.count(ScrapRecord.id)).where(ScrapRecord.report_id == report.id)) or 0 + if scrap_count: + # Do not delete a report already used by disposal records. It will be hidden from miniapp sync state + # by strict matching, and manual correction can handle the dependent disposal data if needed. + return + + operation = db.get(WorkOrderOperation, report.work_order_operation_id) if report.work_order_operation_id else None + work_order_id = int(report.work_order_id) if report.work_order_id else None + if operation: + operation.report_qty = max(Decimal("0"), to_decimal(operation.report_qty) - to_decimal(report.report_qty)) + operation.good_qty = max(Decimal("0"), to_decimal(operation.good_qty) - to_decimal(report.good_qty)) + operation.scrap_qty = max(Decimal("0"), to_decimal(operation.scrap_qty) - to_decimal(report.scrap_qty)) + operation.rework_qty = max(Decimal("0"), to_decimal(operation.rework_qty) - to_decimal(report.rework_qty)) + report_qty = to_decimal(operation.report_qty) + planned_qty = to_decimal(operation.planned_qty) + if planned_qty > 0 and report_qty >= planned_qty: + operation.status = "DONE" + elif report_qty > 0: + operation.status = "IN_PROGRESS" + else: + operation.status = "PENDING" + operation.is_abnormal = 1 if report_qty > 0 and ( + to_decimal(operation.scrap_qty) / report_qty + ) > to_decimal(operation.allowed_scrap_rate, "0.0001") else 0 + db.add(operation) + + report_no = report.report_no + db.delete(report) + db.flush() + if cache is not None: + cache["report"][report_no] = None + if work_order_id: + sync_work_order_status(db, work_order_id) + + +def _select_miniapp_issue_candidate( + db: Session, + candidates: list[WorkOrderMaterialIssue], + row: dict, +) -> WorkOrderMaterialIssue | None: + unsettled_work_order_ids = { + work_order_id + for (work_order_id,) in db.execute( + select(WorkOrder.id).where( + WorkOrder.id.in_({issue.work_order_id for issue in candidates}), + _miniapp_syncable_work_order_status_filter(), + ) + ).all() + } + latest_by_work_order: dict[int, WorkOrderMaterialIssue] = {} + for issue in candidates: + if issue.work_order_id not in unsettled_work_order_ids: + continue + latest_by_work_order.setdefault(issue.work_order_id, issue) + if len(latest_by_work_order) == 1: + return next(iter(latest_by_work_order.values())) + + product_name = str(row.get("product_name") or "").strip() + project_no = str(row.get("project_no") or "").strip() + if not product_name and not project_no: + return None + + matched_work_order_ids: set[int] = set() + product_rows = db.execute( + select(WorkOrder.id, Item.item_name, Item.item_code) + .join(Item, Item.id == WorkOrder.product_item_id) + .where( + WorkOrder.id.in_(latest_by_work_order), + _miniapp_syncable_work_order_status_filter(), + ) + ).all() + for work_order_id, item_name, item_code in product_rows: + if product_name and str(item_name or "").strip() == product_name: + matched_work_order_ids.add(work_order_id) + if project_no and str(item_code or "").strip() == project_no: + matched_work_order_ids.add(work_order_id) + if len(matched_work_order_ids) == 1: + return latest_by_work_order[next(iter(matched_work_order_ids))] + return None + + +def _resolve_work_order_issue_by_inventory_lot_no( + db: Session, + lot_no: str, + row: dict, +) -> tuple[WorkOrderMaterialIssue | None, bool]: + candidates = db.scalars( + select(WorkOrderMaterialIssue) + .join(WorkOrder, WorkOrder.id == WorkOrderMaterialIssue.work_order_id) + .join(StockLot, StockLot.id == WorkOrderMaterialIssue.source_lot_id) + .where( + StockLot.lot_no == lot_no, + _miniapp_syncable_work_order_status_filter(), + ) + .order_by(WorkOrderMaterialIssue.id.desc()) + ).all() + if candidates: + return _select_miniapp_issue_candidate(db, list(candidates), row), True + has_filtered_source_lot_candidate = bool(db.scalar( + select(func.count(WorkOrderMaterialIssue.id)) + .join(StockLot, StockLot.id == WorkOrderMaterialIssue.source_lot_id) + .where(StockLot.lot_no == lot_no) + ) or 0) + candidates = db.scalars( + select(WorkOrderMaterialIssue) + .join(WorkOrder, WorkOrder.id == WorkOrderMaterialIssue.work_order_id) + .where( + WorkOrderMaterialIssue.material_sub_batch_no == lot_no, + _miniapp_syncable_work_order_status_filter(), + ) + .order_by(WorkOrderMaterialIssue.id.desc()) + ).all() + if candidates: + return _select_miniapp_issue_candidate(db, list(candidates), row), True + has_filtered_sub_batch_candidate = bool(db.scalar( + select(func.count(WorkOrderMaterialIssue.id)).where(WorkOrderMaterialIssue.material_sub_batch_no == lot_no) + ) or 0) + if has_filtered_source_lot_candidate or has_filtered_sub_batch_candidate: + return None, True + return None, False + + +def _find_work_order_for_miniapp_item(db: Session, row: dict, cache: dict[str, dict] | None = None) -> WorkOrder | None: + batch_no = str(row.get("raw_material_batch_no") or "").strip() + product_name = str(row.get("product_name") or "").strip() + project_no = str(row.get("project_no") or "").strip() + cache_key = ("batch", batch_no, product_name, project_no) + if cache is not None and cache_key in cache["work_order"]: + return cache["work_order"][cache_key] + + if not batch_no: + if cache is not None: + cache["work_order"][cache_key] = None + return None + + issue, has_issue_candidates = _resolve_work_order_issue_by_inventory_lot_no(db, batch_no, row) + work_order = db.get(WorkOrder, issue.work_order_id) if issue else None + if not work_order and _is_miniapp_work_order_short_code(batch_no): + work_order = _resolve_work_order_by_miniapp_short_code(db, batch_no, row) + if not work_order and not has_issue_candidates: + work_order = db.scalar( + select(WorkOrder) + .where( + WorkOrder.work_order_no == batch_no, + _miniapp_syncable_work_order_status_filter(), + ) + .limit(1) + ) + if work_order: + if cache is not None: + cache["work_order"][cache_key] = work_order + return work_order + if cache is not None: + cache["work_order"][cache_key] = work_order + return work_order + + +def _find_production_ledger_for_miniapp_item( + db: Session, + row: dict, + cache: dict[str, dict] | None = None, +) -> ProductionBatchLedger | None: + batch_no = str(row.get("raw_material_batch_no") or "").strip() + product_name = str(row.get("product_name") or "").strip() + project_no = str(row.get("project_no") or "").strip() + cache_key = ("production_ledger", batch_no, product_name, project_no) + if cache is not None and cache_key in cache["production_ledger"]: + return cache["production_ledger"][cache_key] + if not batch_no or (not product_name and not project_no): + if cache is not None: + cache["production_ledger"][cache_key] = None + return None + product_filters = [] + if product_name: + product_filters.append(Item.item_name == product_name) + if project_no: + product_filters.append(Item.item_code == project_no) + ledger = db.scalar( + select(ProductionBatchLedger) + .join(Item, Item.id == ProductionBatchLedger.product_item_id) + .where( + ProductionBatchLedger.material_lot_no == batch_no, + ProductionBatchLedger.status == "在生产", + ProductionBatchLedger.miniapp_selectable == 1, + or_(*product_filters), + ) + .order_by(ProductionBatchLedger.id.desc()) + .limit(1) + ) + if cache is not None: + cache["production_ledger"][cache_key] = ledger + return ledger + + +def _find_work_order_operation_for_miniapp_item( + db: Session, + work_order_id: int, + process_name: str | None, + cache: dict[str, dict] | None = None, + *, + allow_fallback: bool = True, +) -> WorkOrderOperation | None: + normalized_process = str(process_name or "").strip() + cache_key = (work_order_id, normalized_process, allow_fallback) + if cache is not None and cache_key in cache["operation"]: + return cache["operation"][cache_key] + operation = None + if normalized_process: + operation = db.scalar( + select(WorkOrderOperation) + .where( + WorkOrderOperation.work_order_id == work_order_id, + WorkOrderOperation.operation_name == normalized_process, + ) + .order_by(WorkOrderOperation.seq_no) + .limit(1) + ) + if operation: + if cache is not None: + cache["operation"][cache_key] = operation + return operation + if not allow_fallback: + if cache is not None: + cache["operation"][cache_key] = None + return None + operation = db.scalar( + select(WorkOrderOperation) + .where(WorkOrderOperation.work_order_id == work_order_id) + .order_by(WorkOrderOperation.seq_no) + .limit(1) + ) + if cache is not None: + cache["operation"][cache_key] = operation + return operation + + +def _get_or_create_miniapp_employee(db: Session, row: dict, cache: dict[str, dict] | None = None) -> Employee: + phone = str(row.get("employee_phone") or "").strip() + employee_name = str(row.get("employee_name") or "").strip() or phone or "小程序员工" + cache_key = phone or f"name:{employee_name}" + if cache is not None and cache_key in cache["employee"]: + return cache["employee"][cache_key] + if phone: + employee = db.scalar(select(Employee).where(Employee.mobile == phone).order_by(Employee.id.desc()).limit(1)) + if employee: + if employee.employee_name != employee_name: + employee.employee_name = employee_name + db.add(employee) + if cache is not None: + cache["employee"][cache_key] = employee + return employee + + department = db.scalar(select(Department).order_by(Department.id).limit(1)) + if not department: + raise HTTPException(status_code=400, detail="ERP 未配置部门,无法把小程序报工员工映射到 ERP 员工档案") + employee = Employee( + employee_code=next_employee_code(db), + employee_name=employee_name, + dept_id=department.id, + mobile=phone or None, + gender=None, + hire_date=None, + job_title="小程序报工员工", + shift_code=None, + manager_employee_id=None, + is_operator=1, + is_workshop_staff=1, + status="ACTIVE", + remark="由小程序报工同步生成", + ) + db.add(employee) + db.flush() + if cache is not None: + cache["employee"][cache_key] = employee + return employee + + +def _sync_miniapp_item_to_erp( + db: Session, + source_item_id: int, + *, + required: bool = False, + row: dict | None = None, + cache: dict[str, dict] | None = None, + sync_status: bool = True, +) -> OperationReport | None: + if row is None: + row = _get_miniapp_report_item_row(db, source_item_id) + report_no = _miniapp_report_no(int(row["source_report_id"]), int(row["source_item_id"])) + if cache is not None and report_no in cache["report"]: + report = cache["report"][report_no] + else: + report = db.scalar(select(OperationReport).where(OperationReport.report_no == report_no).limit(1)) + if cache is not None: + cache["report"][report_no] = report + if str(row.get("status") or "") == "rejected": + _unlink_stale_miniapp_report(db, report, cache=cache) + if required: + raise HTTPException(status_code=400, detail="该小程序报工已驳回,不能同步为 ERP 报工") + return None + + production_ledger = _find_production_ledger_for_miniapp_item(db, row, cache) + work_order = None if production_ledger else _find_work_order_for_miniapp_item(db, row, cache) + if not work_order: + if not production_ledger: + _unlink_stale_miniapp_report(db, report, cache=cache) + if required: + raise HTTPException(status_code=400, detail="小程序报工缺少有效库存批次号,不能同步为 ERP 报工") + return None + operation = None + if work_order: + operation = _find_work_order_operation_for_miniapp_item( + db, + work_order.id, + row.get("process_name"), + cache, + allow_fallback=not _is_miniapp_work_order_short_code(row.get("raw_material_batch_no")), + ) + if not operation: + _unlink_stale_miniapp_report(db, report, cache=cache) + if required: + raise HTTPException(status_code=400, detail="该库存批次缺少可匹配的小程序报工工序") + return None + + employee = _get_or_create_miniapp_employee(db, row, cache) + start_time = row.get("started_at") or row.get("start_time") or datetime.now() + end_time = row.get("end_time") or start_time + timedelta(minutes=float(row.get("allocated_minutes") or 0) or 1) + if end_time <= start_time: + end_time = start_time + timedelta(minutes=1) + + good_qty = to_decimal(row.get("good_qty")) + scrap_qty = _miniapp_effective_scrap_qty(row) + report_qty = good_qty + scrap_qty + if report_qty <= 0: + if required: + raise HTTPException(status_code=400, detail="该小程序报工没有有效产出或报废数量") + return None + + if production_ledger: + if report and not _miniapp_report_matches_production_ledger(report, production_ledger): + _unlink_stale_miniapp_report(db, report, cache=cache) + report = None + elif report and not _miniapp_report_matches_target(report, work_order, operation): + _unlink_stale_miniapp_report(db, report, cache=cache) + report = None + old_report_qty = to_decimal(report.report_qty) if report else Decimal("0") + old_good_qty = to_decimal(report.good_qty) if report else Decimal("0") + old_scrap_qty = to_decimal(report.scrap_qty) if report else Decimal("0") + + actual_cycle_seconds = _calculate_actual_cycle_seconds(start_time, end_time, report_qty) + ref_cycle_seconds = to_decimal(row.get("standard_beat"), "0.01") + abnormal = 0 + if operation and report_qty > 0 and (scrap_qty / report_qty) > to_decimal(operation.allowed_scrap_rate, "0.0001"): + abnormal = 1 + if actual_cycle_seconds is not None and ref_cycle_seconds > 0 and Decimal(str(actual_cycle_seconds)) > ref_cycle_seconds: + abnormal = 1 + + if not report: + report = OperationReport( + report_no=report_no, + work_order_id=work_order.id if work_order else None, + work_order_operation_id=operation.id if operation else None, + production_ledger_id=production_ledger.id if production_ledger else None, + employee_id=employee.id, + equipment_id=None, + report_source="MINIAPP", + shift_code=None, + start_time=start_time, + end_time=end_time, + report_qty=report_qty, + good_qty=good_qty, + scrap_qty=scrap_qty, + rework_qty=to_decimal(0), + scrap_reason=None, + downtime_minutes=to_decimal(0, "0.01"), + extra_cost_amount=to_decimal(0, "0.01"), + is_abnormal=abnormal, + approved_by=None, + approved_at=None, + remark=( + f"小程序报工同步:考勤点={row.get('attendance_point_name') or '-'} " + f"report_id={row['source_report_id']} item_id={row['source_item_id']}" + ), + ) + else: + report.work_order_id = work_order.id if work_order else None + report.work_order_operation_id = operation.id if operation else None + report.production_ledger_id = production_ledger.id if production_ledger else None + report.employee_id = employee.id + report.report_source = "MINIAPP" + report.start_time = start_time + report.end_time = end_time + report.report_qty = report_qty + report.good_qty = good_qty + report.scrap_qty = scrap_qty + report.rework_qty = to_decimal(0) + report.is_abnormal = abnormal + report.remark = ( + f"小程序报工同步:考勤点={row.get('attendance_point_name') or '-'} " + f"report_id={row['source_report_id']} item_id={row['source_item_id']}" + ) + db.add(report) + + if operation and work_order: + delta_report_qty = report_qty - old_report_qty + delta_good_qty = good_qty - old_good_qty + delta_scrap_qty = scrap_qty - old_scrap_qty + operation.report_qty = max(Decimal("0"), to_decimal(operation.report_qty) + delta_report_qty) + operation.good_qty = max(Decimal("0"), to_decimal(operation.good_qty) + delta_good_qty) + operation.scrap_qty = max(Decimal("0"), to_decimal(operation.scrap_qty) + delta_scrap_qty) + if abnormal: + operation.is_abnormal = 1 + if to_decimal(operation.report_qty) >= to_decimal(operation.planned_qty): + operation.status = "DONE" + elif to_decimal(operation.report_qty) > 0: + operation.status = "IN_PROGRESS" + db.add(operation) + + if work_order.actual_start_time is None: + work_order.actual_start_time = start_time + db.add(work_order) + if sync_status: + sync_work_order_status(db, work_order.id) + db.flush() + if cache is not None: + cache["report"][report_no] = report + return report + + +def _build_miniapp_operation_report_read( + db: Session, + row: dict, + report: OperationReport | None = None, + cache: dict[str, dict] | None = None, + resolve_targets: bool = True, +) -> MiniAppOperationReportRead: + if not report: + report_no = _miniapp_report_no(int(row["source_report_id"]), int(row["source_item_id"])) + if cache is not None and report_no in cache["report"]: + report = cache["report"][report_no] + else: + report = db.scalar(select(OperationReport).where(OperationReport.report_no == report_no).limit(1)) + if cache is not None: + cache["report"][report_no] = report + erp_scrap_qty = _miniapp_effective_scrap_qty(row) + status = str(row.get("status") or "pending") + if not resolve_targets: + return MiniAppOperationReportRead( + source_report_id=int(row["source_report_id"]), + source_item_id=int(row["source_item_id"]), + report_no=_miniapp_report_no(int(row["source_report_id"]), int(row["source_item_id"])), + attendance_point_name=str(row.get("attendance_point_name") or ""), + employee_phone=str(row.get("employee_phone") or ""), + employee_name=str(row.get("employee_name") or ""), + report_date=row["report_date"], + start_time=row["start_time"], + end_time=row["end_time"], + duration_minutes=float(row.get("duration_minutes") or 0), + effective_minutes=float(row.get("effective_minutes") or 0), + status=status, + status_name=MINIAPP_REPORT_STATUS_LABELS.get(status, status), + reviewer_phone=row.get("reviewer_phone"), + reviewed_at=row.get("reviewed_at"), + reject_reason=row.get("reject_reason"), + device_no=str(row.get("device_no") or ""), + project_no=str(row.get("project_no") or ""), + product_name=str(row.get("product_name") or ""), + material_code=row.get("material_code"), + material_name=row.get("material_name"), + raw_material_batch_no=row.get("raw_material_batch_no"), + process_name=row.get("process_name"), + standard_beat=float(row.get("standard_beat") or 0), + standard_workload=float(row.get("standard_workload") or 0), + good_qty=float(row.get("good_qty") or 0), + defect_qty=float(row.get("defect_qty") or 0), + scrap_qty=float(row.get("miniapp_scrap_qty") or 0), + erp_scrap_qty=float(erp_scrap_qty), + allocated_minutes=float(row.get("allocated_minutes") or 0), + started_at=row.get("started_at"), + work_order_id=report.work_order_id if report else None, + work_order_no=row.get("raw_material_batch_no"), + work_order_operation_id=report.work_order_operation_id if report else None, + operation_name=row.get("process_name"), + erp_report_id=report.id if report else None, + erp_report_no=report.report_no if report else None, + is_synced_to_erp=bool(report), + abnormal_reason=None, + ) + production_ledger = _find_production_ledger_for_miniapp_item(db, row, cache) + work_order = None if production_ledger else _find_work_order_for_miniapp_item(db, row, cache) + operation = ( + _find_work_order_operation_for_miniapp_item( + db, + work_order.id, + row.get("process_name"), + cache, + allow_fallback=not _is_miniapp_work_order_short_code(row.get("raw_material_batch_no")), + ) + if work_order + else None + ) + synced_report = ( + report + if ( + _miniapp_report_matches_production_ledger(report, production_ledger) + or _miniapp_report_matches_target(report, work_order, operation) + ) + else None + ) + report_qty = to_decimal(row.get("good_qty")) + erp_scrap_qty + abnormal_reason = None + if report_qty > 0 and operation: + scrap_rate = erp_scrap_qty / report_qty + if scrap_rate > to_decimal(operation.allowed_scrap_rate, "0.0001"): + abnormal_reason = ( + f"报废率超标 {round(float(scrap_rate * 100), 2)}% > " + f"{round(float(to_decimal(operation.allowed_scrap_rate, '0.0001') * 100), 2)}%" + ) + return MiniAppOperationReportRead( + source_report_id=int(row["source_report_id"]), + source_item_id=int(row["source_item_id"]), + report_no=_miniapp_report_no(int(row["source_report_id"]), int(row["source_item_id"])), + attendance_point_name=str(row.get("attendance_point_name") or ""), + employee_phone=str(row.get("employee_phone") or ""), + employee_name=str(row.get("employee_name") or ""), + report_date=row["report_date"], + start_time=row["start_time"], + end_time=row["end_time"], + duration_minutes=float(row.get("duration_minutes") or 0), + effective_minutes=float(row.get("effective_minutes") or 0), + status=status, + status_name=MINIAPP_REPORT_STATUS_LABELS.get(status, status), + reviewer_phone=row.get("reviewer_phone"), + reviewed_at=row.get("reviewed_at"), + reject_reason=row.get("reject_reason"), + device_no=str(row.get("device_no") or ""), + project_no=str(row.get("project_no") or ""), + product_name=str(row.get("product_name") or ""), + material_code=row.get("material_code"), + material_name=row.get("material_name"), + raw_material_batch_no=row.get("raw_material_batch_no"), + process_name=row.get("process_name"), + standard_beat=float(row.get("standard_beat") or 0), + standard_workload=float(row.get("standard_workload") or 0), + good_qty=float(row.get("good_qty") or 0), + defect_qty=float(row.get("defect_qty") or 0), + scrap_qty=float(row.get("miniapp_scrap_qty") or 0), + erp_scrap_qty=float(erp_scrap_qty), + allocated_minutes=float(row.get("allocated_minutes") or 0), + started_at=row.get("started_at"), + work_order_id=work_order.id if work_order else None, + work_order_no=work_order.work_order_no if work_order else row.get("raw_material_batch_no"), + work_order_operation_id=operation.id if operation else None, + operation_name=operation.operation_name if operation else row.get("process_name"), + erp_report_id=synced_report.id if synced_report else None, + erp_report_no=synced_report.report_no if synced_report else None, + is_synced_to_erp=bool(synced_report), + abnormal_reason=abnormal_reason, + ) + + +@router.get("/work-order-ledger", response_model=list[WorkOrderLedgerRead]) +def list_work_order_ledger( + work_order_id: int | None = Query(default=None), + limit: int = Query(default=200, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[WorkOrderLedgerRead]: + if work_order_id and sync_work_order_reference_qty(db, work_order_id): + db.commit() + rows = get_work_order_ledger_rows(db, limit=limit, work_order_id=work_order_id) + return [WorkOrderLedgerRead.model_validate(row.model_dump()) for row in rows] + + +@router.get("/production-ledger", response_model=list[ProductionBatchLedgerRead]) +def list_production_batch_ledger( + production_ledger_id: int | None = Query(default=None), + status: str | None = Query(default=None), + limit: int = Query(default=200, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[ProductionBatchLedgerRead]: + material_item = aliased(Item) + product_item = aliased(Item) + query = ( + select( + ProductionBatchLedger, + material_item.item_code.label("material_code"), + material_item.item_name.label("material_name"), + product_item.item_code.label("product_code"), + product_item.item_name.label("product_name"), + ) + .join(material_item, material_item.id == ProductionBatchLedger.material_item_id, isouter=True) + .join(product_item, product_item.id == ProductionBatchLedger.product_item_id, isouter=True) + .order_by(ProductionBatchLedger.updated_at.desc(), ProductionBatchLedger.id.desc()) + .limit(limit) + ) + if production_ledger_id: + query = query.where(ProductionBatchLedger.id == production_ledger_id) + if status: + query = query.where(ProductionBatchLedger.status == status) + + rows = db.execute(query).all() + ledger_ids = [row.ProductionBatchLedger.id for row in rows] + material_out_archives = _latest_archive_map(db, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, [int(ledger_id) for ledger_id in ledger_ids]) + txns_by_ledger_id: dict[int, list[ProductionBatchLedgerTxn]] = {ledger_id: [] for ledger_id in ledger_ids} + settlement_archives: dict[int, DocumentArchive] = {} + settlement_business_id_by_txn_id: dict[int, int] = {} + if ledger_ids: + txn_rows = db.scalars( + select(ProductionBatchLedgerTxn) + .where(ProductionBatchLedgerTxn.production_ledger_id.in_(ledger_ids)) + .order_by(ProductionBatchLedgerTxn.biz_time.desc(), ProductionBatchLedgerTxn.id.desc()) + ).all() + for txn in txn_rows: + txns_by_ledger_id.setdefault(txn.production_ledger_id, []).append(txn) + settlement_group_order = {"成品入库": 1, "生产余料入库": 2, "生产废料入库": 3} + settlement_grouped_txns: dict[tuple[int, object], list[ProductionBatchLedgerTxn]] = {} + for txn in txn_rows: + if txn.source_doc_type != "库存流水" or not txn.source_doc_id or txn.txn_type not in PRODUCTION_LEDGER_INBOUND_TXN_TYPES: + continue + batch_no = production_document_batch_no_from_text(txn.remark) + group_key = (int(txn.production_ledger_id), f"批次:{batch_no}" if batch_no else txn.biz_time) + settlement_grouped_txns.setdefault(group_key, []).append(txn) + for grouped_txns in settlement_grouped_txns.values(): + main_txn = sorted( + grouped_txns, + key=lambda item: (settlement_group_order.get(str(item.txn_type or ""), 99), int(item.source_doc_id or 0)), + )[0] + main_business_id = int(main_txn.source_doc_id) + for txn in grouped_txns: + settlement_business_id_by_txn_id[int(txn.id)] = main_business_id + settlement_business_ids = list(set(settlement_business_id_by_txn_id.values())) + settlement_archives = _latest_archive_map(db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, settlement_business_ids) + + result: list[ProductionBatchLedgerRead] = [] + for row in rows: + ledger = row.ProductionBatchLedger + material_archive = material_out_archives.get(int(ledger.id)) + result.append( + ProductionBatchLedgerRead( + production_ledger_id=ledger.id, + material_lot_id=ledger.material_lot_id, + material_lot_no=ledger.material_lot_no, + material_item_id=ledger.material_item_id, + material_code=row.material_code, + material_name=row.material_name, + product_item_id=ledger.product_item_id, + product_code=row.product_code, + product_name=row.product_name, + status=ledger.status, + miniapp_selectable=bool(ledger.miniapp_selectable), + total_issued_weight_kg=float(to_decimal(ledger.total_issued_weight_kg)), + outside_weight_kg=float(to_decimal(ledger.outside_weight_kg)), + finished_inbound_qty=float(to_decimal(ledger.finished_inbound_qty)), + surplus_inbound_weight_kg=float(to_decimal(ledger.surplus_inbound_weight_kg)), + scrap_inbound_weight_kg=float(to_decimal(ledger.scrap_inbound_weight_kg)), + writeoff_weight_kg=float(to_decimal(ledger.writeoff_weight_kg)), + first_issue_time=ledger.first_issue_time, + last_issue_time=ledger.last_issue_time, + locked_at=ledger.locked_at, + reopened_at=ledger.reopened_at, + lock_count=int(ledger.lock_count or 0), + remark=ledger.remark, + material_out_archive_status=material_archive.status if material_archive else ARCHIVE_STATUS_MISSING, + material_out_archive_business_id=ledger.id, + material_out_archive_document_type=DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, + material_out_archive_error_message=material_archive.error_message if material_archive else None, + txns=[ + _production_ledger_txn_read( + txn, + settlement_archives.get(settlement_business_id_by_txn_id[int(txn.id)]) + if int(txn.id) in settlement_business_id_by_txn_id + else None, + settlement_business_id_by_txn_id.get(int(txn.id)), + ) + for txn in txns_by_ledger_id.get(ledger.id, []) + ], + ) + ) + return result + + +@router.get("/work-order-inbounds/preview", response_model=ProductionWorkOrderInboundPreviewRead) +def preview_work_order_inbound( + work_order_id: int = Query(...), + finished_qty: float | None = Query(default=None), + db: Session = Depends(get_db), +) -> ProductionWorkOrderInboundPreviewRead: + return preview_production_work_order_inbound(db, work_order_id=work_order_id, finished_qty=finished_qty) + + +@router.post("/work-order-inbounds", response_model=ProductionWorkOrderInboundRead) +def create_work_order_inbound( + payload: ProductionWorkOrderInboundCreate, + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> ProductionWorkOrderInboundRead: + return create_production_work_order_inbound(db, payload=payload, operator_user_id=context.user.id) + + +@router.get("/production-ledger-inbounds/preview", response_model=ProductionBatchLedgerInboundPreviewRead) +def preview_production_batch_ledger_inbound( + production_ledger_id: int = Query(...), + finished_qty: float = Query(default=0), + db: Session = Depends(get_db), +) -> ProductionBatchLedgerInboundPreviewRead: + snapshot = calculate_production_batch_ledger_snapshot( + db, + production_ledger_id, + smart_reporting_enabled=False, + selected_finished_qty=to_decimal(finished_qty), + ) + material = db.get(Item, snapshot.material_item_id) + product = db.get(Item, snapshot.product_item_id) + return ProductionBatchLedgerInboundPreviewRead( + production_ledger_id=snapshot.production_ledger_id, + material_lot_no=snapshot.material_lot_no, + product_item_id=snapshot.product_item_id, + product_code=product.item_code if product else None, + product_name=product.item_name if product else None, + material_item_id=snapshot.material_item_id, + material_code=material.item_code if material else None, + material_name=material.item_name if material else None, + gross_weight_per_piece_kg=float(snapshot.gross_weight_per_piece_kg), + net_weight_per_piece_kg=float(snapshot.net_weight_per_piece_kg), + total_issued_weight_kg=float(snapshot.total_issued_weight_kg), + outside_weight_kg=float(snapshot.outside_weight_kg), + finished_inbound_qty=float(snapshot.finished_inbound_qty), + surplus_inbound_weight_kg=float(snapshot.surplus_inbound_weight_kg), + scrap_inbound_weight_kg=float(snapshot.scrap_inbound_weight_kg), + writeoff_weight_kg=float(snapshot.writeoff_weight_kg), + reference_finished_qty=float(snapshot.reference_finished_qty), + used_material_weight_kg=float(snapshot.used_material_weight_kg), + theoretical_surplus_weight_kg=float(snapshot.theoretical_surplus_weight_kg), + theoretical_scrap_weight_kg=float(snapshot.theoretical_scrap_weight_kg), + ) + + +@router.post("/production-ledger-inbounds", response_model=ProductionBatchLedgerInboundRead) +def create_production_batch_ledger_inbound( + payload: ProductionBatchLedgerInboundCreate, + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> ProductionBatchLedgerInboundRead: + finished_qty = to_decimal(payload.finished_qty) + surplus_weight = to_decimal(payload.surplus_weight_kg) + scrap_weight = to_decimal(payload.scrap_weight_kg) + if finished_qty <= 0 and surplus_weight <= 0 and scrap_weight <= 0 and not payload.lock_material_batch: + raise HTTPException(status_code=400, detail="请至少填写一种入库数量或勾选该批材料结单") + + ledger: ProductionBatchLedger | None = db.get(ProductionBatchLedger, payload.production_ledger_id) + if not ledger: + raise HTTPException(status_code=404, detail="生产台账不存在") + source_lot = db.get(StockLot, ledger.material_lot_id) + material = db.get(Item, ledger.material_item_id) + product = db.get(Item, ledger.product_item_id) + if not source_lot or not material or not product: + raise HTTPException(status_code=400, detail="生产台账来源批次、原材料或产品数据不完整") + snapshot = calculate_production_batch_ledger_snapshot( + db, + payload.production_ledger_id, + smart_reporting_enabled=False, + selected_finished_qty=finished_qty, + ) + deviation_remark = str(payload.deviation_remark or "").strip() + total_finished_qty = to_decimal(snapshot.finished_inbound_qty) + finished_qty + reference_finished_qty = to_decimal(snapshot.reference_finished_qty) + if reference_finished_qty <= 0: + finished_exceeded = total_finished_qty > 0 + else: + finished_exceeded = total_finished_qty > reference_finished_qty * Decimal("1.15") + + def _deviation_exceeded(actual: Decimal, theoretical: Decimal) -> bool: + actual = to_decimal(actual) + theoretical = to_decimal(theoretical) + if theoretical == 0: + return actual != 0 + return abs(actual - theoretical) / abs(theoretical) > Decimal("0.15") + + if finished_exceeded and not deviation_remark: + raise HTTPException(status_code=400, detail="成品入库数量超过本生产台账参考生产数量上限15%,请填写偏差说明") + if ( + _deviation_exceeded(surplus_weight, to_decimal(snapshot.theoretical_surplus_weight_kg)) + or _deviation_exceeded(scrap_weight, to_decimal(snapshot.theoretical_scrap_weight_kg)) + ) and not deviation_remark: + raise HTTPException(status_code=400, detail="余料或废料入库重量与理论值偏差超过上下15%,请填写偏差说明") + gross_weight = to_decimal(snapshot.gross_weight_per_piece_kg) + net_weight = to_decimal(snapshot.net_weight_per_piece_kg) + if net_weight <= 0: + net_weight = to_decimal(product.unit_weight_kg) + if net_weight <= 0: + net_weight = gross_weight + if gross_weight <= 0 and finished_qty > 0: + raise HTTPException(status_code=400, detail="产品需用清单缺少单件毛重,不能进行生产台账入库") + + now = datetime.now() + locked_check_warehouse_ids: set[int] = set() + finished_warehouse = _get_active_warehouse(db, "FINISHED", "生产台账入库") if finished_qty > 0 else None + scrap_warehouse = _get_active_warehouse(db, "SCRAP", "生产台账入库") if scrap_weight > 0 else None + if finished_warehouse: + locked_check_warehouse_ids.add(int(finished_warehouse.id)) + if surplus_weight > 0: + locked_check_warehouse_ids.add(int(source_lot.warehouse_id)) + if scrap_warehouse: + locked_check_warehouse_ids.add(int(scrap_warehouse.id)) + ensure_warehouses_unlocked(db, locked_check_warehouse_ids, "生产台账入库") + + finished_lot_id = surplus_lot_id = scrap_lot_id = None + finished_txn_id = surplus_txn_id = scrap_txn_id = None + base_remark = payload.remark or "生产台账入库" + if payload.deviation_remark: + base_remark = f"{base_remark};偏差说明:{payload.deviation_remark}" + document_batch_no = _production_document_batch_no(now) if (finished_qty > 0 or surplus_weight > 0 or scrap_weight > 0) else None + + if finished_qty > 0: + receipt_weight = to_decimal(finished_qty * net_weight) + unit_cost = to_decimal(to_decimal(source_lot.unit_cost) * gross_weight, "0.0001") + lot_no = _build_production_ledger_lot_no(db, "FGL", ledger, product, now) + source_summary = f"{material.item_name}:{ledger.material_lot_no}" + finished_lot = StockLot( + lot_no=lot_no, + parent_lot_id=source_lot.id, + lot_role="FINISHED_FROM_RAW_BATCH", + material_sub_batch_no=None, + item_id=product.id, + warehouse_id=finished_warehouse.id, + location_id=None, + source_doc_type="PRODUCTION_LEDGER_IN", + source_doc_id=ledger.id, + source_line_id=None, + source_material_lot_id=source_lot.id, + source_material_sub_batch_no=ledger.material_lot_no, + source_material_summary=source_summary, + inbound_qty=finished_qty, + inbound_weight_kg=receipt_weight, + remaining_qty=finished_qty, + remaining_weight_kg=receipt_weight, + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=unit_cost, + production_date=now.date(), + expire_date=None, + quality_status="PASS", + status="AVAILABLE", + remark=_with_document_batch_no(f"{base_remark};成品入库;来源库存批次已记录", document_batch_no), + ) + db.add(finished_lot) + db.flush() + upsert_stock_balance( + db, + item_id=product.id, + warehouse_id=finished_warehouse.id, + location_id=None, + qty_delta=finished_qty, + weight_delta=receipt_weight, + available_qty_delta=finished_qty, + available_weight_delta=receipt_weight, + unit_cost=unit_cost, + ) + finished_txn = create_inventory_txn( + db, + txn_type="FG_IN", + item_id=product.id, + warehouse_id=finished_warehouse.id, + location_id=None, + lot_id=finished_lot.id, + qty_change=finished_qty, + weight_change=receipt_weight, + unit_cost=unit_cost, + source_doc_type="生产台账", + source_doc_id=ledger.id, + source_line_id=finished_lot.id, + biz_time=now, + operator_user_id=context.user.id, + remark=_with_document_batch_no(f"{base_remark};成品入库", document_batch_no), + amount_basis="QTY", + ) + finished_lot_id = finished_lot.id + finished_txn_id = finished_txn.id + ledger = post_finished_inbound_to_ledger( + db, + production_ledger_id=payload.production_ledger_id, + finished_qty=finished_qty, + operator_user_id=context.user.id, + source_doc_type="库存流水", + source_doc_id=finished_txn.id, + source_line_id=finished_lot.id, + biz_time=now, + remark=_with_document_batch_no(f"{base_remark};成品入库联动生产台账", document_batch_no), + ) + if surplus_weight > 0: + source_lot.remaining_weight_kg = to_decimal(source_lot.remaining_weight_kg) + surplus_weight + source_lot.status = "AVAILABLE" + db.add(source_lot) + upsert_stock_balance( + db, + item_id=material.id, + warehouse_id=source_lot.warehouse_id, + location_id=source_lot.location_id, + qty_delta=Decimal("0"), + weight_delta=surplus_weight, + available_qty_delta=Decimal("0"), + available_weight_delta=surplus_weight, + unit_cost=to_decimal(source_lot.unit_cost), + ) + surplus_txn = create_inventory_txn( + db, + txn_type="PRODUCTION_SURPLUS_IN", + item_id=material.id, + warehouse_id=source_lot.warehouse_id, + location_id=source_lot.location_id, + lot_id=source_lot.id, + qty_change=Decimal("0"), + weight_change=surplus_weight, + unit_cost=to_decimal(source_lot.unit_cost), + source_doc_type="生产台账", + source_doc_id=ledger.id, + source_line_id=source_lot.id, + biz_time=now, + operator_user_id=context.user.id, + remark=_with_document_batch_no(f"{base_remark};生产余料入库", document_batch_no), + amount_basis="WEIGHT", + ) + surplus_lot_id = source_lot.id + surplus_txn_id = surplus_txn.id + ledger = post_surplus_inbound_to_ledger( + db, + production_ledger_id=payload.production_ledger_id, + surplus_weight_kg=surplus_weight, + operator_user_id=context.user.id, + source_doc_type="库存流水", + source_doc_id=surplus_txn.id, + source_line_id=source_lot.id, + biz_time=now, + remark=_with_document_batch_no(f"{base_remark};生产余料入库联动生产台账", document_batch_no), + ) + if scrap_weight > 0: + scrap_lot_no = _build_production_ledger_lot_no(db, "SCRAPI", ledger, material, now) + scrap_lot = StockLot( + lot_no=scrap_lot_no, + parent_lot_id=source_lot.id, + lot_role="PRODUCTION_SCRAP", + material_sub_batch_no=None, + item_id=material.id, + warehouse_id=scrap_warehouse.id, + location_id=None, + source_doc_type="PRODUCTION_SCRAP_IN", + source_doc_id=ledger.id, + source_line_id=None, + source_material_lot_id=source_lot.id, + source_material_sub_batch_no=ledger.material_lot_no, + source_material_summary=f"{material.item_name}:{ledger.material_lot_no}", + inbound_qty=Decimal("0"), + inbound_weight_kg=scrap_weight, + remaining_qty=Decimal("0"), + remaining_weight_kg=scrap_weight, + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=to_decimal(source_lot.unit_cost), + production_date=now.date(), + expire_date=None, + quality_status="SCRAP", + status="AVAILABLE", + remark=_with_document_batch_no(f"{base_remark};生产废料入库;来源库存批次已记录", document_batch_no), + ) + db.add(scrap_lot) + db.flush() + upsert_stock_balance( + db, + item_id=material.id, + warehouse_id=scrap_warehouse.id, + location_id=None, + qty_delta=Decimal("0"), + weight_delta=scrap_weight, + available_qty_delta=Decimal("0"), + available_weight_delta=scrap_weight, + unit_cost=to_decimal(source_lot.unit_cost), + ) + scrap_txn = create_inventory_txn( + db, + txn_type="PRODUCTION_SCRAP_IN", + item_id=material.id, + warehouse_id=scrap_warehouse.id, + location_id=None, + lot_id=scrap_lot.id, + qty_change=Decimal("0"), + weight_change=scrap_weight, + unit_cost=to_decimal(source_lot.unit_cost), + source_doc_type="生产台账", + source_doc_id=ledger.id, + source_line_id=scrap_lot.id, + biz_time=now, + operator_user_id=context.user.id, + remark=_with_document_batch_no(f"{base_remark};生产废料入库", document_batch_no), + amount_basis="WEIGHT", + ) + scrap_lot_id = scrap_lot.id + scrap_txn_id = scrap_txn.id + ledger = post_scrap_inbound_to_ledger( + db, + production_ledger_id=payload.production_ledger_id, + scrap_weight_kg=scrap_weight, + operator_user_id=context.user.id, + source_doc_type="库存流水", + source_doc_id=scrap_txn.id, + source_line_id=scrap_lot.id, + biz_time=now, + remark=_with_document_batch_no(f"{base_remark};生产废料入库联动生产台账", document_batch_no), + ) + if payload.lock_material_batch: + if not payload.lock_confirmed: + raise HTTPException(status_code=400, detail="请确认该批材料结单后再提交") + ledger = lock_production_batch_ledger( + db, + production_ledger_id=payload.production_ledger_id, + operator_user_id=context.user.id, + biz_time=now, + remark=payload.deviation_remark or payload.remark or "生产台账入库后该批材料结单", + ) + response_ledger_id = int(ledger.id) + response_material_lot_no = str(ledger.material_lot_no) + response_status = str(ledger.status) + main_txn_id = finished_txn_id or surplus_txn_id or scrap_txn_id + db.commit() + + archive_result: DocumentArchiveGenerateResult | None = None + if main_txn_id: + try: + archive_result = generate_document_archive( + db, + DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + int(main_txn_id), + created_by=context.user.id, + ) + except Exception as exc: + archive_result = _failed_archive_result( + DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + int(main_txn_id), + f"库存流水#{main_txn_id}", + exc, + ) + + response = ProductionBatchLedgerInboundRead( + production_ledger_id=response_ledger_id, + material_lot_no=response_material_lot_no, + finished_qty=float(finished_qty), + surplus_weight_kg=float(surplus_weight), + scrap_weight_kg=float(scrap_weight), + finished_lot_id=finished_lot_id, + surplus_lot_id=surplus_lot_id, + scrap_lot_id=scrap_lot_id, + finished_txn_id=finished_txn_id, + surplus_txn_id=surplus_txn_id, + scrap_txn_id=scrap_txn_id, + locked=bool(payload.lock_material_batch), + status=response_status, + remark=payload.remark, + ) + for key, value in _archive_fields_from_result( + archive_result, + document_type=DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + business_id=int(main_txn_id) if main_txn_id else None, + ).items(): + setattr(response, key, value) + return response + + +@router.get("/work-orders", response_model=list[WorkOrderRead]) +def list_work_orders( + limit: int = Query(default=100, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[WorkOrderRead]: + if sync_recent_work_order_reference_qty(db, limit=limit): + db.commit() + rows = db.execute(get_work_orders_query(limit=limit)).mappings().all() + return [WorkOrderRead.model_validate(dict(row)) for row in rows] + + +@router.get("/work-order-materials", response_model=list[WorkOrderMaterialRead]) +def list_work_order_materials( + work_order_id: int | None = Query(default=None), + limit: int = Query(default=200, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[WorkOrderMaterialRead]: + rows = db.execute(get_work_order_materials_query(limit=limit, work_order_id=work_order_id)).mappings().all() + return [WorkOrderMaterialRead.model_validate(dict(row)) for row in rows] + + +@router.get("/work-order-material-issues", response_model=list[WorkOrderMaterialIssueRead]) +def list_work_order_material_issues( + work_order_id: int | None = Query(default=None), + limit: int = Query(default=300, ge=1, le=800), + db: Session = Depends(get_db), +) -> list[WorkOrderMaterialIssueRead]: + rows = db.execute(get_work_order_material_issues_query(limit=limit, work_order_id=work_order_id)).mappings().all() + return [WorkOrderMaterialIssueRead.model_validate(dict(row)) for row in rows] + + +@router.get("/work-order-operations", response_model=list[WorkOrderOperationRead]) +def list_work_order_operations( + work_order_id: int | None = Query(default=None), + limit: int = Query(default=200, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[WorkOrderOperationRead]: + if work_order_id and sync_work_order_reference_qty(db, work_order_id): + db.commit() + rows = db.execute(get_work_order_operations_query(limit=limit, work_order_id=work_order_id)).mappings().all() + return [WorkOrderOperationRead.model_validate(dict(row)) for row in rows] + + +@router.post("/work-orders", response_model=WorkOrderRead) +def create_work_order( + payload: WorkOrderCreate, + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> WorkOrderRead: + product = db.get(Item, payload.product_item_id) + if not product: + raise HTTPException(status_code=404, detail="产品不存在") + if payload.source_sales_order_item_id: + source_order_item = db.get(SalesOrderItem, payload.source_sales_order_item_id) + if not source_order_item: + raise HTTPException(status_code=404, detail="来源订单明细不存在") + + selected_lots = [ + { + "source_lot_id": int(row.source_lot_id), + "material_item_id": int(row.material_item_id) if row.material_item_id else None, + "issued_weight_kg": to_decimal(row.issued_weight_kg), + } + for row in payload.selected_stock_lots + ] + if not selected_lots: + raise HTTPException(status_code=400, detail="生产出库必须选择库存批次号") + if len(selected_lots) != 1: + raise HTTPException(status_code=400, detail="一次生产出库只能选择一个材料库存批次号") + route = get_default_route(db, payload.product_item_id) + bom = get_default_bom(db, payload.product_item_id) + bom_items = db.scalars(select(BomItem).where(BomItem.bom_id == bom.id).order_by(BomItem.seq_no)).all() + if not bom_items: + raise HTTPException(status_code=400, detail="产品需规缺少默认用料,无法按库存批次出库") + + selected_lot_payload = selected_lots[0] + selected_lot = db.scalar( + select(StockLot) + .where(StockLot.id == int(selected_lot_payload["source_lot_id"])) + .with_for_update() + ) + if not selected_lot: + raise HTTPException(status_code=400, detail="库存批次不存在") + selected_material_item_id = int(selected_lot_payload["material_item_id"] or selected_lot.item_id) + if selected_material_item_id != int(selected_lot.item_id): + raise HTTPException(status_code=400, detail=f"库存批次 {selected_lot.lot_no} 与选择的原材料不匹配") + selected_lot_payload["material_item_id"] = selected_material_item_id + bom_item_by_material_id = {int(item.material_item_id): item for item in bom_items} + selected_bom_item = bom_item_by_material_id.get(selected_material_item_id) + if not selected_bom_item: + raise HTTPException(status_code=400, detail=f"库存批次 {selected_lot.lot_no} 不属于该产品需规默认用料") + ensure_warehouses_unlocked(db, {int(selected_lot.warehouse_id)}, "生产出库") + + issue_weight = to_decimal(selected_lot_payload["issued_weight_kg"]) + if issue_weight <= 0: + raise HTTPException(status_code=400, detail="出库重量必须大于0") + + planned_qty = to_decimal(payload.planned_qty) + if issue_weight > 0: + gross_weight = to_decimal(selected_bom_item.gross_weight_per_piece_kg) + if gross_weight <= 0: + gross_weight = to_decimal(product.unit_weight_kg) + if gross_weight <= 0: + raise HTTPException(status_code=400, detail="产品需用清单缺少单件毛重/净重,无法按领料重量计算参考生产数量") + planned_qty = (issue_weight / gross_weight).to_integral_value(rounding=ROUND_FLOOR) + if planned_qty <= 0: + raise HTTPException(status_code=400, detail="参考生产数量必须大于0") + biz_time = datetime.now() + ledger = record_production_issue_to_ledger( + db, + material_lot_id=selected_lot.id, + product_item_id=payload.product_item_id, + issued_weight_kg=issue_weight, + operator_user_id=context.user.id, + source_doc_type="库存流水", + source_doc_id=None, + source_line_id=None, + biz_time=biz_time, + remark=payload.remark or "原材料库生产出库形成生产台账", + ) + inventory_txn = _issue_raw_lot_to_production_ledger( + db, + lot=selected_lot, + material_item_id=selected_material_item_id, + issued_weight=issue_weight, + production_ledger_id=ledger.id, + operator_user_id=context.user.id, + biz_time=biz_time, + ) + _link_latest_issue_ledger_txn_to_inventory_txn(db, production_ledger_id=ledger.id, inventory_txn=inventory_txn) + production_ledger_id = int(ledger.id) + production_material_lot_no = str(ledger.material_lot_no) + production_ledger_status = str(ledger.status) + operation_count = db.scalar(select(func.count(ProcessRouteOperation.id)).where(ProcessRouteOperation.route_id == route.id)) or 0 + response = _build_production_ledger_work_order_compat_read( + production_ledger_id=production_ledger_id, + material_lot_no=production_material_lot_no, + product=product, + route=route, + bom=bom, + planned_qty=planned_qty, + payload=payload, + biz_time=biz_time, + status=production_ledger_status, + operation_count=int(operation_count), + ) + db.commit() + + archive_result: DocumentArchiveGenerateResult | None = None + try: + archive_result = generate_document_archive( + db, + DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, + production_ledger_id, + created_by=context.user.id, + ) + except Exception as exc: + archive_result = _failed_archive_result( + DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, + production_ledger_id, + f"生产台账#{production_ledger_id}", + exc, + ) + for key, value in _archive_fields_from_result( + archive_result, + document_type=DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, + business_id=production_ledger_id, + ).items(): + setattr(response, key, value) + return response + + +@router.get("/miniapp-operation-reports/sync-state", response_model=MiniAppOperationReportSyncStateRead) +def get_miniapp_operation_report_sync_state( + db: Session = Depends(get_db), +) -> MiniAppOperationReportSyncStateRead: + return MiniAppOperationReportSyncStateRead(last_synced_at=get_miniapp_operation_report_last_sync(db)) + + +@router.post("/miniapp-operation-reports/sync", response_model=MiniAppOperationReportSyncRead) +def sync_miniapp_operation_reports( + db: Session = Depends(get_db), +) -> MiniAppOperationReportSyncRead: + lock_miniapp_operation_report_last_sync(db) + sync_from = get_miniapp_operation_report_last_sync(db) + sync_to = datetime.now() + rows = _miniapp_report_item_rows( + db, + limit=None, + updated_after=sync_from, + updated_until=sync_to, + order_by_updated_asc=True, + ) + cache = _new_miniapp_resolve_cache() + report_nos = [_miniapp_report_no(int(row["source_report_id"]), int(row["source_item_id"])) for row in rows] + _preload_miniapp_operation_reports(db, report_nos, cache) + + touched_work_order_ids: set[int] = set() + synced = 0 + skipped = 0 + for row in rows: + synced_report = _sync_miniapp_item_to_erp( + db, + int(row["source_item_id"]), + required=False, + row=row, + cache=cache, + sync_status=False, + ) + if synced_report: + synced += 1 + if synced_report.work_order_id: + touched_work_order_ids.add(int(synced_report.work_order_id)) + else: + skipped += 1 + + for work_order_id in touched_work_order_ids: + sync_work_order_status(db, work_order_id) + row = upsert_miniapp_operation_report_last_sync(db, sync_to) + db.commit() + db.refresh(row) + return MiniAppOperationReportSyncRead( + sync_from=sync_from, + sync_to=sync_to, + last_synced_at=sync_to, + total=len(rows), + synced=synced, + skipped=skipped, + failed=0, + ) + + +@router.get("/miniapp-operation-reports", response_model=list[MiniAppOperationReportRead]) +def list_miniapp_operation_reports( + limit: int = Query(default=300, ge=1, le=800), + sync_to_erp: bool = Query(default=True), + start_date: date | None = Query(default=None), + end_date: date | None = Query(default=None), + db: Session = Depends(get_db), +) -> list[MiniAppOperationReportRead]: + if start_date and end_date and start_date > end_date: + raise HTTPException(status_code=400, detail="开始日期不能晚于结束日期") + rows = _miniapp_report_item_rows(db, limit=limit, start_date=start_date, end_date=end_date) + cache = _new_miniapp_resolve_cache() + report_nos = [_miniapp_report_no(int(row["source_report_id"]), int(row["source_item_id"])) for row in rows] + _preload_miniapp_operation_reports(db, report_nos, cache) + result: list[MiniAppOperationReportRead] = [] + touched_work_order_ids: set[int] = set() + for row in rows: + synced_report = None + if sync_to_erp: + synced_report = _sync_miniapp_item_to_erp( + db, + int(row["source_item_id"]), + required=False, + row=row, + cache=cache, + sync_status=False, + ) + if synced_report and synced_report.work_order_id: + touched_work_order_ids.add(int(synced_report.work_order_id)) + result.append(_build_miniapp_operation_report_read(db, row, synced_report, cache=cache, resolve_targets=sync_to_erp)) + if sync_to_erp: + for work_order_id in touched_work_order_ids: + sync_work_order_status(db, work_order_id) + db.commit() + return result + + +@router.get("/operation-reports", response_model=list[OperationReportRead]) +def list_operation_reports( + work_order_id: int | None = Query(default=None), + limit: int = Query(default=200, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[OperationReportRead]: + rows = db.execute(get_operation_reports_query(limit=limit, work_order_id=work_order_id)).mappings().all() + return [_build_operation_report_read(dict(row)) for row in rows] + + +@router.post("/miniapp-scrap-records", response_model=ScrapRecordRead) +def create_miniapp_scrap_record(payload: MiniAppScrapRecordCreate, db: Session = Depends(get_db)) -> ScrapRecordRead: + report = _sync_miniapp_item_to_erp(db, payload.source_item_id, required=True) + if not report: + raise HTTPException(status_code=400, detail="小程序报工无法同步为 ERP 报工") + work_order = db.get(WorkOrder, report.work_order_id) + if not work_order: + raise HTTPException(status_code=404, detail="库存批次号不存在") + source_row = _get_miniapp_report_item_row(db, payload.source_item_id) + source_scrap_qty = _miniapp_effective_scrap_qty(source_row) + scrap_qty = to_decimal(payload.scrap_qty) if to_decimal(payload.scrap_qty) > 0 else source_scrap_qty + scrap_payload = ScrapRecordCreate( + report_id=report.id, + work_order_id=report.work_order_id, + work_order_operation_id=report.work_order_operation_id, + item_id=work_order.product_item_id, + scrap_qty=float(scrap_qty), + scrap_weight_kg=payload.scrap_weight_kg, + scrap_reason=payload.scrap_reason, + disposal_type="SCRAP", + saleable_scrap_weight_kg=payload.saleable_scrap_weight_kg, + scrap_cost_amount=payload.scrap_cost_amount, + ) + return create_scrap_record(scrap_payload, db) + + +@router.post("/operation-reports", response_model=OperationReportRead) +def create_operation_report( + payload: OperationReportCreate, + db: Session = Depends(get_db), +) -> OperationReportRead: + work_order = db.get(WorkOrder, payload.work_order_id) + if not work_order: + raise HTTPException(status_code=404, detail="工单不存在") + operation = db.get(WorkOrderOperation, payload.work_order_operation_id) + if not operation or operation.work_order_id != payload.work_order_id: + raise HTTPException(status_code=400, detail="工单工序不存在或不属于当前工单") + employee = db.get(Employee, payload.employee_id) + if not employee: + raise HTTPException(status_code=404, detail="员工不存在") + if payload.equipment_id: + equipment = db.get(Equipment, payload.equipment_id) + if not equipment: + raise HTTPException(status_code=404, detail="设备不存在") + if payload.end_time <= payload.start_time: + raise HTTPException(status_code=400, detail="报工结束时间必须晚于开始时间") + + report_qty = to_decimal(payload.report_qty) + good_qty = to_decimal(payload.good_qty) + scrap_qty = to_decimal(payload.scrap_qty) + rework_qty = to_decimal(payload.rework_qty) + if report_qty <= 0: + raise HTTPException(status_code=400, detail="报工数量必须大于0") + if good_qty < 0 or scrap_qty < 0 or rework_qty < 0: + raise HTTPException(status_code=400, detail="报工数量、合格数、报废数、返工数不能小于0") + if good_qty + scrap_qty + rework_qty > report_qty: + raise HTTPException(status_code=400, detail="合格数、报废数、返工数合计不能超过报工数量") + + route_operation = db.get(ProcessRouteOperation, operation.route_operation_id) if operation.route_operation_id else None + ref_cycle_seconds = to_decimal(route_operation.std_cycle_seconds if route_operation else 0, "0.01") + actual_cycle_seconds = _calculate_actual_cycle_seconds(payload.start_time, payload.end_time, report_qty) + abnormal_reasons: list[str] = [] + if report_qty > 0 and (scrap_qty / report_qty) > to_decimal(operation.allowed_scrap_rate, "0.0001"): + abnormal_reasons.append("报废率超出工序允许值") + if actual_cycle_seconds is not None and ref_cycle_seconds > 0 and Decimal(str(actual_cycle_seconds)) > ref_cycle_seconds: + abnormal_reasons.append("实际节拍慢于参考节拍") + abnormal = 1 if abnormal_reasons else 0 + + report = OperationReport( + report_no=_build_operation_report_no(db, work_order, operation, employee), + work_order_id=payload.work_order_id, + work_order_operation_id=payload.work_order_operation_id, + employee_id=payload.employee_id, + equipment_id=payload.equipment_id, + report_source=payload.report_source, + shift_code=payload.shift_code, + start_time=payload.start_time, + end_time=payload.end_time, + report_qty=report_qty, + good_qty=good_qty, + scrap_qty=scrap_qty, + rework_qty=rework_qty, + scrap_reason=payload.scrap_reason, + downtime_minutes=to_decimal(payload.downtime_minutes, "0.01"), + extra_cost_amount=to_decimal(payload.extra_cost_amount, "0.01"), + is_abnormal=abnormal, + approved_by=None, + approved_at=None, + remark=payload.remark, + ) + db.add(report) + + operation.report_qty = to_decimal(operation.report_qty) + report_qty + operation.good_qty = to_decimal(operation.good_qty) + good_qty + operation.scrap_qty = to_decimal(operation.scrap_qty) + scrap_qty + operation.rework_qty = to_decimal(operation.rework_qty) + rework_qty + if abnormal: + operation.is_abnormal = 1 + if operation.report_qty > 0 and (to_decimal(operation.scrap_qty) / to_decimal(operation.report_qty)) > to_decimal( + operation.allowed_scrap_rate, "0.0001" + ): + operation.is_abnormal = 1 + if to_decimal(operation.report_qty) >= to_decimal(operation.planned_qty): + operation.status = "DONE" + else: + operation.status = "IN_PROGRESS" + db.add(operation) + + if work_order.actual_start_time is None: + work_order.actual_start_time = payload.start_time + db.add(work_order) + sync_work_order_status(db, work_order.id) + db.commit() + + row = db.execute(get_operation_reports_query(limit=1).where(OperationReport.id == report.id)).mappings().first() + if not row: + raise HTTPException(status_code=500, detail="报工记录创建后读取失败") + return _build_operation_report_read(dict(row)) + + +@router.get("/scrap-records", response_model=list[ScrapRecordRead]) +def list_scrap_records( + limit: int = Query(default=200, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[ScrapRecordRead]: + rows = db.execute(get_scrap_records_query(limit=limit)).mappings().all() + return [ScrapRecordRead.model_validate(dict(row)) for row in rows] + + +@router.post("/scrap-records", response_model=ScrapRecordRead) +def create_scrap_record(payload: ScrapRecordCreate, db: Session = Depends(get_db)) -> ScrapRecordRead: + report = db.get(OperationReport, payload.report_id) + if not report: + raise HTTPException(status_code=404, detail="报工记录不存在") + if report.work_order_id != payload.work_order_id or report.work_order_operation_id != payload.work_order_operation_id: + raise HTTPException(status_code=400, detail="处置记录与来源报工不匹配") + work_order = db.get(WorkOrder, payload.work_order_id) + if not work_order: + raise HTTPException(status_code=404, detail="工单不存在") + operation = db.get(WorkOrderOperation, payload.work_order_operation_id) + if not operation or operation.work_order_id != payload.work_order_id: + raise HTTPException(status_code=404, detail="工序不存在") + item = db.get(Item, payload.item_id) + if not item: + raise HTTPException(status_code=404, detail="物料不存在") + disposal_type = str(payload.disposal_type or "SCRAP").strip().upper() + if disposal_type != "SCRAP": + raise HTTPException(status_code=400, detail="生产过程只登记报废;客户退货返工请在发货概览模块处理") + + existing_active_record = _get_active_scrap_record(db, payload.report_id) + if existing_active_record: + _revert_scrap_record_effect(work_order, existing_active_record) + existing_active_record.status = "OVERRIDDEN" + db.add(existing_active_record) + + source_qty = to_decimal(report.rework_qty if disposal_type == "REWORK" and to_decimal(report.rework_qty) > 0 else report.scrap_qty) + scrap_qty = to_decimal(payload.scrap_qty) if to_decimal(payload.scrap_qty) > 0 else source_qty + if scrap_qty <= 0: + raise HTTPException(status_code=400, detail="当前来源报工没有可处置的报废/返工数量") + scrap_weight = to_decimal(payload.scrap_weight_kg) + if scrap_weight <= 0: + scrap_weight = scrap_qty * to_decimal(item.unit_weight_kg) + + saleable_scrap_weight = Decimal("0") + scrap_cost_amount = Decimal("0") + if disposal_type == "SCRAP": + saleable_scrap_weight = ( + to_decimal(payload.saleable_scrap_weight_kg) + if to_decimal(payload.saleable_scrap_weight_kg) > 0 + else scrap_weight + ) + scrap_cost_amount = to_decimal(payload.scrap_cost_amount, "0.01") + if scrap_cost_amount <= 0 and saleable_scrap_weight > 0: + scrap_cost_amount = to_decimal(_get_average_issued_weight_cost(db, payload.work_order_id) * saleable_scrap_weight, "0.01") + else: + scrap_cost_amount = to_decimal(scrap_qty * to_decimal(operation.std_labor_cost, "0.0001"), "0.01") + + record = ScrapRecord( + scrap_no=_build_scrap_no(db, work_order, disposal_type), + report_id=payload.report_id, + work_order_id=payload.work_order_id, + work_order_operation_id=payload.work_order_operation_id, + item_id=payload.item_id, + scrap_qty=scrap_qty, + scrap_weight_kg=scrap_weight, + scrap_reason=payload.scrap_reason or report.scrap_reason, + disposal_type=disposal_type, + saleable_scrap_weight_kg=saleable_scrap_weight, + scrap_cost_amount=scrap_cost_amount, + status="CONFIRMED", + ) + db.add(record) + + if disposal_type == "SCRAP": + work_order.scrap_qty = to_decimal(work_order.scrap_qty) + scrap_qty + db.add(work_order) + sync_work_order_status(db, work_order.id) + db.commit() + + row = db.execute(get_scrap_records_query(limit=1).where(ScrapRecord.id == record.id)).mappings().first() + if not row: + raise HTTPException(status_code=500, detail="报废/返工记录创建后读取失败") + return ScrapRecordRead.model_validate(dict(row)) + + +@router.get("/completion-receipts", response_model=list[CompletionReceiptRead]) +def list_completion_receipts( + limit: int = Query(default=100, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[CompletionReceiptRead]: + rows = db.execute(get_completion_receipts_query(limit=limit)).mappings().all() + return [CompletionReceiptRead.model_validate(dict(row)) for row in rows] + + +@router.get("/completion-items", response_model=list[CompletionReceiptItemRead]) +def list_completion_receipt_items( + receipt_id: int | None = Query(default=None), + limit: int = Query(default=200, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[CompletionReceiptItemRead]: + rows = db.execute(get_completion_receipt_items_query(limit=limit, receipt_id=receipt_id)).mappings().all() + return [CompletionReceiptItemRead.model_validate(dict(row)) for row in rows] + + +def _build_work_order_source_material_summary(db: Session, work_order_id: int) -> dict[str, object | None]: + rows = db.execute(get_work_order_material_issues_query(limit=500, work_order_id=work_order_id)).mappings().all() + if not rows: + return { + "source_material_lot_id": None, + "source_material_sub_batch_no": None, + "source_material_summary": None, + "source_material_weight_kg": Decimal("0"), + "source_material_amount": Decimal("0"), + "source_material_unit_cost": Decimal("0"), + } + first = rows[0] + parts: list[str] = [] + total_weight = Decimal("0") + total_amount = Decimal("0") + for row in rows: + weight = to_decimal(row["issued_weight_kg"]) + amount = to_decimal(row["amount"], "0.01") + source_lot_no = row["source_lot_no"] or row["material_sub_batch_no"] + parts.append(f"{source_lot_no} / {row['material_name']} / {weight}kg / ¥{amount}") + if weight > 0: + total_weight += weight + total_amount += amount + first_lot_no = first["source_lot_no"] or first["material_sub_batch_no"] + material_unit_cost = (total_amount / total_weight) if total_weight > 0 else Decimal("0") + return { + "source_material_lot_id": first["source_lot_id"], + "source_material_sub_batch_no": first_lot_no, + "source_material_summary": ";".join(parts)[:1000], + "source_material_weight_kg": to_decimal(total_weight, "0.000001"), + "source_material_amount": to_decimal(total_amount, "0.01"), + "source_material_unit_cost": to_decimal(material_unit_cost, "0.0001"), + } + + +def _is_rework_work_order(work_order: WorkOrder) -> bool: + return str(work_order.work_order_type or "").upper() == "REWORK" + + +def _build_rework_source_material_summary(db: Session, work_order_id: int) -> dict[str, object | None]: + row = db.execute( + select( + ReturnDisposition.id.label("return_disposition_id"), + ReturnItem.source_lot_id.label("source_lot_id"), + ) + .join(ReturnItem, ReturnItem.id == ReturnDisposition.return_item_id) + .where(ReturnDisposition.rework_work_order_id == work_order_id) + .limit(1) + ).mappings().first() + if not row: + raise HTTPException(status_code=400, detail="该返工工单缺少退货返工来源,不能返工入库") + + finished_lot = db.get(StockLot, row["source_lot_id"]) if row["source_lot_id"] else None + raw_lot = db.get(StockLot, finished_lot.source_material_lot_id) if finished_lot and finished_lot.source_material_lot_id else None + source_material_lot_id = finished_lot.source_material_lot_id if finished_lot else None + source_material_sub_batch_no = ( + finished_lot.source_material_sub_batch_no + if finished_lot and finished_lot.source_material_sub_batch_no + else raw_lot.lot_no if raw_lot else None + ) + source_material_summary = ( + finished_lot.source_material_summary + if finished_lot and finished_lot.source_material_summary + else raw_lot.lot_no if raw_lot else "退货返工来源" + ) + source_material_unit_cost = to_decimal(raw_lot.unit_cost if raw_lot else finished_lot.unit_cost if finished_lot else 0, "0.0001") + return { + "source_material_lot_id": source_material_lot_id, + "source_material_sub_batch_no": source_material_sub_batch_no or "退货返工来源批次未指定", + "source_material_summary": f"退货返工;{source_material_summary}", + "source_material_weight_kg": Decimal("0"), + "source_material_amount": Decimal("0"), + "source_material_unit_cost": source_material_unit_cost, + } + + +def _get_work_order_gross_weight_per_piece(db: Session, work_order: WorkOrder, product: Item) -> Decimal: + gross_weight = to_decimal( + db.scalar( + select(BomItem.gross_weight_per_piece_kg) + .where(BomItem.bom_id == work_order.bom_id, BomItem.gross_weight_per_piece_kg > 0) + .order_by(BomItem.seq_no, BomItem.id) + .limit(1) + ), + "0.000001", + ) + if gross_weight <= 0: + gross_weight = to_decimal(product.unit_weight_kg, "0.000001") + return gross_weight + + +def _calculate_finished_material_cost_per_piece( + source_material: dict[str, object | None], + gross_weight_per_piece: Decimal, +) -> Decimal: + material_unit_cost = to_decimal(source_material.get("source_material_unit_cost"), "0.0001") + if material_unit_cost <= 0 or gross_weight_per_piece <= 0: + return Decimal("0") + return to_decimal(material_unit_cost * gross_weight_per_piece, "0.0001") + + +def _append_finished_work_order_settle_remark(work_order: WorkOrder, settle_remark: str | None) -> None: + existing = str(work_order.remark or "").strip() + parts = [part.strip() for part in existing.replace(";", ";").split(";") if part.strip()] + if "生产入库已结单" not in parts: + parts.append("生产入库已结单") + remark = str(settle_remark or "").strip() + if remark: + parts.append(f"生产入库结单说明:{remark}") + unique_parts: list[str] = [] + for part in parts: + if part not in unique_parts: + unique_parts.append(part) + work_order.remark = ";".join(unique_parts)[-255:] + + +def _rework_finished_inbound_qty(db: Session, work_order_id: int) -> Decimal: + return to_decimal( + db.scalar( + select(func.coalesce(func.sum(InventoryTxn.qty_change), 0)).where( + InventoryTxn.txn_type == "REWORK_FINISHED_IN", + InventoryTxn.source_doc_type == "WORK_ORDER", + InventoryTxn.source_doc_id == work_order_id, + ) + ), + "0.000001", + ) + + +def _rework_scrap_inbound_qty(db: Session, work_order_id: int) -> Decimal: + return to_decimal( + db.scalar( + select(func.coalesce(func.sum(StockLot.inbound_qty), 0)).where( + StockLot.lot_role == "REWORK_SCRAP", + StockLot.source_doc_type == "WORK_ORDER", + StockLot.source_doc_id == work_order_id, + ) + ), + "0.000001", + ) + + +def _settle_rework_work_order_if_ready( + db: Session, + work_order: WorkOrder, + *, + incoming_finished_qty: Decimal = Decimal("0"), + settle_remark: str | None = None, +) -> None: + planned_qty = to_decimal(work_order.planned_qty) + processed_qty = _rework_finished_inbound_qty(db, work_order.id) + incoming_finished_qty + _rework_scrap_inbound_qty(db, work_order.id) + if processed_qty < planned_qty: + raise HTTPException( + status_code=400, + detail=f"返工工单尚未闭环:返工出库 {planned_qty} 件,已回库/入废料 {processed_qty} 件", + ) + work_order.status = "SETTLED" + work_order.actual_end_time = work_order.actual_end_time or datetime.now() + work_order.updated_at = datetime.now() + parts = [part.strip() for part in str(work_order.remark or "").replace(";", ";").split(";") if part.strip()] + if "返工工单已结单" not in parts: + parts.append("返工工单已结单") + remark = str(settle_remark or "").strip() + if remark: + parts.append(f"返工结单说明:{remark}") + work_order.remark = ";".join(dict.fromkeys(parts))[-255:] + db.add(work_order) + + +def _validate_finished_settle_request( + payload: CompletionReceiptCreate, + *, + receipt_qty: Decimal, + expected_receipt_qty: Decimal, +) -> None: + if not payload.settle_work_order: + return + lower_limit = expected_receipt_qty * Decimal("0.85") + upper_limit = expected_receipt_qty * INBOUND_TOLERANCE_MULTIPLIER + if lower_limit <= receipt_qty <= upper_limit: + return + if not str(payload.settle_remark or "").strip(): + raise HTTPException(status_code=400, detail="该工单结单入库成品数量超出标准成品数量上下15%,请填写结单说明") + + +@router.post("/completion-receipts", response_model=CompletionReceiptRead) +def create_completion_receipt( + payload: CompletionReceiptCreate, + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> CompletionReceiptRead: + work_order = db.get(WorkOrder, payload.work_order_id) + if not work_order: + raise HTTPException(status_code=404, detail="工单不存在") + if str(work_order.status or "").upper() == "SETTLED": + raise HTTPException(status_code=400, detail="该生产工单已结单,不能继续成品入库") + warehouse = db.get(Warehouse, payload.warehouse_id) + if not warehouse: + raise HTTPException(status_code=404, detail="仓库不存在") + ensure_warehouses_unlocked(db, [payload.warehouse_id], "成品入库") + if not payload.items: + raise HTTPException(status_code=400, detail="成品入库至少需要一条明细") + + is_rework = _is_rework_work_order(work_order) + limit_snapshot = get_work_order_limit_snapshot(db, payload.work_order_id) + max_receivable_qty = to_decimal(limit_snapshot.max_finished_inbound_qty) + if max_receivable_qty <= 0: + detail = "返工工单当前没有可返工入库数量" if is_rework else "该工单当前没有可入库的完工数量" + raise HTTPException(status_code=400, detail=detail) + + total_receipt_qty = Decimal("0") + for item_payload in payload.items: + product = db.get(Item, item_payload.product_item_id) + if not product: + raise HTTPException(status_code=404, detail=f"产品不存在: {item_payload.product_item_id}") + if item_payload.product_item_id != work_order.product_item_id: + raise HTTPException(status_code=400, detail="成品入库产品必须与工单产品一致") + receipt_qty = to_decimal(item_payload.receipt_qty) + if receipt_qty <= 0: + raise HTTPException(status_code=400, detail="入库数量必须大于0") + total_receipt_qty += receipt_qty + if is_rework: + if total_receipt_qty > max_receivable_qty: + raise HTTPException(status_code=400, detail=f"返工入库数量不能超过当前可返工入库数量 {max_receivable_qty}") + else: + validate_inbound_with_tolerance("成品入库数量", total_receipt_qty, max_receivable_qty) + if not is_rework: + _validate_finished_settle_request( + payload, + receipt_qty=total_receipt_qty, + expected_receipt_qty=max_receivable_qty, + ) + elif payload.settle_work_order: + _settle_rework_work_order_if_ready( + db, + work_order, + incoming_finished_qty=total_receipt_qty, + settle_remark=payload.settle_remark, + ) + + if is_rework: + source_material = _build_rework_source_material_summary(db, payload.work_order_id) + else: + source_material = _build_work_order_source_material_summary(db, payload.work_order_id) + if not source_material["source_material_sub_batch_no"]: + raise HTTPException(status_code=400, detail="该工单没有原材料库存批次领用记录,不能进行成品入库") + ensure_employee_has_permission( + db, + payload.receiver_employee_id, + "MENU_INVENTORY_LEDGER", + "接收人", + required=False, + ) + completion = CompletionReceipt( + receipt_no=_build_completion_receipt_no(db, work_order), + work_order_id=payload.work_order_id, + warehouse_id=payload.warehouse_id, + receipt_time=payload.receipt_time or datetime.now(), + receiver_employee_id=payload.receiver_employee_id, + status="POSTED", + remark=payload.remark, + ) + db.add(completion) + db.flush() + + remaining_receivable_qty = max_receivable_qty + for index, item_payload in enumerate(payload.items, start=1): + product = db.get(Item, item_payload.product_item_id) + if not product: + raise HTTPException(status_code=404, detail=f"产品不存在: {item_payload.product_item_id}") + if item_payload.product_item_id != work_order.product_item_id: + raise HTTPException(status_code=400, detail="成品入库产品必须与工单产品一致") + location = get_location_or_default(db, payload.warehouse_id, item_payload.location_id) + receipt_qty = to_decimal(item_payload.receipt_qty) + if receipt_qty <= 0: + raise HTTPException(status_code=400, detail="入库数量必须大于0") + gross_weight = _get_work_order_gross_weight_per_piece(db, work_order, product) + if gross_weight <= 0: + raise HTTPException(status_code=400, detail="产品需用清单缺少单件毛重,无法计算成品入库材料成本") + receipt_weight = receipt_qty * to_decimal(product.unit_weight_kg) + unit_cost = _calculate_finished_material_cost_per_piece(source_material, gross_weight) + lot_no = _build_finished_lot_no(db, work_order, product, completion.receipt_time, index) + + receipt_item = CompletionReceiptItem( + completion_receipt_id=completion.id, + line_no=index, + product_item_id=item_payload.product_item_id, + location_id=location.id if location else None, + lot_no=lot_no, + source_material_lot_id=source_material["source_material_lot_id"], + source_material_sub_batch_no=source_material["source_material_sub_batch_no"], + source_material_summary=source_material["source_material_summary"], + receipt_qty=receipt_qty, + receipt_weight_kg=receipt_weight, + unit_cost=unit_cost, + status="POSTED", + remark=item_payload.remark, + ) + db.add(receipt_item) + db.flush() + + lot = StockLot( + lot_no=lot_no, + parent_lot_id=source_material["source_material_lot_id"], + lot_role="FINISHED_FROM_RAW_BATCH", + material_sub_batch_no=None, + item_id=item_payload.product_item_id, + warehouse_id=payload.warehouse_id, + location_id=location.id if location else None, + source_doc_type="REWORK_RECEIPT" if is_rework else "FG_RECEIPT", + source_doc_id=completion.id, + source_line_id=receipt_item.id, + source_material_lot_id=source_material["source_material_lot_id"], + source_material_sub_batch_no=source_material["source_material_sub_batch_no"], + source_material_summary=source_material["source_material_summary"], + inbound_qty=receipt_qty, + inbound_weight_kg=receipt_weight, + remaining_qty=receipt_qty, + remaining_weight_kg=receipt_weight, + locked_qty=to_decimal(0), + locked_weight_kg=to_decimal(0), + unit_cost=unit_cost, + production_date=completion.receipt_time.date(), + expire_date=None, + quality_status="PASS", + status="AVAILABLE", + remark="返工入库;来源库存批次已记录" if is_rework else "完工入库;来源库存批次已记录", + ) + db.add(lot) + db.flush() + + upsert_stock_balance( + db, + item_id=item_payload.product_item_id, + warehouse_id=payload.warehouse_id, + location_id=location.id if location else None, + qty_delta=receipt_qty, + weight_delta=receipt_weight, + available_qty_delta=receipt_qty, + available_weight_delta=receipt_weight, + unit_cost=unit_cost, + ) + create_inventory_txn( + db, + txn_type="REWORK_FINISHED_IN" if is_rework else "FG_IN", + item_id=item_payload.product_item_id, + warehouse_id=payload.warehouse_id, + location_id=location.id if location else None, + lot_id=lot.id, + qty_change=receipt_qty, + weight_change=receipt_weight, + unit_cost=unit_cost, + source_doc_type="WORK_ORDER" if is_rework else "FG_RECEIPT", + source_doc_id=work_order.id if is_rework else completion.id, + source_line_id=receipt_item.id, + biz_time=completion.receipt_time, + operator_user_id=context.user.id, + remark="返工入库" if is_rework else "完工入库", + amount_basis="QTY", + ) + + work_order.finished_qty = to_decimal(work_order.finished_qty) + receipt_qty + db.add(work_order) + remaining_receivable_qty -= receipt_qty + + if payload.settle_work_order: + if not is_rework: + work_order.status = "SETTLED" + work_order.actual_end_time = work_order.actual_end_time or datetime.now() + work_order.updated_at = datetime.now() + _append_finished_work_order_settle_remark(work_order, payload.settle_remark) + db.add(work_order) + else: + sync_work_order_status(db, work_order.id) + db.commit() + + row = db.execute(get_completion_receipts_query(limit=1).where(CompletionReceipt.id == completion.id)).mappings().first() + if not row: + raise HTTPException(status_code=500, detail="完工入库单创建后读取失败") + return CompletionReceiptRead.model_validate(dict(row)) diff --git a/backend/app/api/routes/purchase.py b/backend/app/api/routes/purchase.py new file mode 100644 index 0000000..288c9d8 --- /dev/null +++ b/backend/app/api/routes/purchase.py @@ -0,0 +1,1184 @@ +import re +from datetime import date, datetime, timedelta +from io import BytesIO +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 as sa_delete, func, inspect, or_, select, text, update +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.models.master_data import Item, Material, Warehouse +from app.models.operations import PurchaseOrder, PurchaseOrderItem, PurchaseOrderSalesOrderLink, PurchaseReceipt, PurchaseReceiptItem, StockLot, Supplier +from app.models.planning import MaterialDemand +from app.models.sales import SalesOrder, SalesOrderItem +from app.schemas.operations import ( + PurchaseOrderCreate, + PurchaseOrderItemRead, + PurchaseOrderRead, + PurchaseOrderUpdate, + PurchaseReceiptCreate, + PurchaseReceiptItemRead, + PurchaseReceiptRead, + SupplierCreate, + SupplierRead, + SupplierUpdate, +) +from app.services.auth import AuthContext, require_authenticated_user +from app.services.contacts import normalize_contacts, parse_contacts, serialize_contacts +from app.services.document_archives import DOCUMENT_TYPE_PURCHASE_ORDER, DOCUMENT_TYPE_PURCHASE_RECEIPT, generate_document_archive +from app.services.logistics import normalize_logistics_fields +from app.services.operations import ( + build_inventory_lot_no, + build_supplier_code, + create_inventory_txn, + get_location_or_default, + get_purchase_order_items_query, + get_purchase_order_query, + get_purchase_receipt_items_query, + get_purchase_receipt_query, + sync_purchase_order_item_status, + sync_purchase_order_status, + to_decimal, + upsert_stock_balance, +) +from app.services.purchase_order_links import ensure_purchase_order_sales_link_table +from app.services.stocktake import ensure_warehouses_unlocked +from app.services.system_permissions import ensure_employee_has_permission + +router = APIRouter(dependencies=[Depends(require_authenticated_user)]) + +SUPPLIER_EXCEL_HEADERS = ["供应商名称", "联系人1", "联系电话1", "联系人2", "联系电话2", "联系人3", "联系电话3", "地址"] +SUPPLIER_EXCEL_REQUIRED_HEADERS = ["供应商名称", "联系人1", "联系电话1", "地址"] +LEGACY_SUPPLIER_EXCEL_HEADERS = ["供应商名称", "联系人", "联系电话", "地址"] +PURCHASE_TARGET_WAREHOUSE_TYPES = {"RAW", "AUX"} +RECEIVABLE_PURCHASE_ORDER_STATUSES = {"LOCKED", "PENDING_QC", "PARTIAL", "PARTIAL_RECEIVED", "REJECTED"} + + +def _auth_context_user_id(context: AuthContext | object) -> int | None: + user = getattr(context, "user", None) + user_id = getattr(user, "id", None) + return int(user_id) if user_id is not None else None + + +def build_purchase_order_no(db: Session, supplier: Supplier) -> str: + _ = supplier + year_part = date.today().strftime("%Y") + prefix = f"采购{year_part}" + pattern = re.compile(rf"{re.escape(prefix)}-(\d{{5}})") + max_no = 0 + rows = db.scalars(select(PurchaseOrder.po_no).where(PurchaseOrder.po_no.like(f"{prefix}-%"))).all() + for po_no in rows: + match = pattern.fullmatch(str(po_no or "").strip()) + if match: + max_no = max(max_no, int(match.group(1))) + return f"{prefix}-{max_no + 1:05d}" + + +def build_purchase_receipt_no(db: Session, purchase_order: PurchaseOrder) -> str: + _ = purchase_order + year_part = date.today().strftime("%Y") + prefix = f"入库{year_part}" + pattern = re.compile(rf"{re.escape(prefix)}-(\d{{5}})") + max_no = 0 + rows = db.scalars(select(PurchaseReceipt.receipt_no).where(PurchaseReceipt.receipt_no.like(f"{prefix}-%"))).all() + for receipt_no in rows: + match = pattern.fullmatch(str(receipt_no or "").strip()) + if match: + max_no = max(max_no, int(match.group(1))) + return f"{prefix}-{max_no + 1:05d}" + + +def _clean_supplier_text(value: object | None) -> str: + return str(value or "").strip() + + +def _build_supplier_workbook(rows: list[Supplier]) -> Workbook: + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "供应商名录" + worksheet.freeze_panes = "A2" + worksheet.append(SUPPLIER_EXCEL_HEADERS) + for row in rows: + contacts = parse_contacts(row.contact_name, row.contact_phone) + worksheet.append([ + row.supplier_name, + contacts[0]["contact_name"] if len(contacts) > 0 else "", + contacts[0]["contact_phone"] if len(contacts) > 0 else "", + contacts[1]["contact_name"] if len(contacts) > 1 else "", + contacts[1]["contact_phone"] if len(contacts) > 1 else "", + contacts[2]["contact_name"] if len(contacts) > 2 else "", + contacts[2]["contact_phone"] if len(contacts) > 2 else "", + row.address or "", + ]) + for index, width in enumerate([30, 18, 20, 18, 20, 18, 20, 46], start=1): + worksheet.column_dimensions[worksheet.cell(row=1, column=index).column_letter].width = width + return workbook + + +def _supplier_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=\"suppliers.xlsx\"; filename*=UTF-8''{encoded_filename}"}, + ) + + +def _read_supplier_excel_rows(content: bytes) -> list[dict[str, object]]: + try: + workbook = load_workbook(BytesIO(content), read_only=True, data_only=True) + except Exception as exc: + raise ValueError("Excel 文件读取失败,请确认上传的是供应商名录 xlsx 文件") from exc + + worksheet = workbook.active + rows = list(worksheet.iter_rows(values_only=True)) + if not rows: + return [] + headers = [_clean_supplier_text(value) for value in rows[0]] + accepted_headers = SUPPLIER_EXCEL_REQUIRED_HEADERS if "联系人1" in headers else LEGACY_SUPPLIER_EXCEL_HEADERS + missing = [header for header in accepted_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 _ensure_purchase_order_target_warehouse_type(db: Session) -> None: + bind = db.get_bind() + columns = {column["name"] for column in inspect(bind).get_columns("po_purchase_order")} + changed = False + if "target_warehouse_type" not in columns: + if bind.dialect.name == "mysql": + db.execute( + text( + "ALTER TABLE po_purchase_order " + "ADD COLUMN target_warehouse_type VARCHAR(32) NOT NULL DEFAULT 'RAW' COMMENT '目标仓库类型:RAW原材料库/AUX辅料库'" + ) + ) + else: + db.execute(text("ALTER TABLE po_purchase_order ADD COLUMN target_warehouse_type VARCHAR(32) NOT NULL DEFAULT 'RAW'")) + changed = True + if "warning_lead_days" not in columns: + if bind.dialect.name == "mysql": + db.execute( + text( + "ALTER TABLE po_purchase_order " + "ADD COLUMN warning_lead_days INT NOT NULL DEFAULT 0 COMMENT '预计到货提前预警天数'" + ) + ) + else: + db.execute(text("ALTER TABLE po_purchase_order ADD COLUMN warning_lead_days INTEGER NOT NULL DEFAULT 0")) + changed = True + if changed: + db.commit() + + receipt_columns = {column["name"] for column in inspect(bind).get_columns("po_receipt")} + receipt_column_defs = { + "logistics_waybill_no": ("VARCHAR(100)", "运单号"), + "logistics_freight_amount": ("DECIMAL(18, 2)", "运费"), + "logistics_photo_url": ("VARCHAR(255)", "辅助照片"), + } + receipt_changed = False + for column_name, (column_type, comment) in receipt_column_defs.items(): + if column_name in receipt_columns: + continue + if bind.dialect.name == "mysql": + db.execute(text(f"ALTER TABLE po_receipt ADD COLUMN {column_name} {column_type} NULL COMMENT '{comment}'")) + else: + db.execute(text(f"ALTER TABLE po_receipt ADD COLUMN {column_name} {column_type} NULL")) + receipt_changed = True + if receipt_changed: + db.commit() + + +def _normalize_purchase_target_warehouse_type(value: str | None) -> str: + target_type = str(value or "RAW").strip().upper() + if target_type not in PURCHASE_TARGET_WAREHOUSE_TYPES: + raise HTTPException(status_code=400, detail="采购目标仓库类型只能选择原材料库或辅料库") + return target_type + + +def _purchase_target_label(value: str | None) -> str: + return "辅料库" if _normalize_purchase_target_warehouse_type(value) == "AUX" else "原材料库" + + +def _normalize_sales_order_ids(values: list[int] | None) -> list[int]: + normalized_ids: set[int] = set() + for value in values or []: + sales_order_id = int(value) + if sales_order_id <= 0: + raise HTTPException(status_code=400, detail="销售订单ID必须大于0") + normalized_ids.add(sales_order_id) + return sorted(normalized_ids) + + +def _ensure_sales_orders_exist(db: Session, sales_order_ids: list[int]) -> None: + for sales_order_id in sales_order_ids: + sales_order = db.get(SalesOrder, sales_order_id) + if not sales_order: + raise HTTPException(status_code=404, detail=f"销售订单不存在: {sales_order_id}") + + +def _replace_purchase_order_sales_links(db: Session, purchase_order_id: int, sales_order_ids: list[int]) -> None: + normalized_ids = _normalize_sales_order_ids(sales_order_ids) + _ensure_sales_orders_exist(db, normalized_ids) + db.execute( + sa_delete(PurchaseOrderSalesOrderLink) + .where(PurchaseOrderSalesOrderLink.purchase_order_id == purchase_order_id) + .execution_options(synchronize_session=False) + ) + for sales_order_id in normalized_ids: + db.add( + PurchaseOrderSalesOrderLink( + purchase_order_id=purchase_order_id, + sales_order_id=sales_order_id, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + ) + + +def _coerce_date(value: object | None) -> date | None: + if isinstance(value, date): + return value + if value: + try: + return date.fromisoformat(str(value)[:10]) + except ValueError: + return None + return None + + +def _enrich_purchase_order_warning(row: dict) -> None: + expected_date = _coerce_date(row.get("expected_date")) + warning_lead_days = max(int(row.get("warning_lead_days") or 0), 0) + warning_start_date = expected_date - timedelta(days=warning_lead_days) if expected_date and warning_lead_days > 0 else None + target_warehouse_type = _normalize_purchase_target_warehouse_type(row.get("target_warehouse_type")) + total_order_value = to_decimal(row.get("total_order_qty") or 0) if target_warehouse_type == "AUX" else to_decimal(row.get("total_order_weight_kg") or 0) + total_received_value = to_decimal(row.get("total_received_qty") or 0) if target_warehouse_type == "AUX" else to_decimal(row.get("total_received_weight_kg") or 0) + row["warning_lead_days"] = warning_lead_days + row["warning_start_date"] = warning_start_date + row["is_warning_active"] = bool( + warning_start_date + and date.today() >= warning_start_date + and total_order_value > 0 + and total_received_value < total_order_value + ) + + +def _sales_order_ids_from_items(db: Session, items: list) -> list[int]: + ids: set[int] = set() + for item in items: + if not item.source_demand_id: + continue + row = db.execute( + select(SalesOrder.id) + .join(SalesOrderItem, SalesOrderItem.sales_order_id == SalesOrder.id) + .join(MaterialDemand, MaterialDemand.sales_order_item_id == SalesOrderItem.id) + .where(MaterialDemand.id == item.source_demand_id) + ).scalar_one_or_none() + if row: + ids.add(int(row)) + return sorted(ids) + + +def _enrich_purchase_order_sources(db: Session, rows: list[dict]) -> list[dict]: + purchase_order_ids = [int(row["purchase_order_id"]) for row in rows] + if not purchase_order_ids: + return rows + ensure_purchase_order_sales_link_table(db) + link_rows = db.execute( + select( + PurchaseOrderSalesOrderLink.purchase_order_id.label("purchase_order_id"), + SalesOrder.id.label("sales_order_id"), + SalesOrder.order_no.label("order_no"), + ) + .join(SalesOrder, SalesOrder.id == PurchaseOrderSalesOrderLink.sales_order_id) + .where(PurchaseOrderSalesOrderLink.purchase_order_id.in_(purchase_order_ids)) + .order_by(PurchaseOrderSalesOrderLink.purchase_order_id, SalesOrder.order_no) + ).mappings().all() + links_by_po: dict[int, list[dict]] = {} + for link in link_rows: + links_by_po.setdefault(int(link["purchase_order_id"]), []).append(dict(link)) + inferred_rows = db.execute( + select( + PurchaseOrderItem.purchase_order_id.label("purchase_order_id"), + SalesOrder.id.label("sales_order_id"), + SalesOrder.order_no.label("order_no"), + ) + .join(MaterialDemand, MaterialDemand.id == PurchaseOrderItem.source_demand_id) + .join(SalesOrderItem, SalesOrderItem.id == MaterialDemand.sales_order_item_id) + .join(SalesOrder, SalesOrder.id == SalesOrderItem.sales_order_id) + .where(PurchaseOrderItem.purchase_order_id.in_(purchase_order_ids)) + .order_by(PurchaseOrderItem.purchase_order_id, SalesOrder.order_no) + ).mappings().all() + inferred_by_po: dict[int, list[dict]] = {} + for inferred in inferred_rows: + links = inferred_by_po.setdefault(int(inferred["purchase_order_id"]), []) + if any(int(link["sales_order_id"]) == int(inferred["sales_order_id"]) for link in links): + continue + links.append(dict(inferred)) + for row in rows: + _enrich_purchase_order_warning(row) + links = links_by_po.get(int(row["purchase_order_id"]), []) + if not links: + links = inferred_by_po.get(int(row["purchase_order_id"]), []) + if links: + row["sales_order_ids"] = [int(link["sales_order_id"]) for link in links] + row["source_order_nos"] = "、".join(str(link["order_no"]) for link in links) + row["source_type"] = "SALES_ORDER" + else: + row["sales_order_ids"] = [] + return rows + + +def _import_supplier_excel(db: Session, content: bytes) -> dict[str, object]: + rows = _read_supplier_excel_rows(content) + if not rows: + raise ValueError("Excel 中没有可导入的供应商数据") + + latest_by_name: dict[str, dict[str, str]] = {} + skipped = 0 + errors: list[str] = [] + for index, row in enumerate(rows, start=2): + if not any(_clean_supplier_text(value) for value in row.values()): + continue + supplier_name = _clean_supplier_text(row.get("供应商名称")) + if not supplier_name: + skipped += 1 + errors.append(f"第{index}行缺少供应商名称") + continue + latest_by_name[supplier_name] = { + "supplier_name": supplier_name, + "contacts": normalize_contacts( + [ + {"contact_name": row.get("联系人1"), "contact_phone": row.get("联系电话1")}, + {"contact_name": row.get("联系人2"), "contact_phone": row.get("联系电话2")}, + {"contact_name": row.get("联系人3"), "contact_phone": row.get("联系电话3")}, + ], + fallback_name=_clean_supplier_text(row.get("联系人")), + fallback_phone=_clean_supplier_text(row.get("联系电话")), + ), + "address": _clean_supplier_text(row.get("地址")), + } + + if not latest_by_name: + raise ValueError("Excel 中没有可导入的有效供应商数据") + + existing_rows = db.scalars( + select(Supplier) + .where(Supplier.supplier_name.in_(latest_by_name.keys())) + .order_by(Supplier.id.asc()) + ).all() + existing_by_name: dict[str, Supplier] = {} + for supplier in existing_rows: + existing_by_name.setdefault(_clean_supplier_text(supplier.supplier_name), supplier) + + imported = 0 + updated = 0 + for supplier_name, payload in latest_by_name.items(): + contact_name, contact_phone = serialize_contacts(payload["contacts"]) + supplier = existing_by_name.get(supplier_name) + if supplier: + supplier.contact_name = contact_name + supplier.contact_phone = contact_phone + supplier.address = payload["address"] or None + supplier.short_name = supplier.short_name or supplier_name + supplier.lead_time_days = 0 + supplier.default_tax_rate = to_decimal(0, "0.0001") + supplier.status = "ACTIVE" + db.add(supplier) + updated += 1 + continue + + db.add( + Supplier( + supplier_code=build_supplier_code(db), + supplier_name=supplier_name, + short_name=supplier_name, + contact_name=contact_name, + contact_phone=contact_phone, + address=payload["address"] or None, + lead_time_days=0, + default_tax_rate=to_decimal(0, "0.0001"), + status="ACTIVE", + remark=None, + ) + ) + db.flush() + imported += 1 + + db.commit() + return { + "imported": imported, + "updated": updated, + "skipped": skipped, + "errors": errors, + "message": f"导入完成:新增 {imported} 条,更新 {updated} 条,跳过 {skipped} 条", + } + + +def _supplier_contact_fields(payload: SupplierCreate | SupplierUpdate) -> tuple[str | None, str | None]: + try: + contacts = normalize_contacts( + list(payload.contacts or []), + fallback_name=payload.contact_name, + fallback_phone=payload.contact_phone, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return serialize_contacts(contacts) + + +def _supplier_read(supplier: Supplier) -> SupplierRead: + return SupplierRead.model_validate( + { + "id": supplier.id, + "supplier_code": supplier.supplier_code, + "supplier_name": supplier.supplier_name, + "short_name": supplier.short_name, + "contact_name": supplier.contact_name, + "contact_phone": supplier.contact_phone, + "contacts": parse_contacts(supplier.contact_name, supplier.contact_phone), + "address": supplier.address, + "lead_time_days": supplier.lead_time_days, + "default_tax_rate": supplier.default_tax_rate, + "status": supplier.status, + "remark": supplier.remark, + } + ) + + +@router.get("/suppliers", response_model=list[SupplierRead]) +def list_suppliers( + limit: int = Query(default=100, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[SupplierRead]: + rows = db.scalars(select(Supplier).order_by(Supplier.id.desc()).limit(limit)).all() + return [_supplier_read(row) for row in rows] + + +@router.post("/suppliers", response_model=SupplierRead) +def create_supplier(payload: SupplierCreate, db: Session = Depends(get_db)) -> SupplierRead: + contact_name, contact_phone = _supplier_contact_fields(payload) + supplier = Supplier( + supplier_code=build_supplier_code(db), + supplier_name=payload.supplier_name, + short_name=payload.short_name, + contact_name=contact_name, + contact_phone=contact_phone, + address=payload.address, + lead_time_days=0, + default_tax_rate=to_decimal(payload.default_tax_rate, "0.0001"), + status=payload.status, + remark=payload.remark, + ) + db.add(supplier) + db.commit() + db.refresh(supplier) + return _supplier_read(supplier) + + +@router.get("/suppliers/export") +def export_suppliers(db: Session = Depends(get_db)) -> Response: + rows = db.scalars(select(Supplier).order_by(Supplier.supplier_name.asc(), Supplier.id.asc())).all() + workbook = _build_supplier_workbook(rows) + return _supplier_excel_response(workbook, f"供应商名录_{datetime.now().strftime('%Y%m%d%H%M%S')}.xlsx") + + +@router.post("/suppliers/import") +async def import_suppliers( + file: UploadFile = File(...), + db: Session = Depends(get_db), +) -> dict[str, object]: + content = await file.read() + try: + result = _import_supplier_excel(db, content) + except ValueError as exc: + db.rollback() + raise HTTPException(status_code=400, detail=str(exc)) from exc + return result + + +@router.put("/suppliers/{supplier_id}", response_model=SupplierRead) +def update_supplier(supplier_id: int, payload: SupplierUpdate, db: Session = Depends(get_db)) -> SupplierRead: + supplier = db.get(Supplier, supplier_id) + if not supplier: + raise HTTPException(status_code=404, detail="供应商不存在") + + contact_name, contact_phone = _supplier_contact_fields(payload) + supplier.supplier_name = payload.supplier_name + supplier.short_name = payload.short_name + supplier.contact_name = contact_name + supplier.contact_phone = contact_phone + supplier.address = payload.address + supplier.lead_time_days = 0 + supplier.default_tax_rate = to_decimal(payload.default_tax_rate, "0.0001") + supplier.status = payload.status + supplier.remark = payload.remark + + db.add(supplier) + db.commit() + db.refresh(supplier) + return _supplier_read(supplier) + + +@router.delete("/suppliers/{supplier_id}") +def delete_supplier(supplier_id: int, db: Session = Depends(get_db)) -> dict[str, str]: + supplier = db.get(Supplier, supplier_id) + if not supplier: + raise HTTPException(status_code=404, detail="供应商不存在") + + references = [ + ("采购订单", db.scalar(select(func.count(PurchaseOrder.id)).where(PurchaseOrder.supplier_id == supplier_id)) or 0), + ] + used = [f"{label}({count})" for label, count in references if count > 0] + if used: + raise HTTPException(status_code=400, detail=f"该供应商已被业务数据引用,不能删除:{'、'.join(used)}") + + supplier_name = supplier.supplier_name + db.execute(update(Material).where(Material.default_supplier_id == supplier_id).values(default_supplier_id=None)) + db.delete(supplier) + db.commit() + return {"message": f"供应商 {supplier_name} 已删除"} + + +@router.get("/orders", response_model=list[PurchaseOrderRead]) +def list_purchase_orders( + limit: int = Query(default=100, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[PurchaseOrderRead]: + _ensure_purchase_order_target_warehouse_type(db) + rows = [dict(row) for row in db.execute(get_purchase_order_query(limit=limit)).mappings().all()] + rows = _enrich_purchase_order_sources(db, rows) + return [PurchaseOrderRead.model_validate(dict(row)) for row in rows] + + +@router.get("/order-items", response_model=list[PurchaseOrderItemRead]) +def list_purchase_order_items( + purchase_order_id: int | None = Query(default=None), + limit: int = Query(default=200, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[PurchaseOrderItemRead]: + _ensure_purchase_order_target_warehouse_type(db) + rows = db.execute(get_purchase_order_items_query(limit=limit, purchase_order_id=purchase_order_id)).mappings().all() + return [PurchaseOrderItemRead.model_validate(dict(row)) for row in rows] + + +def _read_purchase_order(db: Session, purchase_order_id: int) -> PurchaseOrderRead: + _ensure_purchase_order_target_warehouse_type(db) + row = db.execute(get_purchase_order_query(limit=1).where(PurchaseOrder.id == purchase_order_id)).mappings().first() + if not row: + raise HTTPException(status_code=500, detail="采购订单读取失败") + rows = _enrich_purchase_order_sources(db, [dict(row)]) + return PurchaseOrderRead.model_validate(rows[0]) + + +def _read_purchase_order_after_archive_generation( + db: Session, + purchase_order_id: int, + *, + created_by: int | None, +) -> PurchaseOrderRead: + try: + archive_kwargs = {"created_by": created_by} if created_by is not None else {} + archive_result = generate_document_archive( + db, + DOCUMENT_TYPE_PURCHASE_ORDER, + int(purchase_order_id), + **archive_kwargs, + ) + archive_fields = { + "archive_status": archive_result.archive_status, + "archive_version": archive_result.archive_version, + "archive_error_message": archive_result.archive_error_message, + } + except Exception as exc: + # The order is already committed; this only clears any failed archive transaction. + try: + db.rollback() + except Exception: + pass + archive_fields = { + "archive_status": "归档失败", + "archive_version": None, + "archive_error_message": f"归档失败:{exc}", + } + return _read_purchase_order(db, purchase_order_id).model_copy(update=archive_fields) + + +def _get_purchase_order_display_status(db: Session, purchase_order_id: int) -> str: + row = db.execute(get_purchase_order_query(limit=1).where(PurchaseOrder.id == purchase_order_id)).mappings().first() + if not row: + return "" + return str(row.get("display_status") or row.get("status") or "").upper() + + +def _assert_purchase_order_editable(db: Session, order: PurchaseOrder) -> list[PurchaseOrderItem]: + if order.status != "OPEN": + raise HTTPException(status_code=400, detail="只有未锁单的采购订单可以修改或删除") + receipt_count = db.scalar(select(func.count(PurchaseReceipt.id)).where(PurchaseReceipt.purchase_order_id == order.id)) or 0 + if receipt_count > 0: + raise HTTPException(status_code=400, detail="采购订单已有到货入库记录,不能修改或删除") + items = db.scalars(select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == order.id)).all() + if any(to_decimal(item.received_weight_kg) > 0 or to_decimal(item.received_qty) > 0 for item in items): + raise HTTPException(status_code=400, detail="采购订单已有收货重量,不能修改或删除") + return items + + +def _reset_unreferenced_demands(db: Session, demand_ids: set[int]) -> None: + for demand_id in demand_ids: + still_referenced = db.scalar( + select(func.count(PurchaseOrderItem.id)).where(PurchaseOrderItem.source_demand_id == demand_id) + ) or 0 + if still_referenced: + continue + demand = db.get(MaterialDemand, demand_id) + if demand and demand.status == "ORDERED": + demand.status = "NEW" + db.add(demand) + + +def _build_purchase_order_items( + db: Session, + *, + order: PurchaseOrder, + payload_items: list, + target_warehouse_type: str, +) -> tuple[list[PurchaseOrderItem], set[int], object]: + if not payload_items: + raise HTTPException(status_code=400, detail="采购订单至少需要一条明细") + + rows: list[PurchaseOrderItem] = [] + linked_demand_ids: set[int] = set() + total_amount = to_decimal(0, "0.01") + for index, item_payload in enumerate(payload_items, start=1): + material_item = db.get(Item, item_payload.material_item_id) + item_label = "辅料" if target_warehouse_type == "AUX" else "原材料" + if not material_item: + raise HTTPException(status_code=404, detail=f"{item_label}不存在: {item_payload.material_item_id}") + material_ext = db.scalar(select(Material).where(Material.item_id == item_payload.material_item_id)) + if not material_ext: + raise HTTPException(status_code=404, detail=f"{item_label}缺少采购参数: {item_payload.material_item_id}") + + if target_warehouse_type == "AUX": + order_qty = to_decimal(item_payload.order_qty) + order_weight = to_decimal(0) + else: + order_qty = to_decimal(0) + order_weight = to_decimal(item_payload.order_weight_kg) + + if target_warehouse_type == "AUX" and item_payload.source_demand_id: + raise HTTPException(status_code=400, detail="辅料库采购只能做备料采购,不能关联销售订单采购需求") + + if item_payload.source_demand_id: + demand = db.get(MaterialDemand, item_payload.source_demand_id) + if not demand: + raise HTTPException(status_code=404, detail=f"MRP 需求不存在: {item_payload.source_demand_id}") + if demand.material_item_id != item_payload.material_item_id: + raise HTTPException(status_code=400, detail=f"MRP 需求 {demand.demand_no} 与采购原材料不一致") + if order_weight <= 0: + order_weight = to_decimal(demand.suggested_purchase_weight_kg) + demand.status = "ORDERED" + db.add(demand) + linked_demand_ids.add(int(demand.id)) + + if target_warehouse_type == "AUX": + if order_qty <= 0: + raise HTTPException(status_code=400, detail=f"辅料 {material_item.item_name} 的采购数量必须大于0") + line_amount = to_decimal(order_qty, "0.000001") * to_decimal(item_payload.unit_price, "0.0001") + else: + if order_weight <= 0: + raise HTTPException(status_code=400, detail=f"原材料 {material_item.item_name} 的采购重量必须大于0") + line_amount = to_decimal(order_weight, "0.000001") * to_decimal(item_payload.unit_price, "0.0001") + + line_amount = to_decimal(line_amount, "0.01") + rows.append( + PurchaseOrderItem( + purchase_order_id=order.id, + line_no=index, + material_item_id=item_payload.material_item_id, + source_demand_id=item_payload.source_demand_id, + order_qty=order_qty, + order_weight_kg=order_weight, + received_qty=to_decimal(0), + received_weight_kg=to_decimal(0), + unit_price=to_decimal(item_payload.unit_price, "0.0001"), + line_amount=line_amount, + status="OPEN", + remark=item_payload.remark, + ) + ) + total_amount += line_amount + + return rows, linked_demand_ids, total_amount + + +@router.post("/orders", response_model=PurchaseOrderRead) +def create_purchase_order( + payload: PurchaseOrderCreate, + db: Session = Depends(get_db), + context: AuthContext = Depends(require_authenticated_user), +) -> PurchaseOrderRead: + _ensure_purchase_order_target_warehouse_type(db) + ensure_purchase_order_sales_link_table(db) + explicit_sales_order_ids = _normalize_sales_order_ids(payload.sales_order_ids) + target_warehouse_type = _normalize_purchase_target_warehouse_type(payload.target_warehouse_type) + if target_warehouse_type == "AUX" and explicit_sales_order_ids: + raise HTTPException(status_code=400, detail="辅料库采购只能做备料采购,不能显式关联销售订单") + supplier = db.get(Supplier, payload.supplier_id) + if not supplier: + raise HTTPException(status_code=404, detail="供应商不存在") + _ensure_sales_orders_exist(db, explicit_sales_order_ids) + ensure_employee_has_permission( + db, + payload.purchaser_employee_id, + "MENU_PURCHASE_ORDER", + "采购员", + required=False, + ) + + order = PurchaseOrder( + po_no=build_purchase_order_no(db, supplier), + supplier_id=payload.supplier_id, + order_date=payload.order_date or date.today(), + expected_date=payload.expected_date, + warning_lead_days=int(payload.warning_lead_days or 0), + purchaser_employee_id=payload.purchaser_employee_id, + target_warehouse_type=target_warehouse_type, + tax_rate=to_decimal(payload.tax_rate, "0.0001"), + total_amount=to_decimal(0, "0.01"), + status="OPEN", + remark=payload.remark, + ) + db.add(order) + db.flush() + + item_rows, _linked_demand_ids, total_amount = _build_purchase_order_items( + db, + order=order, + payload_items=payload.items, + target_warehouse_type=target_warehouse_type, + ) + for item_row in item_rows: + db.add(item_row) + order.total_amount = total_amount + linked_sales_order_ids = sorted({*explicit_sales_order_ids, *_sales_order_ids_from_items(db, payload.items)}) + _replace_purchase_order_sales_links(db, order.id, linked_sales_order_ids) + db.add(order) + db.commit() + + return _read_purchase_order_after_archive_generation(db, int(order.id), created_by=_auth_context_user_id(context)) + + +@router.put("/orders/{purchase_order_id}", response_model=PurchaseOrderRead) +def update_purchase_order( + purchase_order_id: int, + payload: PurchaseOrderUpdate, + db: Session = Depends(get_db), + context: AuthContext = Depends(require_authenticated_user), +) -> PurchaseOrderRead: + _ensure_purchase_order_target_warehouse_type(db) + ensure_purchase_order_sales_link_table(db) + explicit_sales_order_ids = _normalize_sales_order_ids(payload.sales_order_ids) + target_warehouse_type = _normalize_purchase_target_warehouse_type(payload.target_warehouse_type) + if target_warehouse_type == "AUX" and explicit_sales_order_ids: + raise HTTPException(status_code=400, detail="辅料库采购只能做备料采购,不能显式关联销售订单") + order = db.get(PurchaseOrder, purchase_order_id) + if not order: + raise HTTPException(status_code=404, detail="采购订单不存在") + supplier = db.get(Supplier, payload.supplier_id) + if not supplier: + raise HTTPException(status_code=404, detail="供应商不存在") + old_items = _assert_purchase_order_editable(db, order) + _ensure_sales_orders_exist(db, explicit_sales_order_ids) + ensure_employee_has_permission( + db, + payload.purchaser_employee_id, + "MENU_PURCHASE_ORDER", + "采购员", + required=False, + ) + old_demand_ids = {int(item.source_demand_id) for item in old_items if item.source_demand_id} + + order.supplier_id = payload.supplier_id + order.order_date = payload.order_date or order.order_date or date.today() + order.expected_date = payload.expected_date + order.warning_lead_days = int(payload.warning_lead_days or 0) + order.purchaser_employee_id = payload.purchaser_employee_id + order.target_warehouse_type = target_warehouse_type + order.tax_rate = to_decimal(payload.tax_rate, "0.0001") + order.remark = payload.remark + + db.execute( + sa_delete(PurchaseOrderItem) + .where(PurchaseOrderItem.purchase_order_id == order.id) + .execution_options(synchronize_session=False) + ) + db.flush() + for item in old_items: + if item in db: + db.expunge(item) + + item_rows, _linked_demand_ids, total_amount = _build_purchase_order_items( + db, + order=order, + payload_items=payload.items, + target_warehouse_type=target_warehouse_type, + ) + for item_row in item_rows: + db.add(item_row) + order.total_amount = total_amount + order.status = "OPEN" + linked_sales_order_ids = sorted({*explicit_sales_order_ids, *_sales_order_ids_from_items(db, payload.items)}) + _replace_purchase_order_sales_links(db, order.id, linked_sales_order_ids) + db.add(order) + db.flush() + _reset_unreferenced_demands(db, old_demand_ids) + db.commit() + + return _read_purchase_order_after_archive_generation(db, int(order.id), created_by=_auth_context_user_id(context)) + + +@router.delete("/orders/{purchase_order_id}") +def delete_purchase_order( + purchase_order_id: int, + db: Session = Depends(get_db), +) -> dict[str, str]: + _ensure_purchase_order_target_warehouse_type(db) + ensure_purchase_order_sales_link_table(db) + order = db.get(PurchaseOrder, purchase_order_id) + if not order: + raise HTTPException(status_code=404, detail="采购订单不存在") + old_items = _assert_purchase_order_editable(db, order) + old_demand_ids = {int(item.source_demand_id) for item in old_items if item.source_demand_id} + po_no = order.po_no + + try: + db.execute( + sa_delete(PurchaseOrderItem) + .where(PurchaseOrderItem.purchase_order_id == order.id) + .execution_options(synchronize_session=False) + ) + db.flush() + for item in old_items: + if item in db: + db.expunge(item) + db.execute( + sa_delete(PurchaseOrderSalesOrderLink) + .where(PurchaseOrderSalesOrderLink.purchase_order_id == order.id) + .execution_options(synchronize_session=False) + ) + db.delete(order) + db.flush() + _reset_unreferenced_demands(db, old_demand_ids) + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(status_code=400, detail="采购订单已被后续业务引用,不能删除") from exc + + return {"message": f"采购订单 {po_no} 已删除"} + + +@router.post("/orders/{purchase_order_id}/lock", response_model=PurchaseOrderRead) +def lock_purchase_order( + purchase_order_id: int, + db: Session = Depends(get_db), +) -> PurchaseOrderRead: + _ensure_purchase_order_target_warehouse_type(db) + order = db.get(PurchaseOrder, purchase_order_id) + if not order: + raise HTTPException(status_code=404, detail="采购订单不存在") + if order.status == "LOCKED": + row = db.execute(get_purchase_order_query(limit=1).where(PurchaseOrder.id == order.id)).mappings().first() + rows = _enrich_purchase_order_sources(db, [dict(row)]) + return PurchaseOrderRead.model_validate(rows[0]) + if order.status != "OPEN": + raise HTTPException(status_code=400, detail="只有未开始的采购订单可以锁单") + item_count = db.scalar(select(func.count(PurchaseOrderItem.id)).where(PurchaseOrderItem.purchase_order_id == order.id)) or 0 + if item_count <= 0: + raise HTTPException(status_code=400, detail="采购订单没有明细,不能锁单") + order.status = "LOCKED" + db.add(order) + db.commit() + row = db.execute(get_purchase_order_query(limit=1).where(PurchaseOrder.id == order.id)).mappings().first() + if not row: + raise HTTPException(status_code=500, detail="采购订单锁单后读取失败") + rows = _enrich_purchase_order_sources(db, [dict(row)]) + return PurchaseOrderRead.model_validate(rows[0]) + + +@router.get("/receipts", response_model=list[PurchaseReceiptRead]) +def list_purchase_receipts( + limit: int = Query(default=100, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[PurchaseReceiptRead]: + _ensure_purchase_order_target_warehouse_type(db) + rows = db.execute(get_purchase_receipt_query(limit=limit)).mappings().all() + return [PurchaseReceiptRead.model_validate(dict(row)) for row in rows] + + +@router.get("/receipt-items", response_model=list[PurchaseReceiptItemRead]) +def list_purchase_receipt_items( + receipt_id: int | None = Query(default=None), + limit: int = Query(default=200, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[PurchaseReceiptItemRead]: + _ensure_purchase_order_target_warehouse_type(db) + rows = db.execute(get_purchase_receipt_items_query(limit=limit, receipt_id=receipt_id)).mappings().all() + return [PurchaseReceiptItemRead.model_validate(dict(row)) for row in rows] + + +def _read_purchase_receipt(db: Session, purchase_receipt_id: int) -> PurchaseReceiptRead: + _ensure_purchase_order_target_warehouse_type(db) + row = db.execute(get_purchase_receipt_query(limit=1).where(PurchaseReceipt.id == purchase_receipt_id)).mappings().first() + if not row: + raise HTTPException(status_code=500, detail="到货入库单读取失败") + return PurchaseReceiptRead.model_validate(dict(row)) + + +def _read_purchase_receipt_after_archive_generation( + db: Session, + purchase_receipt_id: int, + *, + created_by: int | None, +) -> PurchaseReceiptRead: + try: + archive_kwargs = {"created_by": created_by} if created_by is not None else {} + archive_result = generate_document_archive( + db, + DOCUMENT_TYPE_PURCHASE_RECEIPT, + int(purchase_receipt_id), + **archive_kwargs, + ) + archive_fields = { + "archive_status": archive_result.archive_status, + "archive_version": archive_result.archive_version, + "archive_error_message": archive_result.archive_error_message, + } + except Exception as exc: + try: + db.rollback() + except Exception: + pass + archive_fields = { + "archive_status": "归档失败", + "archive_version": None, + "archive_error_message": f"归档失败:{exc}", + } + return _read_purchase_receipt(db, purchase_receipt_id).model_copy(update=archive_fields) + + +@router.post("/receipts", response_model=PurchaseReceiptRead) +def create_purchase_receipt( + payload: PurchaseReceiptCreate, + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> PurchaseReceiptRead: + _ensure_purchase_order_target_warehouse_type(db) + purchase_order = db.get(PurchaseOrder, payload.purchase_order_id) + if not purchase_order: + raise HTTPException(status_code=404, detail="采购订单不存在") + display_status = _get_purchase_order_display_status(db, purchase_order.id) + if purchase_order.status not in RECEIVABLE_PURCHASE_ORDER_STATUSES and display_status not in RECEIVABLE_PURCHASE_ORDER_STATUSES: + raise HTTPException(status_code=400, detail="只有已锁单且未完成收货的采购订单可以到货入库") + warehouse = db.get(Warehouse, payload.warehouse_id) + if not warehouse: + raise HTTPException(status_code=404, detail="仓库不存在") + target_warehouse_type = _normalize_purchase_target_warehouse_type(purchase_order.target_warehouse_type) + warehouse_type = str(warehouse.warehouse_type or "").upper() + if warehouse_type != target_warehouse_type: + raise HTTPException( + status_code=400, + detail=f"该采购订单目标到货仓库为{_purchase_target_label(target_warehouse_type)},不能入库到{warehouse.warehouse_name}", + ) + logistics_waybill_no, logistics_freight_amount, logistics_photo_url = normalize_logistics_fields( + payload.waybill_no, + payload.freight_amount, + required=target_warehouse_type == "RAW", + order_photo_url=payload.order_photo_url, + freight_required=None if target_warehouse_type == "RAW" else False, + ) + ensure_warehouses_unlocked(db, [payload.warehouse_id], "到货入库") + if not payload.items: + raise HTTPException(status_code=400, detail="到货入库至少需要一条明细") + ensure_employee_has_permission( + db, + payload.receiver_employee_id, + "MENU_PURCHASE_RECEIPT", + "接收人", + required=False, + ) + + receipt = PurchaseReceipt( + receipt_no=build_purchase_receipt_no(db, purchase_order), + purchase_order_id=payload.purchase_order_id, + warehouse_id=payload.warehouse_id, + receipt_date=payload.receipt_date or datetime.now(), + receiver_employee_id=payload.receiver_employee_id, + supplier_delivery_no=payload.supplier_delivery_no, + logistics_waybill_no=logistics_waybill_no, + logistics_freight_amount=logistics_freight_amount, + logistics_photo_url=logistics_photo_url, + status="PENDING_QC", + remark=payload.remark, + ) + + try: + db.add(receipt) + db.flush() + + selected_po_item_ids: set[int] = set() + for index, item_payload in enumerate(payload.items, start=1): + po_item = db.get(PurchaseOrderItem, item_payload.purchase_order_item_id) + if not po_item or po_item.purchase_order_id != payload.purchase_order_id: + raise HTTPException(status_code=400, detail="采购订单明细不存在或不属于当前采购订单") + if item_payload.purchase_order_item_id in selected_po_item_ids: + raise HTTPException(status_code=400, detail=f"采购行 {po_item.line_no} 在同一张到货单中重复提交") + selected_po_item_ids.add(item_payload.purchase_order_item_id) + if po_item.material_item_id != item_payload.material_item_id: + raise HTTPException(status_code=400, detail=f"采购行 {po_item.line_no} 的原材料与当前选择不一致") + + material_item = db.get(Item, item_payload.material_item_id) + item_label = "辅料" if target_warehouse_type == "AUX" else "原材料" + if not material_item: + raise HTTPException(status_code=404, detail=f"{item_label}不存在: {item_payload.material_item_id}") + + if target_warehouse_type == "AUX": + received_qty = to_decimal(item_payload.received_qty) + received_weight = to_decimal(0) + if received_qty <= 0: + raise HTTPException(status_code=400, detail=f"采购行 {po_item.line_no} 的收货数量必须大于0") + else: + received_qty = to_decimal(0) + received_weight = to_decimal(item_payload.received_weight_kg) + if received_weight <= 0: + raise HTTPException(status_code=400, detail=f"采购行 {po_item.line_no} 的收货重量必须大于0") + entered_unit_cost = to_decimal(item_payload.unit_cost, "0.0001") + if entered_unit_cost < 0: + raise HTTPException(status_code=400, detail=f"采购行 {po_item.line_no} 的入库单价不能小于0") + if entered_unit_cost <= 0: + entered_unit_cost = to_decimal(po_item.unit_price, "0.0001") + + location = get_location_or_default(db, payload.warehouse_id, item_payload.location_id) + if location and location.warehouse_id != payload.warehouse_id: + raise HTTPException(status_code=400, detail=f"采购行 {po_item.line_no} 选择的库位不属于当前仓库") + + final_unit_cost = entered_unit_cost + lot_no = build_inventory_lot_no(db, item_payload.material_item_id) + + receipt_item = PurchaseReceiptItem( + receipt_id=receipt.id, + line_no=index, + purchase_order_item_id=item_payload.purchase_order_item_id, + material_item_id=item_payload.material_item_id, + location_id=location.id if location else None, + lot_no=lot_no, + merge_to_lot_id=None, + received_qty=received_qty, + received_weight_kg=received_weight, + accepted_qty=to_decimal(0), + accepted_weight_kg=to_decimal(0), + unit_cost=final_unit_cost, + status="PENDING_QC", + remark=item_payload.remark, + ) + db.add(receipt_item) + db.flush() + + lot = StockLot( + lot_no=lot_no, + parent_lot_id=None, + lot_role="INBOUND_RAW", + material_sub_batch_no=None, + item_id=item_payload.material_item_id, + warehouse_id=payload.warehouse_id, + location_id=location.id if location else None, + source_doc_type="PURCHASE_RECEIPT", + source_doc_id=receipt.id, + source_line_id=receipt_item.id, + source_material_lot_id=None, + source_material_sub_batch_no=None, + source_material_summary=None, + inbound_qty=received_qty, + inbound_weight_kg=received_weight, + remaining_qty=received_qty, + remaining_weight_kg=received_weight, + locked_qty=received_qty, + locked_weight_kg=received_weight, + unit_cost=final_unit_cost, + production_date=None, + expire_date=None, + quality_status="PENDING_QC", + logistics_waybill_no=logistics_waybill_no, + logistics_freight_amount=logistics_freight_amount, + logistics_photo_url=logistics_photo_url, + status="LOCKED", + remark="到货待检", + ) + db.add(lot) + db.flush() + + upsert_stock_balance( + db, + item_id=item_payload.material_item_id, + warehouse_id=payload.warehouse_id, + location_id=location.id if location else None, + qty_delta=received_qty, + weight_delta=received_weight, + available_qty_delta=to_decimal(0), + available_weight_delta=to_decimal(0), + unit_cost=final_unit_cost, + ) + create_inventory_txn( + db, + txn_type="PURCHASE_IN", + item_id=item_payload.material_item_id, + warehouse_id=payload.warehouse_id, + location_id=location.id if location else None, + lot_id=lot.id, + qty_change=received_qty, + weight_change=received_weight, + unit_cost=final_unit_cost, + source_doc_type="PURCHASE_RECEIPT", + source_doc_id=receipt.id, + source_line_id=receipt_item.id, + biz_time=receipt.receipt_date, + operator_user_id=context.user.id, + logistics_waybill_no=logistics_waybill_no, + logistics_freight_amount=logistics_freight_amount, + logistics_photo_url=logistics_photo_url, + remark="采购到货入库", + amount_basis="QTY" if target_warehouse_type == "AUX" else "WEIGHT", + ) + + po_item.received_qty = to_decimal(po_item.received_qty) + received_qty + po_item.received_weight_kg = to_decimal(po_item.received_weight_kg) + received_weight + sync_purchase_order_item_status(po_item) + db.add(po_item) + + if po_item.source_demand_id: + demand = db.get(MaterialDemand, po_item.source_demand_id) + if demand: + demand.status = "ORDERED" + db.add(demand) + + sync_purchase_order_status(db, payload.purchase_order_id) + db.commit() + except IntegrityError as exc: + db.rollback() + error_text = str(exc.orig or exc) + if "lot_no" in error_text or "Duplicate entry" in error_text: + raise HTTPException(status_code=400, detail="批次号重复,请更换批次号后重试") from exc + raise HTTPException(status_code=400, detail="到货入库保存失败,请检查采购行、批次号、接收人与收货重量后重试") from exc + + return _read_purchase_receipt_after_archive_generation(db, receipt.id, created_by=_auth_context_user_id(context)) diff --git a/backend/app/api/routes/quality.py b/backend/app/api/routes/quality.py new file mode 100644 index 0000000..f216d82 --- /dev/null +++ b/backend/app/api/routes/quality.py @@ -0,0 +1,190 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import and_, select +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.models.operations import PurchaseOrderItem, PurchaseReceiptItem, StockLot +from app.models.planning import MaterialDemand +from app.schemas.operations import QualityInspectionAction, QualityInspectionRead +from app.services.auth import AuthContext, require_authenticated_user +from app.services.document_archives import DOCUMENT_TYPE_QUALITY_INSPECTION, generate_document_archive +from app.services.operations import ( + get_quality_inspections_query, + recalc_purchase_order_item_received_weight, + sync_purchase_order_status, + sync_purchase_receipt_status, + to_decimal, + upsert_stock_balance, +) + +router = APIRouter(dependencies=[Depends(require_authenticated_user)]) + + +def _auth_context_user_id(context: AuthContext | object) -> int | None: + user = getattr(context, "user", None) + user_id = getattr(user, "id", None) + return int(user_id) if user_id is not None else None + + +def _read_quality_inspection(db: Session, receipt_item_id: int) -> QualityInspectionRead: + row = db.execute(get_quality_inspections_query(limit=1).where(PurchaseReceiptItem.id == receipt_item_id)).mappings().first() + if not row: + raise HTTPException(status_code=500, detail="质检结果保存后读取失败") + return QualityInspectionRead.model_validate(dict(row)) + + +def _read_quality_inspection_after_archive_generation( + db: Session, + receipt_item_id: int, + *, + created_by: int | None, +) -> QualityInspectionRead: + try: + archive_kwargs = {"created_by": created_by} if created_by is not None else {} + archive_result = generate_document_archive( + db, + DOCUMENT_TYPE_QUALITY_INSPECTION, + int(receipt_item_id), + **archive_kwargs, + ) + archive_fields = { + "archive_status": archive_result.archive_status, + "archive_version": archive_result.archive_version, + "archive_error_message": archive_result.archive_error_message, + } + except Exception as exc: + try: + db.rollback() + except Exception: + pass + archive_fields = { + "archive_status": "归档失败", + "archive_version": None, + "archive_error_message": f"归档失败:{exc}", + } + return _read_quality_inspection(db, receipt_item_id).model_copy(update=archive_fields) + + +@router.get("/receipt-inspections", response_model=list[QualityInspectionRead]) +def list_receipt_inspections( + pending_only: bool = Query(default=False), + limit: int = Query(default=100, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[QualityInspectionRead]: + rows = db.execute(get_quality_inspections_query(limit=limit, pending_only=pending_only)).mappings().all() + return [QualityInspectionRead.model_validate(dict(row)) for row in rows] + + +@router.post("/receipt-inspections/{receipt_item_id}", response_model=QualityInspectionRead) +def inspect_receipt_item( + receipt_item_id: int, + payload: QualityInspectionAction, + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> QualityInspectionRead: + receipt_item = db.get(PurchaseReceiptItem, receipt_item_id) + if not receipt_item: + raise HTTPException(status_code=404, detail="到货明细不存在") + + lot = db.scalar( + select(StockLot).where( + StockLot.source_doc_type == "PURCHASE_RECEIPT", + StockLot.source_doc_id == receipt_item.receipt_id, + StockLot.source_line_id == receipt_item.id, + ) + ) + merged_into_existing_lot = False + if not lot and receipt_item.merge_to_lot_id: + lot = db.get(StockLot, receipt_item.merge_to_lot_id) + merged_into_existing_lot = True + if not lot: + raise HTTPException(status_code=404, detail="对应批次不存在") + + po_item = db.get(PurchaseOrderItem, receipt_item.purchase_order_item_id) + if not po_item: + raise HTTPException(status_code=404, detail="采购订单明细不存在") + + result = payload.result.upper() + if result not in {"PASS", "REJECT"}: + raise HTTPException(status_code=400, detail="质检结果只支持 PASS 或 REJECT") + + if result == "PASS": + receipt_qty = to_decimal(receipt_item.received_qty) + receipt_weight = to_decimal(receipt_item.received_weight_kg) + receipt_item.accepted_qty = receipt_qty + receipt_item.accepted_weight_kg = receipt_weight + receipt_item.status = "PASSED" + lot.quality_status = "PASS" + lot.status = "AVAILABLE" + if merged_into_existing_lot: + lot.locked_qty = max(to_decimal(0), to_decimal(lot.locked_qty) - receipt_qty) + lot.locked_weight_kg = max( + to_decimal(0), + to_decimal(lot.locked_weight_kg) - receipt_weight, + ) + else: + lot.locked_qty = to_decimal(0) + lot.locked_weight_kg = to_decimal(0) + upsert_stock_balance( + db, + item_id=lot.item_id, + warehouse_id=lot.warehouse_id, + location_id=lot.location_id, + qty_delta=to_decimal(0), + weight_delta=to_decimal(0), + available_qty_delta=receipt_qty, + available_weight_delta=receipt_weight, + unit_cost=to_decimal(lot.unit_cost, "0.0001"), + ) + else: + receipt_item.accepted_qty = to_decimal(0) + receipt_item.accepted_weight_kg = to_decimal(0) + receipt_item.status = "REJECTED" + if merged_into_existing_lot: + reject_qty = to_decimal(receipt_item.received_qty) + reject_weight = to_decimal(receipt_item.received_weight_kg) + lot.inbound_qty = max(to_decimal(0), to_decimal(lot.inbound_qty) - reject_qty) + lot.inbound_weight_kg = max(to_decimal(0), to_decimal(lot.inbound_weight_kg) - reject_weight) + lot.remaining_qty = max(to_decimal(0), to_decimal(lot.remaining_qty) - reject_qty) + lot.remaining_weight_kg = max(to_decimal(0), to_decimal(lot.remaining_weight_kg) - reject_weight) + lot.locked_qty = max(to_decimal(0), to_decimal(lot.locked_qty) - reject_qty) + lot.locked_weight_kg = max(to_decimal(0), to_decimal(lot.locked_weight_kg) - reject_weight) + if lot.remaining_weight_kg <= 0 and lot.remaining_qty <= 0: + lot.status = "DEPLETED" + upsert_stock_balance( + db, + item_id=lot.item_id, + warehouse_id=lot.warehouse_id, + location_id=lot.location_id, + qty_delta=-reject_qty, + weight_delta=-reject_weight, + available_qty_delta=to_decimal(0), + available_weight_delta=to_decimal(0), + unit_cost=to_decimal(lot.unit_cost, "0.0001"), + ) + else: + lot.quality_status = "REJECT" + lot.status = "REJECTED" + lot.locked_qty = to_decimal(lot.remaining_qty) + lot.locked_weight_kg = to_decimal(lot.remaining_weight_kg) + + receipt_item.remark = payload.remark or receipt_item.remark + lot.remark = context.employee.employee_name + db.add(receipt_item) + db.add(lot) + db.flush() + po_item = recalc_purchase_order_item_received_weight(db, po_item.id) or po_item + if po_item.source_demand_id and po_item.status == "CLOSED": + demand = db.get(MaterialDemand, po_item.source_demand_id) + if demand: + demand.status = "RECEIVED" + db.add(demand) + sync_purchase_receipt_status(db, receipt_item.receipt_id) + sync_purchase_order_status(db, po_item.purchase_order_id) + db.commit() + + return _read_quality_inspection_after_archive_generation( + db, + receipt_item_id, + created_by=_auth_context_user_id(context), + ) diff --git a/backend/app/api/routes/returns.py b/backend/app/api/routes/returns.py new file mode 100644 index 0000000..41927ad --- /dev/null +++ b/backend/app/api/routes/returns.py @@ -0,0 +1,798 @@ +from datetime import datetime +from decimal import Decimal + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import func, select +from sqlalchemy.orm import Session, aliased + +from app.db.session import get_db +from app.models.master_data import Item, Warehouse +from app.models.operations import Delivery, DeliveryItem, ReturnDisposition, ReturnItem, ReturnOrder, StockLot +from app.models.sales import Customer, SalesOrder, SalesOrderItem +from app.schemas.operations import ( + ReturnDispositionCreate, + ReturnDispositionRead, + ReturnItemRead, + ReturnOrderCreate, + ReturnOrderRead, + ReturnScrapInboundCreate, +) +from app.services.auth import AuthContext, require_authenticated_user +from app.services.document_archives import DOCUMENT_TYPE_WAREHOUSE_OPERATION, generate_document_archive +from app.services.operations import ( + create_inventory_txn, + create_rework_work_order, + get_location_or_default, + get_return_dispositions_query, + get_return_items_query, + get_return_orders_query, + sync_return_order_status, + to_decimal, + upsert_stock_balance, +) +from app.services.system_permissions import ensure_employee_has_permission +router = APIRouter(dependencies=[Depends(require_authenticated_user)]) + + +def _generate_return_archive_fields( + db: Session, + *, + business_id: int | None, + created_by: int | None, +) -> dict[str, object]: + if not business_id: + return { + "archive_status": None, + "archive_business_id": None, + "archive_document_type": None, + "archive_error_message": None, + } + try: + archive_result = generate_document_archive( + db, + DOCUMENT_TYPE_WAREHOUSE_OPERATION, + int(business_id), + created_by=created_by, + ) + return { + "archive_status": archive_result.archive_status, + "archive_business_id": int(business_id), + "archive_document_type": DOCUMENT_TYPE_WAREHOUSE_OPERATION, + "archive_error_message": archive_result.archive_error_message, + } + except Exception: + return { + "archive_status": "归档失败", + "archive_business_id": int(business_id), + "archive_document_type": DOCUMENT_TYPE_WAREHOUSE_OPERATION, + "archive_error_message": "归档失败:PDF归档服务异常,请稍后重新生成", + } + + +def _apply_archive_fields_to_model(model, archive_fields: dict[str, object]): + model.archive_status = archive_fields["archive_status"] + model.archive_business_id = archive_fields["archive_business_id"] + model.archive_document_type = archive_fields["archive_document_type"] + model.archive_error_message = archive_fields["archive_error_message"] + return model + + +def get_return_warehouse(db: Session, warehouse_id: int | None) -> Warehouse: + if warehouse_id: + warehouse = db.get(Warehouse, warehouse_id) + else: + warehouse = db.scalar(select(Warehouse).where(Warehouse.warehouse_type == "RETURN", Warehouse.status == "ACTIVE")) + if not warehouse: + raise HTTPException(status_code=404, detail="退货仓不存在") + if str(warehouse.warehouse_type or "").upper() != "RETURN": + raise HTTPException(status_code=400, detail="退货入库只能进入退货库") + if str(warehouse.status or "").upper() != "ACTIVE": + raise HTTPException(status_code=400, detail="退货仓未启用,不能办理退货入库") + return warehouse + + +def _serial_code_part(value: str | None, fallback: str, max_length: int = 14) -> str: + token = "".join(ch for ch in str(value or "").upper() if ch.isalnum()) + return (token or fallback)[-max_length:] + + +def build_return_no(db: Session, customer: Customer) -> str: + date_part = datetime.now().strftime("%Y%m%d") + customer_part = _serial_code_part(customer.short_name or customer.customer_code, f"CUS{customer.id:04d}") + prefix = f"RTN-NB-{date_part}-{customer_part}" + count = db.scalar(select(func.count(ReturnOrder.id)).where(ReturnOrder.return_no.like(f"{prefix}-%"))) or 0 + return f"{prefix}-{count + 1:03d}" + + +def build_return_disposition_no(db: Session, return_order: ReturnOrder, disposition_type: str) -> str: + date_part = datetime.now().strftime("%Y%m%d") + type_part = "RWK" if disposition_type == "REWORK" else "SCRAP" + return_part = _serial_code_part(return_order.return_no, f"RTN{return_order.id:04d}", 18) + prefix = f"RTD-NB-{type_part}-{date_part}-{return_part}" + count = db.scalar(select(func.count(ReturnDisposition.id)).where(ReturnDisposition.disposition_no.like(f"{prefix}-%"))) or 0 + return f"{prefix}-{count + 1:03d}" + + +def build_return_lot_no(db: Session, return_order: ReturnOrder, product: Item, line_no: int) -> str: + date_part = datetime.now().strftime("%Y%m%d") + return_part = _serial_code_part(return_order.return_no, f"RTN{return_order.id:04d}", 12) + product_part = _serial_code_part(product.item_code or product.item_name, f"FG{product.id:04d}", 10) + base_lot_no = f"RTL-NB-{date_part}-{return_part}-{product_part}-L{line_no:02d}" + candidate = base_lot_no + suffix = 1 + while db.scalar(select(func.count(StockLot.id)).where(StockLot.lot_no == candidate)): + suffix += 1 + candidate = f"{base_lot_no}-{suffix:02d}" + return candidate + + +def get_delivery_item_trace(db: Session, delivery_item_id: int) -> dict: + source_lot = aliased(StockLot) + source_material_lot = aliased(StockLot) + source_material_item = aliased(Item) + row = db.execute( + select( + DeliveryItem.id.label("delivery_item_id"), + Delivery.id.label("delivery_id"), + Delivery.delivery_no.label("delivery_no"), + Delivery.status.label("delivery_status"), + Delivery.customer_id.label("customer_id"), + Delivery.sales_order_id.label("sales_order_id"), + DeliveryItem.sales_order_item_id.label("sales_order_item_id"), + DeliveryItem.product_item_id.label("product_item_id"), + DeliveryItem.status.label("delivery_item_status"), + source_lot.id.label("source_lot_id"), + DeliveryItem.delivery_qty.label("delivery_qty"), + DeliveryItem.delivery_weight_kg.label("delivery_weight_kg"), + source_lot.lot_no.label("source_lot_no"), + source_lot.source_material_lot_id.label("source_material_lot_id"), + source_material_lot.lot_no.label("source_material_lot_no"), + source_material_item.item_code.label("source_material_code"), + source_material_item.item_name.label("source_material_name"), + source_lot.source_material_sub_batch_no.label("source_material_sub_batch_no"), + source_lot.source_material_summary.label("source_material_summary"), + ) + .join(Delivery, Delivery.id == DeliveryItem.delivery_id) + .join(source_lot, source_lot.id == DeliveryItem.lot_id) + .outerjoin(source_material_lot, source_material_lot.id == source_lot.source_material_lot_id) + .outerjoin(source_material_item, source_material_item.id == source_material_lot.item_id) + .where(DeliveryItem.id == delivery_item_id) + ).mappings().first() + if not row: + raise HTTPException(status_code=404, detail="发货批次不存在") + return dict(row) + + +def _returned_totals_for_delivery_item(db: Session, delivery_item_id: int) -> tuple[Decimal, Decimal]: + row = db.execute( + select( + func.coalesce(func.sum(ReturnItem.return_qty), 0).label("returned_qty"), + func.coalesce(func.sum(ReturnItem.return_weight_kg), 0).label("returned_weight"), + ).where(ReturnItem.delivery_item_id == delivery_item_id) + ).mappings().first() + return to_decimal(row["returned_qty"]), to_decimal(row["returned_weight"]) + + +def _lock_delivery_items_for_return(db: Session, delivery_item_ids: set[int]) -> None: + ids = sorted(int(item_id) for item_id in delivery_item_ids if item_id) + if not ids: + return + db.execute(select(DeliveryItem.id).where(DeliveryItem.id.in_(ids)).with_for_update()).all() + + +def _ensure_return_amount_available( + db: Session, + *, + trace: dict, + requested_qty: Decimal, + requested_weight: Decimal, + staged_qty: Decimal = Decimal("0"), + staged_weight: Decimal = Decimal("0"), +) -> None: + returned_qty, returned_weight = _returned_totals_for_delivery_item(db, int(trace["delivery_item_id"])) + delivery_qty = to_decimal(trace["delivery_qty"]) + delivery_weight = to_decimal(trace["delivery_weight_kg"]) + if delivery_qty > 0 and returned_qty + staged_qty + requested_qty > delivery_qty: + raise HTTPException(status_code=400, detail="退货数量超过发货批次可退数量") + if delivery_weight > 0 and returned_weight + staged_weight + requested_weight > delivery_weight: + raise HTTPException(status_code=400, detail="退货重量超过发货批次可退重量") + + +def _validate_return_delivery_trace( + item_payload, + trace: dict, + *, + customer_id: int, + delivery_id: int | None, + sales_order_id: int | None, +) -> None: + if str(trace.get("delivery_status") or "").upper() != "POSTED": + raise HTTPException(status_code=400, detail="发货单未过账,不能办理退货") + if str(trace.get("delivery_item_status") or "").upper() != "POSTED": + raise HTTPException(status_code=400, detail="发货批次未过账,不能办理退货") + if trace["customer_id"] != customer_id: + raise HTTPException(status_code=400, detail="发货批次客户与退货客户不一致") + if delivery_id and trace["delivery_id"] != delivery_id: + raise HTTPException(status_code=400, detail="发货批次不属于所选发货单") + if sales_order_id and trace["sales_order_id"] and trace["sales_order_id"] != sales_order_id: + raise HTTPException(status_code=400, detail="发货批次不属于所选订单") + if item_payload.product_item_id != trace["product_item_id"]: + raise HTTPException(status_code=400, detail="退货产品与发货批次不一致") + if item_payload.source_lot_id and item_payload.source_lot_id != trace["source_lot_id"]: + raise HTTPException(status_code=400, detail="退货来源批次与发货批次不一致") + if ( + getattr(item_payload, "sales_order_item_id", None) + and trace["sales_order_item_id"] + and item_payload.sales_order_item_id != trace["sales_order_item_id"] + ): + raise HTTPException(status_code=400, detail="退货订单明细与发货批次不一致") + + +@router.get("/orders", response_model=list[ReturnOrderRead]) +def list_return_orders( + limit: int = Query(default=100, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[ReturnOrderRead]: + rows = db.execute(get_return_orders_query(limit=limit)).mappings().all() + return [ReturnOrderRead.model_validate(dict(row)) for row in rows] + + +@router.get("/items", response_model=list[ReturnItemRead]) +def list_return_items( + return_order_id: int | None = Query(default=None), + limit: int = Query(default=200, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[ReturnItemRead]: + rows = db.execute(get_return_items_query(limit=limit, return_order_id=return_order_id)).mappings().all() + return [ReturnItemRead.model_validate(dict(row)) for row in rows] + + +@router.post("/orders", response_model=ReturnOrderRead) +def create_return_order( + payload: ReturnOrderCreate, + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> ReturnOrderRead: + customer = db.get(Customer, payload.customer_id) + if not customer: + raise HTTPException(status_code=404, detail="客户不存在") + if payload.sales_order_id and not db.get(SalesOrder, payload.sales_order_id): + raise HTTPException(status_code=404, detail="订单不存在") + if payload.delivery_id and not db.get(Delivery, payload.delivery_id): + raise HTTPException(status_code=404, detail="发货单不存在") + if not payload.items: + raise HTTPException(status_code=400, detail="退货单至少需要一条明细") + + warehouse = get_return_warehouse(db, payload.warehouse_id) + prepared_items = [] + staged_by_delivery_item: dict[int, tuple[Decimal, Decimal]] = {} + order_delivery_id = payload.delivery_id + order_sales_order_id = payload.sales_order_id + for item_payload in payload.items: + if not item_payload.delivery_item_id: + raise HTTPException(status_code=400, detail="退货入库必须选择发货批次") + trace = get_delivery_item_trace(db, item_payload.delivery_item_id) + if order_delivery_id is None: + order_delivery_id = trace["delivery_id"] + if order_sales_order_id is None: + order_sales_order_id = trace["sales_order_id"] + _validate_return_delivery_trace( + item_payload, + trace, + customer_id=payload.customer_id, + delivery_id=order_delivery_id, + sales_order_id=order_sales_order_id, + ) + product = db.get(Item, item_payload.product_item_id) + if not product: + raise HTTPException(status_code=404, detail=f"产品不存在: {item_payload.product_item_id}") + if item_payload.sales_order_item_id: + order_item = db.get(SalesOrderItem, item_payload.sales_order_item_id) + if not order_item: + raise HTTPException(status_code=404, detail=f"订单明细不存在: {item_payload.sales_order_item_id}") + return_qty = to_decimal(item_payload.return_qty) + if return_qty <= 0: + raise HTTPException(status_code=400, detail="退货数量必须大于0") + return_weight = to_decimal(item_payload.return_weight_kg) + if return_weight <= 0: + return_weight = return_qty * to_decimal(product.unit_weight_kg) + if return_weight <= 0: + raise HTTPException(status_code=400, detail="退货重量必须大于0") + staged_qty, staged_weight = staged_by_delivery_item.get( + int(item_payload.delivery_item_id), + (Decimal("0"), Decimal("0")), + ) + _ensure_return_amount_available( + db, + trace=trace, + requested_qty=return_qty, + requested_weight=return_weight, + staged_qty=staged_qty, + staged_weight=staged_weight, + ) + staged_by_delivery_item[int(item_payload.delivery_item_id)] = ( + staged_qty + return_qty, + staged_weight + return_weight, + ) + prepared_items.append((item_payload, trace, product, return_qty, return_weight)) + + ensure_employee_has_permission( + db, + payload.handler_employee_id, + "MENU_INVENTORY_LEDGER", + "经办人", + required=False, + ) + _lock_delivery_items_for_return(db, {int(item.delivery_item_id) for item, *_ in prepared_items}) + staged_by_delivery_item = {} + for item_payload, trace, _product, return_qty, return_weight in prepared_items: + staged_qty, staged_weight = staged_by_delivery_item.get( + int(item_payload.delivery_item_id), + (Decimal("0"), Decimal("0")), + ) + _ensure_return_amount_available( + db, + trace=trace, + requested_qty=return_qty, + requested_weight=return_weight, + staged_qty=staged_qty, + staged_weight=staged_weight, + ) + staged_by_delivery_item[int(item_payload.delivery_item_id)] = ( + staged_qty + return_qty, + staged_weight + return_weight, + ) + + order = ReturnOrder( + return_no=build_return_no(db, customer), + customer_id=payload.customer_id, + sales_order_id=order_sales_order_id, + delivery_id=order_delivery_id, + return_date=payload.return_date or datetime.now(), + return_reason=payload.return_reason, + handler_employee_id=payload.handler_employee_id, + status="CREATED", + remark=payload.remark, + ) + db.add(order) + db.flush() + + location = get_location_or_default(db, warehouse.id, None) + first_inventory_txn_id: int | None = None + for index, (item_payload, trace, product, return_qty, return_weight) in enumerate(prepared_items, start=1): + return_item = ReturnItem( + return_order_id=order.id, + line_no=index, + sales_order_item_id=item_payload.sales_order_item_id or trace["sales_order_item_id"], + delivery_item_id=item_payload.delivery_item_id, + product_item_id=item_payload.product_item_id, + source_lot_id=trace["source_lot_id"], + return_qty=return_qty, + return_weight_kg=return_weight, + disposition_status="PENDING", + responsibility_type=item_payload.responsibility_type, + remark=item_payload.remark, + ) + db.add(return_item) + db.flush() + + lot = StockLot( + lot_no=build_return_lot_no(db, order, product, index), + parent_lot_id=trace["source_lot_id"], + lot_role="RETURNED_FINISHED", + material_sub_batch_no=None, + item_id=item_payload.product_item_id, + warehouse_id=warehouse.id, + location_id=location.id if location else None, + source_doc_type="RETURN_ORDER", + source_doc_id=order.id, + source_line_id=return_item.id, + source_material_lot_id=trace["source_material_lot_id"], + source_material_sub_batch_no=trace["source_material_sub_batch_no"], + source_material_summary=trace["source_material_summary"], + inbound_qty=return_qty, + inbound_weight_kg=return_weight, + remaining_qty=return_qty, + remaining_weight_kg=return_weight, + locked_qty=return_qty, + locked_weight_kg=return_weight, + unit_cost=to_decimal(0, "0.0001"), + production_date=None, + expire_date=None, + quality_status="PENDING", + status="LOCKED", + remark="客户退货待处置", + ) + db.add(lot) + db.flush() + + upsert_stock_balance( + db, + item_id=item_payload.product_item_id, + warehouse_id=warehouse.id, + location_id=location.id if location else None, + qty_delta=return_qty, + weight_delta=return_weight, + available_qty_delta=to_decimal(0), + available_weight_delta=to_decimal(0), + unit_cost=to_decimal(0, "0.0001"), + ) + txn = create_inventory_txn( + db, + txn_type="RETURN_IN", + item_id=item_payload.product_item_id, + warehouse_id=warehouse.id, + location_id=location.id if location else None, + lot_id=lot.id, + qty_change=return_qty, + weight_change=return_weight, + unit_cost=to_decimal(0, "0.0001"), + source_doc_type="RETURN_ORDER", + source_doc_id=order.id, + source_line_id=return_item.id, + biz_time=order.return_date, + operator_user_id=context.user.id, + remark="客户退货入库", + amount_basis="QTY", + ) + if first_inventory_txn_id is None: + first_inventory_txn_id = int(txn.id) + + db.commit() + archive_fields = _generate_return_archive_fields(db, business_id=first_inventory_txn_id, created_by=context.user.id) + + row = db.execute(get_return_orders_query(limit=1).where(ReturnOrder.id == order.id)).mappings().first() + if not row: + raise HTTPException(status_code=500, detail="退货单创建后读取失败") + return _apply_archive_fields_to_model(ReturnOrderRead.model_validate(dict(row)), archive_fields) + + +@router.post("/scrap-inbound", response_model=ReturnOrderRead) +def create_return_scrap_inbound( + payload: ReturnScrapInboundCreate, + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> ReturnOrderRead: + customer = db.get(Customer, payload.customer_id) + if not customer: + raise HTTPException(status_code=404, detail="客户不存在") + delivery = db.get(Delivery, payload.delivery_id) + if not delivery: + raise HTTPException(status_code=404, detail="发货单不存在") + warehouse = db.get(Warehouse, payload.warehouse_id) + if not warehouse: + raise HTTPException(status_code=404, detail="废料仓不存在") + if str(warehouse.warehouse_type or "").upper() != "SCRAP": + raise HTTPException(status_code=400, detail="退货报废入库只能进入废料库") + if str(warehouse.status or "").upper() != "ACTIVE": + raise HTTPException(status_code=400, detail="废料仓未启用,不能办理退货报废入库") + if not payload.items: + raise HTTPException(status_code=400, detail="退货报废入库至少需要一条明细") + + prepared_items = [] + staged_by_delivery_item: dict[int, tuple[Decimal, Decimal]] = {} + for item_payload in payload.items: + trace = get_delivery_item_trace(db, item_payload.delivery_item_id) + _validate_return_delivery_trace( + item_payload, + trace, + customer_id=payload.customer_id, + delivery_id=payload.delivery_id, + sales_order_id=delivery.sales_order_id, + ) + product = db.get(Item, item_payload.product_item_id) + if not product: + raise HTTPException(status_code=404, detail=f"产品不存在: {item_payload.product_item_id}") + scrap_weight = to_decimal(item_payload.scrap_weight_kg) + if scrap_weight <= 0: + raise HTTPException(status_code=400, detail="退货报废入库重量必须大于0") + scrap_qty = to_decimal(item_payload.scrap_qty) + unit_weight = to_decimal(product.unit_weight_kg) + if scrap_qty <= 0 and unit_weight > 0: + scrap_qty = scrap_weight / unit_weight + if scrap_qty <= 0: + raise HTTPException(status_code=400, detail="退货报废数量必须大于0") + staged_qty, staged_weight = staged_by_delivery_item.get( + int(item_payload.delivery_item_id), + (Decimal("0"), Decimal("0")), + ) + _ensure_return_amount_available( + db, + trace=trace, + requested_qty=scrap_qty, + requested_weight=scrap_weight, + staged_qty=staged_qty, + staged_weight=staged_weight, + ) + staged_by_delivery_item[int(item_payload.delivery_item_id)] = ( + staged_qty + scrap_qty, + staged_weight + scrap_weight, + ) + unit_cost = ( + to_decimal(item_payload.unit_cost, "0.0001") + if item_payload.unit_cost is not None + else to_decimal(product.scrap_sale_price or 0, "0.0001") + ) + prepared_items.append((item_payload, trace, product, scrap_qty, scrap_weight, unit_cost)) + + ensure_employee_has_permission( + db, + payload.handler_employee_id, + "MENU_INVENTORY_LEDGER", + "经办人", + required=False, + ) + _lock_delivery_items_for_return(db, {int(item.delivery_item_id) for item, *_ in prepared_items}) + staged_by_delivery_item = {} + for item_payload, trace, _product, scrap_qty, scrap_weight, _unit_cost in prepared_items: + staged_qty, staged_weight = staged_by_delivery_item.get( + int(item_payload.delivery_item_id), + (Decimal("0"), Decimal("0")), + ) + _ensure_return_amount_available( + db, + trace=trace, + requested_qty=scrap_qty, + requested_weight=scrap_weight, + staged_qty=staged_qty, + staged_weight=staged_weight, + ) + staged_by_delivery_item[int(item_payload.delivery_item_id)] = ( + staged_qty + scrap_qty, + staged_weight + scrap_weight, + ) + + inbound_time = payload.inbound_date or datetime.now() + order = ReturnOrder( + return_no=build_return_no(db, customer), + customer_id=payload.customer_id, + sales_order_id=delivery.sales_order_id, + delivery_id=payload.delivery_id, + return_date=inbound_time, + return_reason=payload.return_reason, + handler_employee_id=payload.handler_employee_id, + status="CLOSED", + remark=payload.remark, + ) + db.add(order) + db.flush() + + location = get_location_or_default(db, warehouse.id, None) + first_inventory_txn_id: int | None = None + for index, (item_payload, trace, product, scrap_qty, scrap_weight, unit_cost) in enumerate(prepared_items, start=1): + return_item = ReturnItem( + return_order_id=order.id, + line_no=index, + sales_order_item_id=trace["sales_order_item_id"], + delivery_item_id=item_payload.delivery_item_id, + product_item_id=item_payload.product_item_id, + source_lot_id=trace["source_lot_id"], + return_qty=scrap_qty, + return_weight_kg=scrap_weight, + disposition_status="CLOSED", + responsibility_type=None, + remark=item_payload.remark, + ) + db.add(return_item) + db.flush() + + lot = StockLot( + lot_no=build_return_lot_no(db, order, product, index).replace("RTL", "RTS", 1), + parent_lot_id=trace["source_lot_id"], + lot_role="CUSTOMER_RETURN_SCRAP", + material_sub_batch_no=None, + item_id=item_payload.product_item_id, + warehouse_id=warehouse.id, + location_id=location.id if location else None, + source_doc_type="RETURN_ORDER", + source_doc_id=order.id, + source_line_id=return_item.id, + source_material_lot_id=trace["source_material_lot_id"], + source_material_sub_batch_no=trace["source_material_sub_batch_no"], + source_material_summary=trace["source_material_summary"], + inbound_qty=scrap_qty, + inbound_weight_kg=scrap_weight, + remaining_qty=scrap_qty, + remaining_weight_kg=scrap_weight, + locked_qty=to_decimal(0), + locked_weight_kg=to_decimal(0), + unit_cost=unit_cost, + production_date=None, + expire_date=None, + quality_status="SCRAP", + status="AVAILABLE", + remark="客户退货直接报废入库", + ) + db.add(lot) + db.flush() + + upsert_stock_balance( + db, + item_id=item_payload.product_item_id, + warehouse_id=warehouse.id, + location_id=location.id if location else None, + qty_delta=scrap_qty, + weight_delta=scrap_weight, + available_qty_delta=scrap_qty, + available_weight_delta=scrap_weight, + unit_cost=unit_cost, + ) + txn = create_inventory_txn( + db, + txn_type="RETURN_SCRAP_IN", + item_id=item_payload.product_item_id, + warehouse_id=warehouse.id, + location_id=location.id if location else None, + lot_id=lot.id, + qty_change=scrap_qty, + weight_change=scrap_weight, + unit_cost=unit_cost, + source_doc_type="RETURN_ORDER", + source_doc_id=order.id, + source_line_id=return_item.id, + biz_time=inbound_time, + operator_user_id=context.user.id, + remark="客户退货报废入库", + amount_basis="WEIGHT", + ) + if first_inventory_txn_id is None: + first_inventory_txn_id = int(txn.id) + + disposition = ReturnDisposition( + disposition_no=build_return_disposition_no(db, order, "SCRAP"), + return_item_id=return_item.id, + disposition_type="SCRAP", + rework_work_order_id=None, + scrap_weight_kg=scrap_weight, + extra_cost_amount=to_decimal(0, "0.01"), + scrap_sale_amount=to_decimal(scrap_weight * unit_cost, "0.01"), + closed_at=datetime.now(), + status="CLOSED", + remark=item_payload.remark or payload.remark, + ) + db.add(disposition) + + db.commit() + archive_fields = _generate_return_archive_fields(db, business_id=first_inventory_txn_id, created_by=context.user.id) + + row = db.execute(get_return_orders_query(limit=1).where(ReturnOrder.id == order.id)).mappings().first() + if not row: + raise HTTPException(status_code=500, detail="退货报废入库创建后读取失败") + return _apply_archive_fields_to_model(ReturnOrderRead.model_validate(dict(row)), archive_fields) + + +@router.get("/dispositions", response_model=list[ReturnDispositionRead]) +def list_return_dispositions( + limit: int = Query(default=100, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[ReturnDispositionRead]: + rows = db.execute(get_return_dispositions_query(limit=limit)).mappings().all() + return [ReturnDispositionRead.model_validate(dict(row)) for row in rows] + + +@router.post("/dispositions", response_model=ReturnDispositionRead) +def create_return_disposition( + payload: ReturnDispositionCreate, + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> ReturnDispositionRead: + return_item = db.scalar(select(ReturnItem).where(ReturnItem.id == payload.return_item_id).with_for_update()) + if not return_item: + raise HTTPException(status_code=404, detail="退货明细不存在") + if return_item.disposition_status == "CLOSED": + raise HTTPException(status_code=400, detail="该退货明细已完成处置") + return_order = db.get(ReturnOrder, return_item.return_order_id) + if not return_order: + raise HTTPException(status_code=404, detail="退货单不存在") + + lot = db.scalar( + select(StockLot).where( + StockLot.source_doc_type == "RETURN_ORDER", + StockLot.source_doc_id == return_item.return_order_id, + StockLot.source_line_id == return_item.id, + ).with_for_update() + ) + if not lot: + raise HTTPException(status_code=404, detail="退货批次不存在") + + disposition_type = payload.disposition_type.upper() + if disposition_type != "REWORK": + raise HTTPException(status_code=400, detail="退货库处置只支持返工,报废退货请从废料库办理退货废料入库") + warehouse = db.get(Warehouse, lot.warehouse_id) + if str(warehouse.warehouse_type if warehouse else "").upper() != "RETURN": + raise HTTPException(status_code=400, detail="退货批次不在退货库,不能返工出库") + if lot.lot_role != "RETURNED_FINISHED": + raise HTTPException(status_code=400, detail="退货批次类型不正确,不能返工出库") + if lot.status != "LOCKED" or lot.quality_status != "PENDING": + raise HTTPException(status_code=400, detail="退货批次状态不是待返工锁定状态") + required_qty = to_decimal(return_item.return_qty) + required_weight = to_decimal(return_item.return_weight_kg) + if to_decimal(lot.remaining_qty) < required_qty or to_decimal(lot.locked_qty) < required_qty: + raise HTTPException(status_code=400, detail="退货批次数量不足,不能返工出库") + if to_decimal(lot.remaining_weight_kg) < required_weight or to_decimal(lot.locked_weight_kg) < required_weight: + raise HTTPException(status_code=400, detail="退货批次重量不足,不能返工出库") + + rework_work_order_id = None + if disposition_type == "REWORK": + rework_order = create_rework_work_order( + db, + product_item_id=return_item.product_item_id, + planned_qty=to_decimal(return_item.return_qty), + remark=payload.remark or "由退货处置自动生成返工工单", + ) + rework_work_order_id = rework_order.id + + lot.remaining_qty = to_decimal(0) + lot.remaining_weight_kg = to_decimal(0) + lot.locked_qty = to_decimal(0) + lot.locked_weight_kg = to_decimal(0) + lot.status = "CLOSED" + lot.quality_status = "REWORK" + db.add(lot) + + disposition = ReturnDisposition( + disposition_no=build_return_disposition_no(db, return_order, disposition_type), + return_item_id=payload.return_item_id, + disposition_type=disposition_type, + rework_work_order_id=rework_work_order_id, + scrap_weight_kg=to_decimal(payload.scrap_weight_kg), + extra_cost_amount=to_decimal(payload.extra_cost_amount, "0.01"), + scrap_sale_amount=to_decimal(payload.scrap_sale_amount, "0.01"), + closed_at=datetime.now(), + status="CLOSED", + remark=payload.remark, + ) + db.add(disposition) + db.flush() + + upsert_stock_balance( + db, + item_id=lot.item_id, + warehouse_id=lot.warehouse_id, + location_id=lot.location_id, + qty_delta=-required_qty, + weight_delta=-required_weight, + available_qty_delta=to_decimal(0), + available_weight_delta=to_decimal(0), + unit_cost=to_decimal(lot.unit_cost, "0.0001"), + ) + txn = create_inventory_txn( + db, + txn_type="RETURN_OUT", + item_id=lot.item_id, + warehouse_id=lot.warehouse_id, + location_id=lot.location_id, + lot_id=lot.id, + qty_change=-required_qty, + weight_change=-required_weight, + unit_cost=to_decimal(lot.unit_cost, "0.0001"), + source_doc_type="RETURN_DISPOSITION", + source_doc_id=disposition.id, + source_line_id=disposition.id, + biz_time=disposition.closed_at or datetime.now(), + operator_user_id=context.user.id, + remark="退货处置出库", + amount_basis="QTY", + ) + + return_item.disposition_status = "CLOSED" + db.add(return_item) + sync_return_order_status(db, return_item.return_order_id) + db.commit() + archive_fields = _generate_return_archive_fields(db, business_id=int(txn.id), created_by=context.user.id) + + row = db.execute(get_return_dispositions_query(limit=1).where(ReturnDisposition.id == disposition.id)).mappings().first() + if not row: + raise HTTPException(status_code=500, detail="退货处置创建后读取失败") + return _apply_archive_fields_to_model(ReturnDispositionRead.model_validate(dict(row)), archive_fields) + + +@router.post("/rework-outbound", response_model=ReturnDispositionRead) +def create_return_rework_outbound( + payload: ReturnDispositionCreate, + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> ReturnDispositionRead: + if payload.disposition_type.upper() != "REWORK": + raise HTTPException(status_code=400, detail="退货库出库只支持返工出库") + return create_return_disposition(payload, context=context, db=db) diff --git a/backend/app/api/routes/sales.py b/backend/app/api/routes/sales.py new file mode 100644 index 0000000..e09440a --- /dev/null +++ b/backend/app/api/routes/sales.py @@ -0,0 +1,789 @@ +import re +from datetime import date, datetime +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 import delete, func, select +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.models.master_data import Item, Warehouse +from app.models.operations import Delivery, DeliveryItem, PurchaseOrderItem, PurchaseOrderSalesOrderLink, ReturnOrder, StockLot, WorkOrder +from app.models.planning import MaterialDemand +from app.models.sales import Customer, SalesOrder, SalesOrderItem +from app.schemas.database import ( + CustomerCreate, + CustomerRead, + SalesOrderCreate, + SalesOrderCreateResult, + SalesOrderItemRead, + SalesOrderRead, +) +from app.schemas.operations import DeliveryCreate, DeliveryItemRead, DeliveryRead +from app.services.auth import AuthContext, require_authenticated_user +from app.services.contacts import normalize_contacts, parse_contacts, serialize_contacts +from app.services.document_archives import DOCUMENT_TYPE_SALES_ORDER, DOCUMENT_TYPE_WAREHOUSE_OPERATION, generate_document_archive +from app.services.logistics import normalize_logistics_fields +from app.services.operations import ( + create_inventory_txn, + get_delivery_items_query, + get_deliveries_query, + sync_sales_order_status, + to_decimal, + upsert_stock_balance, +) +from app.services.purchase_order_links import ensure_purchase_order_sales_link_table +from app.services.stocktake import ensure_warehouses_unlocked +from app.services.system_permissions import ensure_employee_has_permission +from app.services.sales_planning import ( + create_customer_code, + decimalize, + get_sales_order_items_query, + get_sales_order_query, +) + +router = APIRouter(dependencies=[Depends(require_authenticated_user)]) + + +def _auth_context_user_id(context: AuthContext | object) -> int | None: + user = getattr(context, "user", None) + user_id = getattr(user, "id", None) + return int(user_id) if user_id is not None else None + + +def _generate_delivery_archive_fields( + db: Session, + *, + business_id: int | None, + created_by: int | None, +) -> dict[str, object]: + if not business_id: + return { + "archive_status": None, + "archive_business_id": None, + "archive_document_type": None, + "archive_error_message": None, + } + try: + archive_result = generate_document_archive( + db, + DOCUMENT_TYPE_WAREHOUSE_OPERATION, + int(business_id), + created_by=created_by, + ) + return { + "archive_status": archive_result.archive_status, + "archive_business_id": int(business_id), + "archive_document_type": DOCUMENT_TYPE_WAREHOUSE_OPERATION, + "archive_error_message": archive_result.archive_error_message, + } + except Exception: + return { + "archive_status": "归档失败", + "archive_business_id": int(business_id), + "archive_document_type": DOCUMENT_TYPE_WAREHOUSE_OPERATION, + "archive_error_message": "归档失败:PDF归档服务异常,请稍后重新生成", + } + + +def _generate_delivery_archives_for_txns( + db: Session, + inventory_txn_ids: list[int], + *, + created_by: int | None, +) -> dict[str, object]: + first_archive_fields: dict[str, object] | None = None + for inventory_txn_id in inventory_txn_ids: + archive_fields = _generate_delivery_archive_fields(db, business_id=inventory_txn_id, created_by=created_by) + if first_archive_fields is None: + first_archive_fields = archive_fields + return first_archive_fields or _generate_delivery_archive_fields(db, business_id=None, created_by=created_by) + + +def _delivery_read_with_archive(row: dict[str, Any], archive_fields: dict[str, object]) -> DeliveryRead: + result = DeliveryRead.model_validate(dict(row)) + result.archive_status = archive_fields["archive_status"] + result.archive_business_id = archive_fields["archive_business_id"] + result.archive_document_type = archive_fields["archive_document_type"] + result.archive_error_message = archive_fields["archive_error_message"] + return result + +CUSTOMER_EXCEL_HEADERS = ["客户名称", "客户简称", "联系人1", "联系电话1", "联系人2", "联系电话2", "联系人3", "联系电话3", "送货地址", "账期(天)"] +CUSTOMER_EXCEL_REQUIRED_HEADERS = ["客户名称"] + + +def _serial_code_part(value: str | None, fallback: str) -> str: + code = "".join(ch for ch in str(value or "").upper() if ch.isalnum()) + return (code[-8:] if code else fallback)[-8:] + + +def build_sales_order_no(db: Session, customer: Customer) -> str: + _ = customer + year_part = date.today().strftime("%Y") + prefix = f"销售{year_part}" + pattern = re.compile(rf"{re.escape(prefix)}-(\d{{5}})") + max_no = 0 + rows = db.scalars(select(SalesOrder.order_no).where(SalesOrder.order_no.like(f"{prefix}-%"))).all() + for order_no in rows: + match = pattern.fullmatch(str(order_no or "").strip()) + if match: + max_no = max(max_no, int(match.group(1))) + return f"{prefix}-{max_no + 1:05d}" + + +def build_delivery_no(db: Session, sales_order: SalesOrder | None = None, customer: Customer | None = None) -> str: + date_part = date.today().strftime("%Y%m%d") + biz_part = _serial_code_part( + sales_order.order_no if sales_order else (customer.short_name or customer.customer_code if customer else None), + f"CUS{customer.id:04d}" if customer else "DIRECT", + ) + prefix = f"DLV-NB-{date_part}-{biz_part}" + count = db.scalar(select(func.count(Delivery.id)).where(Delivery.delivery_no.like(f"{prefix}-%"))) or 0 + return f"{prefix}-{count + 1:03d}" + + +def _customer_contact_fields(payload: CustomerCreate) -> tuple[str | None, str | None]: + try: + contacts = normalize_contacts( + list(payload.contacts or []), + fallback_name=payload.contact_name, + fallback_phone=payload.contact_phone, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return serialize_contacts(contacts) + + +def _customer_read(customer: Customer) -> CustomerRead: + return CustomerRead.model_validate( + { + "id": customer.id, + "customer_code": customer.customer_code, + "customer_name": customer.customer_name, + "short_name": customer.short_name, + "contact_name": customer.contact_name, + "contact_phone": customer.contact_phone, + "contacts": parse_contacts(customer.contact_name, customer.contact_phone), + "address": customer.address, + "credit_days": customer.credit_days, + "status": customer.status, + } + ) + + +def _clean_excel_text(value: Any) -> str: + return str(value or "").strip() + + +def _excel_int(value: Any, default: int | None = None) -> int | None: + text = _clean_excel_text(value) + if not text: + return default + try: + return int(float(text)) + except (TypeError, ValueError) as exc: + raise ValueError(f"数字格式不正确:{text}") from exc + + +def _excel_workbook_response(workbook: Workbook, filename: str, ascii_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=\"{ascii_filename}\"; filename*=UTF-8''{encoded_filename}"}, + ) + + +def _build_customer_workbook(rows: list[Customer]) -> Workbook: + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "客户名录" + worksheet.freeze_panes = "A2" + worksheet.append(CUSTOMER_EXCEL_HEADERS) + for row in rows: + contacts = parse_contacts(row.contact_name, row.contact_phone) + worksheet.append( + [ + row.customer_name, + row.short_name or "", + contacts[0]["contact_name"] if len(contacts) > 0 else "", + contacts[0]["contact_phone"] if len(contacts) > 0 else "", + contacts[1]["contact_name"] if len(contacts) > 1 else "", + contacts[1]["contact_phone"] if len(contacts) > 1 else "", + contacts[2]["contact_name"] if len(contacts) > 2 else "", + contacts[2]["contact_phone"] if len(contacts) > 2 else "", + row.address or "", + int(row.credit_days or 0), + ] + ) + for index, width in enumerate([30, 18, 18, 20, 18, 20, 18, 20, 46, 12], start=1): + worksheet.column_dimensions[worksheet.cell(row=1, column=index).column_letter].width = width + return workbook + + +def _read_customer_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 = [_clean_excel_text(value) for value in rows[0]] + missing = [header for header in CUSTOMER_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 _import_customer_excel(db: Session, content: bytes) -> dict[str, object]: + rows = _read_customer_excel_rows(content) + if not rows: + raise ValueError("Excel 中没有可导入的客户数据") + + latest_by_name: dict[str, dict[str, Any]] = {} + skipped = 0 + errors: list[str] = [] + for index, row in enumerate(rows, start=2): + if not any(_clean_excel_text(value) for value in row.values()): + continue + customer_name = _clean_excel_text(row.get("客户名称")) + if not customer_name: + skipped += 1 + errors.append(f"第{index}行缺少客户名称") + continue + try: + credit_days = _excel_int(row.get("账期(天)"), None) + except ValueError as exc: + skipped += 1 + errors.append(f"第{index}行{exc}") + continue + latest_by_name[customer_name] = { + "customer_name": customer_name, + "short_name": _clean_excel_text(row.get("客户简称")), + "contacts": normalize_contacts( + [ + {"contact_name": row.get("联系人1"), "contact_phone": row.get("联系电话1")}, + {"contact_name": row.get("联系人2"), "contact_phone": row.get("联系电话2")}, + {"contact_name": row.get("联系人3"), "contact_phone": row.get("联系电话3")}, + ], + fallback_name=_clean_excel_text(row.get("联系人")), + fallback_phone=_clean_excel_text(row.get("联系电话")), + ), + "address": _clean_excel_text(row.get("送货地址") if "送货地址" in row else row.get("地址")), + "credit_days": credit_days, + } + + if not latest_by_name: + raise ValueError("Excel 中没有可导入的有效客户数据") + + existing_rows = db.scalars( + select(Customer) + .where(Customer.customer_name.in_(latest_by_name.keys())) + .order_by(Customer.id.asc()) + ).all() + existing_by_name: dict[str, Customer] = {} + for customer in existing_rows: + existing_by_name.setdefault(_clean_excel_text(customer.customer_name), customer) + + imported = 0 + updated = 0 + for customer_name, payload in latest_by_name.items(): + contact_name, contact_phone = serialize_contacts(payload["contacts"]) + customer = existing_by_name.get(customer_name) + if customer: + customer.short_name = payload["short_name"] or customer.short_name or customer_name + customer.contact_name = contact_name + customer.contact_phone = contact_phone + customer.address = payload["address"] or None + if payload["credit_days"] is not None: + customer.credit_days = payload["credit_days"] + customer.status = "ACTIVE" + db.add(customer) + updated += 1 + continue + + customer = Customer( + customer_code=create_customer_code(db), + customer_name=customer_name, + short_name=payload["short_name"] or customer_name, + contact_name=contact_name, + contact_phone=contact_phone, + address=payload["address"] or None, + credit_days=payload["credit_days"] if payload["credit_days"] is not None else 30, + status="ACTIVE", + ) + db.add(customer) + db.flush() + imported += 1 + + db.commit() + return { + "imported": imported, + "updated": updated, + "skipped": skipped, + "errors": errors, + "message": f"导入完成:新增 {imported} 条,更新 {updated} 条,跳过 {skipped} 条", + } + + +@router.get("/customers", response_model=list[CustomerRead]) +def list_customers( + limit: int = Query(default=50, ge=1, le=200), + db: Session = Depends(get_db), +) -> list[CustomerRead]: + rows = db.scalars(select(Customer).order_by(Customer.id.desc()).limit(limit)).all() + return [_customer_read(row) for row in rows] + + +@router.get("/customers/export") +def export_customers(db: Session = Depends(get_db)) -> Response: + rows = db.scalars(select(Customer).order_by(Customer.customer_name.asc(), Customer.id.asc())).all() + workbook = _build_customer_workbook(rows) + return _excel_workbook_response(workbook, f"客户名录_{datetime.now().strftime('%Y%m%d%H%M%S')}.xlsx", "customers.xlsx") + + +@router.post("/customers/import") +async def import_customers( + file: UploadFile = File(...), + db: Session = Depends(get_db), +) -> dict[str, object]: + content = await file.read() + try: + result = _import_customer_excel(db, content) + except ValueError as exc: + db.rollback() + raise HTTPException(status_code=400, detail=str(exc)) from exc + return result + + +@router.post("/customers", response_model=CustomerRead) +def create_customer(payload: CustomerCreate, db: Session = Depends(get_db)) -> CustomerRead: + contact_name, contact_phone = _customer_contact_fields(payload) + customer = Customer( + customer_code=create_customer_code(db), + customer_name=payload.customer_name, + short_name=payload.short_name, + contact_name=contact_name, + contact_phone=contact_phone, + status="ACTIVE", + ) + customer.address = payload.address + customer.tax_no = payload.tax_no + customer.credit_days = payload.credit_days + db.add(customer) + db.commit() + db.refresh(customer) + return _customer_read(customer) + + +@router.put("/customers/{customer_id}", response_model=CustomerRead) +def update_customer(customer_id: int, payload: CustomerCreate, db: Session = Depends(get_db)) -> CustomerRead: + customer = db.get(Customer, customer_id) + if not customer: + raise HTTPException(status_code=404, detail="客户不存在") + + contact_name, contact_phone = _customer_contact_fields(payload) + customer.customer_name = payload.customer_name + customer.short_name = payload.short_name + customer.contact_name = contact_name + customer.contact_phone = contact_phone + customer.address = payload.address + customer.tax_no = payload.tax_no + customer.credit_days = payload.credit_days + customer.status = "ACTIVE" + db.add(customer) + db.commit() + db.refresh(customer) + return _customer_read(customer) + + +@router.get("/orders", response_model=list[SalesOrderRead]) +def list_sales_orders( + limit: int = Query(default=200, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[SalesOrderRead]: + stmt = get_sales_order_query(limit=limit) + rows = db.execute(stmt).mappings().all() + return [SalesOrderRead.model_validate(dict(row)) for row in rows] + + +@router.post("/orders", response_model=SalesOrderCreateResult) +def create_sales_order( + payload: SalesOrderCreate, + db: Session = Depends(get_db), + context: AuthContext = Depends(require_authenticated_user), +) -> SalesOrderCreateResult: + customer = db.get(Customer, payload.customer_id) + if not customer: + raise HTTPException(status_code=404, detail="客户不存在") + if not payload.items: + raise HTTPException(status_code=400, detail="订单至少需要一条明细") + ensure_employee_has_permission( + db, + payload.sales_employee_id, + "MENU_SALES_PLANNING", + "销售人员", + required=True, + ) + + product_ids = [item.product_item_id for item in payload.items] + products = db.scalars(select(Item).where(Item.id.in_(product_ids))).all() + product_map = {product.id: product for product in products} + missing_products = [product_id for product_id in product_ids if product_id not in product_map] + if missing_products: + raise HTTPException(status_code=400, detail=f"产品不存在: {missing_products}") + + order = SalesOrder( + order_no=build_sales_order_no(db, customer), + customer_id=payload.customer_id, + order_date=date.today(), + promised_date=payload.promised_date, + sales_employee_id=payload.sales_employee_id, + delivery_address=payload.delivery_address or customer.address, + tax_rate=decimalize(payload.tax_rate, "0.0001"), + total_amount=decimalize(0, "0.01"), + status="OPEN", + ) + order.remark = payload.remark + db.add(order) + db.flush() + + total_amount = decimalize(0, "0.01") + for idx, item_payload in enumerate(payload.items, start=1): + line_amount = decimalize(item_payload.order_qty * item_payload.unit_price, "0.01") + total_amount += line_amount + order_item = SalesOrderItem( + sales_order_id=order.id, + line_no=idx, + product_item_id=item_payload.product_item_id, + order_qty=decimalize(item_payload.order_qty), + delivered_qty=decimalize(0), + unit_price=decimalize(item_payload.unit_price, "0.0001"), + line_amount=line_amount, + promised_date=item_payload.promised_date or payload.promised_date, + status="OPEN", + ) + order_item.customer_part_no = item_payload.customer_part_no + db.add(order_item) + + order.total_amount = total_amount + db.commit() + try: + archive_kwargs = {} + created_by = _auth_context_user_id(context) + if created_by is not None: + archive_kwargs["created_by"] = created_by + archive_result = generate_document_archive( + db, + DOCUMENT_TYPE_SALES_ORDER, + int(order.id), + **archive_kwargs, + ) + archive_status = archive_result.archive_status + archive_version = archive_result.archive_version + archive_error_message = archive_result.archive_error_message + except Exception as exc: + archive_status = "归档失败" + archive_version = None + archive_error_message = f"归档失败:{exc}" + + return SalesOrderCreateResult( + order_id=order.id, + order_no=order.order_no, + total_amount=float(total_amount), + item_count=len(payload.items), + status=order.status, + archive_status=archive_status, + archive_version=archive_version, + archive_error_message=archive_error_message, + ) + + +@router.delete("/orders/{order_id}") +def delete_sales_order(order_id: int, db: Session = Depends(get_db)) -> dict[str, str]: + ensure_purchase_order_sales_link_table(db) + order = db.get(SalesOrder, order_id) + if not order: + raise HTTPException(status_code=404, detail="销售订单不存在") + order_no = order.order_no + + item_ids = list(db.scalars(select(SalesOrderItem.id).where(SalesOrderItem.sales_order_id == order_id)).all()) + purchase_count = 0 + work_order_count = 0 + if item_ids: + purchase_count = db.scalar( + select(func.count(func.distinct(PurchaseOrderItem.purchase_order_id))) + .join(MaterialDemand, MaterialDemand.id == PurchaseOrderItem.source_demand_id) + .where(MaterialDemand.sales_order_item_id.in_(item_ids)) + ) or 0 + work_order_count = db.scalar(select(func.count(WorkOrder.id)).where(WorkOrder.source_sales_order_item_id.in_(item_ids))) or 0 + + explicit_purchase_count = db.scalar( + select(func.count(PurchaseOrderSalesOrderLink.id)) + .where(PurchaseOrderSalesOrderLink.sales_order_id == order_id) + ) or 0 + if purchase_count or explicit_purchase_count: + raise HTTPException(status_code=400, detail="销售订单已关联采购订单,不能删除") + delivery_count = db.scalar(select(func.count(Delivery.id)).where(Delivery.sales_order_id == order_id)) or 0 + delivery_item_count = 0 + if item_ids: + delivery_item_count = db.scalar(select(func.count(DeliveryItem.id)).where(DeliveryItem.sales_order_item_id.in_(item_ids))) or 0 + if delivery_count or delivery_item_count: + raise HTTPException(status_code=400, detail="销售订单已有发货记录,不能删除") + return_count = db.scalar(select(func.count(ReturnOrder.id)).where(ReturnOrder.sales_order_id == order_id)) or 0 + if return_count: + raise HTTPException(status_code=400, detail="销售订单已有退货记录,不能删除") + if work_order_count: + raise HTTPException(status_code=400, detail="销售订单已关联生产工单,不能删除") + + if item_ids: + db.execute(delete(MaterialDemand).where(MaterialDemand.sales_order_item_id.in_(item_ids))) + db.execute(delete(SalesOrderItem).where(SalesOrderItem.sales_order_id == order_id)) + db.execute(delete(SalesOrder).where(SalesOrder.id == order_id)) + db.commit() + return {"message": f"销售订单 {order_no} 已删除"} + + +@router.get("/order-items", response_model=list[SalesOrderItemRead]) +def list_sales_order_items( + sales_order_id: int | None = Query(default=None), + limit: int = Query(default=500, ge=1, le=1000), + db: Session = Depends(get_db), +) -> list[SalesOrderItemRead]: + stmt = get_sales_order_items_query(limit=limit, sales_order_id=sales_order_id) + rows = db.execute(stmt).mappings().all() + return [SalesOrderItemRead.model_validate(dict(row)) for row in rows] + + +@router.get("/deliveries", response_model=list[DeliveryRead]) +def list_deliveries( + limit: int = Query(default=100, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[DeliveryRead]: + rows = db.execute(get_deliveries_query(limit=limit)).mappings().all() + return [DeliveryRead.model_validate(dict(row)) for row in rows] + + +@router.get("/delivery-items", response_model=list[DeliveryItemRead]) +def list_delivery_items( + delivery_id: int | None = Query(default=None), + limit: int = Query(default=200, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[DeliveryItemRead]: + rows = db.execute(get_delivery_items_query(limit=limit, delivery_id=delivery_id)).mappings().all() + return [DeliveryItemRead.model_validate(dict(row)) for row in rows] + + +@router.post("/deliveries", response_model=DeliveryRead) +def create_delivery( + payload: DeliveryCreate, + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> DeliveryRead: + warehouse = db.get(Warehouse, payload.warehouse_id) + if not warehouse: + raise HTTPException(status_code=404, detail="仓库不存在") + if str(warehouse.warehouse_type or "").upper() != "FINISHED": + raise HTTPException(status_code=400, detail="销售出库必须选择成品库") + ensure_warehouses_unlocked(db, [payload.warehouse_id], "销售出库") + logistics_waybill_no, logistics_freight_amount, logistics_photo_url = normalize_logistics_fields( + payload.waybill_no, + payload.freight_amount, + required=True, + order_photo_url=payload.order_photo_url, + ) + if not payload.items: + raise HTTPException(status_code=400, detail="发货单至少需要一条明细") + sales_order_id = payload.sales_order_id + linked_order_ids: set[int] = set() + for item_payload in payload.items: + if not item_payload.sales_order_item_id: + continue + linked_order_id = db.scalar( + select(SalesOrderItem.sales_order_id).where(SalesOrderItem.id == item_payload.sales_order_item_id) + ) + if not linked_order_id: + raise HTTPException(status_code=400, detail="订单明细不存在") + linked_order_ids.add(int(linked_order_id)) + + if linked_order_ids: + if len(linked_order_ids) > 1: + raise HTTPException(status_code=400, detail="一张发货单只能关联同一个销售订单下的明细") + linked_order_id = next(iter(linked_order_ids)) + if sales_order_id and int(sales_order_id) != linked_order_id: + raise HTTPException(status_code=400, detail="发货单关联的订单头与订单明细不一致") + sales_order_id = linked_order_id + + sales_order = db.get(SalesOrder, sales_order_id) if sales_order_id else None + if sales_order_id and not sales_order: + raise HTTPException(status_code=404, detail="订单不存在") + customer_id = payload.customer_id or (sales_order.customer_id if sales_order else None) + customer = db.get(Customer, customer_id) if customer_id else None + if not customer: + raise HTTPException(status_code=404, detail="发货必须选择收货客户") + ensure_employee_has_permission( + db, + payload.shipper_employee_id, + "MENU_INVENTORY_LEDGER", + "发货人", + required=False, + ) + + delivery = Delivery( + delivery_no=build_delivery_no(db, sales_order=sales_order, customer=customer), + sales_order_id=sales_order_id, + customer_id=customer.id, + warehouse_id=payload.warehouse_id, + delivery_date=payload.delivery_date or datetime.now(), + shipper_employee_id=payload.shipper_employee_id, + consignee_name=payload.consignee_name or (customer.contact_name if customer else None), + consignee_phone=payload.consignee_phone or (customer.contact_phone if customer else None), + delivery_address=payload.delivery_address or (customer.address if customer else None) or (sales_order.delivery_address if sales_order else None), + logistics_waybill_no=logistics_waybill_no, + logistics_freight_amount=logistics_freight_amount, + logistics_photo_url=logistics_photo_url, + status="POSTED", + remark=payload.remark, + ) + db.add(delivery) + db.flush() + + inventory_txn_ids: list[int] = [] + for index, item_payload in enumerate(payload.items, start=1): + order_item = db.get(SalesOrderItem, item_payload.sales_order_item_id) if item_payload.sales_order_item_id else None + if sales_order: + if not order_item or order_item.sales_order_id != sales_order_id: + raise HTTPException(status_code=400, detail="订单明细不存在或不属于当前订单") + if order_item.product_item_id != item_payload.product_item_id: + raise HTTPException(status_code=400, detail="发货产品与订单明细不一致") + product = db.get(Item, item_payload.product_item_id) + if not product: + raise HTTPException(status_code=404, detail=f"产品不存在: {item_payload.product_item_id}") + + delivery_qty = to_decimal(item_payload.delivery_qty) + if delivery_qty <= 0: + raise HTTPException(status_code=400, detail=f"产品 {product.item_name} 的发货数量必须大于0") + delivery_weight = to_decimal(item_payload.delivery_weight_kg) + if order_item: + order_remaining_qty = to_decimal(order_item.order_qty) - to_decimal(order_item.delivered_qty) + if delivery_qty > order_remaining_qty: + raise HTTPException( + status_code=400, + detail=f"产品 {product.item_name} 的发货数量不能超过订单未发数量 {order_remaining_qty}", + ) + lot = None + if item_payload.lot_id: + lot = db.get(StockLot, item_payload.lot_id) + if not lot: + raise HTTPException(status_code=404, detail="发货批次不存在") + if lot.item_id != item_payload.product_item_id or lot.warehouse_id != payload.warehouse_id: + raise HTTPException(status_code=400, detail="所选批次不属于当前产品或当前发货仓") + else: + lot = db.scalar( + select(StockLot).where( + StockLot.item_id == item_payload.product_item_id, + StockLot.warehouse_id == payload.warehouse_id, + StockLot.quality_status == "PASS", + StockLot.status == "AVAILABLE", + StockLot.remaining_qty > 0, + ) + ) + if not lot: + raise HTTPException(status_code=400, detail=f"产品 {product.item_name} 无可用批次库存") + if lot.quality_status != "PASS" or lot.status != "AVAILABLE": + raise HTTPException(status_code=400, detail=f"库存批次号 {lot.lot_no} 当前不可发货") + + available_qty = to_decimal(lot.remaining_qty) - to_decimal(lot.locked_qty) + available_weight = to_decimal(lot.remaining_weight_kg) - to_decimal(lot.locked_weight_kg) + if delivery_qty > available_qty: + raise HTTPException(status_code=400, detail=f"库存批次号 {lot.lot_no} 可用数量不足") + + if delivery_weight <= 0: + if available_qty > 0 and available_weight > 0: + delivery_weight = available_weight * (delivery_qty / available_qty) + else: + delivery_weight = delivery_qty * to_decimal(product.unit_weight_kg) + + lot.remaining_qty = to_decimal(lot.remaining_qty) - delivery_qty + lot.remaining_weight_kg = to_decimal(lot.remaining_weight_kg) - delivery_weight + if to_decimal(lot.remaining_qty) <= 0 or to_decimal(lot.remaining_weight_kg) <= 0: + lot.status = "DEPLETED" + db.add(lot) + + upsert_stock_balance( + db, + item_id=item_payload.product_item_id, + warehouse_id=payload.warehouse_id, + location_id=lot.location_id, + qty_delta=-delivery_qty, + weight_delta=-delivery_weight, + available_qty_delta=-delivery_qty, + available_weight_delta=-delivery_weight, + unit_cost=to_decimal(lot.unit_cost, "0.0001"), + ) + txn = create_inventory_txn( + db, + txn_type="SALES_OUT", + item_id=item_payload.product_item_id, + warehouse_id=payload.warehouse_id, + location_id=lot.location_id, + lot_id=lot.id, + qty_change=-delivery_qty, + weight_change=-delivery_weight, + unit_cost=to_decimal(lot.unit_cost, "0.0001"), + source_doc_type="DELIVERY", + source_doc_id=delivery.id, + source_line_id=order_item.id if order_item else None, + biz_time=delivery.delivery_date, + operator_user_id=context.user.id, + remark="发货出库", + amount_basis="QTY", + logistics_waybill_no=logistics_waybill_no, + logistics_freight_amount=logistics_freight_amount, + logistics_photo_url=logistics_photo_url, + ) + inventory_txn_ids.append(int(txn.id)) + + line_amount = delivery_qty * to_decimal(item_payload.unit_price, "0.0001") + db.add( + DeliveryItem( + delivery_id=delivery.id, + line_no=index, + sales_order_item_id=item_payload.sales_order_item_id if order_item else None, + product_item_id=item_payload.product_item_id, + lot_id=lot.id, + delivery_qty=delivery_qty, + delivery_weight_kg=delivery_weight, + unit_price=to_decimal(item_payload.unit_price, "0.0001"), + line_amount=to_decimal(line_amount, "0.01"), + status="POSTED", + remark=item_payload.remark, + ) + ) + + if order_item: + order_item.delivered_qty = to_decimal(order_item.delivered_qty) + delivery_qty + if to_decimal(order_item.delivered_qty) >= to_decimal(order_item.order_qty): + order_item.status = "CLOSED" + else: + order_item.status = "PARTIAL" + db.add(order_item) + + if sales_order_id: + sync_sales_order_status(db, sales_order_id) + db.commit() + archive_fields = _generate_delivery_archives_for_txns(db, inventory_txn_ids, created_by=context.user.id) + + row = db.execute(get_deliveries_query(limit=1).where(Delivery.id == delivery.id)).mappings().first() + if not row: + raise HTTPException(status_code=500, detail="发货单创建后读取失败") + return _delivery_read_with_archive(dict(row), archive_fields) diff --git a/backend/app/api/routes/system.py b/backend/app/api/routes/system.py new file mode 100644 index 0000000..7695268 --- /dev/null +++ b/backend/app/api/routes/system.py @@ -0,0 +1,50 @@ +from fastapi import APIRouter, Depends +from sqlalchemy import text +from sqlalchemy.exc import SQLAlchemyError + +from app.db.session import check_database_health, get_engine +from app.schemas.database import DataCountItem +from app.services.auth import require_authenticated_user + +router = APIRouter() + + +@router.get("/health") +def health() -> dict[str, object]: + try: + db_status = check_database_health() + except (SQLAlchemyError, RuntimeError, Exception) as exc: + return { + "app": "ok", + "database": { + "connected": False, + "error": str(exc), + }, + } + + return { + "app": "ok", + "database": db_status, + } + + +@router.get("/data-summary", response_model=list[DataCountItem], dependencies=[Depends(require_authenticated_user)]) +def data_summary() -> list[DataCountItem]: + summary_tables = [ + ("dept_count", "部门数量", "sys_department"), + ("warehouse_count", "仓库数量", "wh_warehouse"), + ("item_count", "物料数量", "md_item"), + ("product_count", "产品数量", "md_product"), + ("customer_count", "客户数量", "md_customer"), + ("sales_order_count", "订单数量", "so_sales_order"), + ("mrp_count", "MRP需求数量", "mrp_material_demand"), + ("work_order_count", "工单数量", "pp_work_order"), + ] + + engine = get_engine() + result: list[DataCountItem] = [] + with engine.connect() as conn: + for code, label, table_name in summary_tables: + count = conn.execute(text(f"SELECT COUNT(*) FROM {table_name}")).scalar() or 0 + result.append(DataCountItem(code=code, label=label, count=int(count))) + return result diff --git a/backend/app/api/routes/system_extension.py b/backend/app/api/routes/system_extension.py new file mode 100644 index 0000000..c4c03a2 --- /dev/null +++ b/backend/app/api/routes/system_extension.py @@ -0,0 +1,359 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import delete, desc, select +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.models.miniapp import ( + MiniAppAttendancePoint, + MiniAppNotice, + MiniAppNoticePoint, + MiniAppPersonRole, + MiniAppPersonnel, +) +from app.models.org import AiAssistantConfig, SystemConfig +from app.schemas.database import ( + AiAssistantConfigRead, + AiAssistantConfigUpdate, + BroadcastMessageCreate, + BroadcastMessageRead, + SmartOperationReportConfigRead, + SmartOperationReportConfigUpdate, + SystemConfigRead, + SystemConfigUpdate, +) +from app.services.auth import AuthContext, require_authenticated_user, require_system_admin +from app.services.system_config import ( + RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE, + RAW_MATERIAL_LOT_PREFIX_DEFAULT, + SMART_OPERATION_REPORT_CONFIG_CODE, + get_smart_operation_report_enabled, + normalize_enabled_config_value, + upsert_smart_operation_report_config, + upsert_system_config, +) + +router = APIRouter() + + +def _active_attendance_point_names(db: Session) -> list[str]: + return [ + name + for name in db.scalars( + select(MiniAppAttendancePoint.name) + .where(MiniAppAttendancePoint.is_active.is_(True)) + .order_by(MiniAppAttendancePoint.name.asc()) + ).all() + if str(name or "").strip() + ] + + +def _notice_point_names(db: Session, notice_id: int) -> list[str]: + return [ + name + for name in db.scalars( + select(MiniAppNoticePoint.attendance_point_name) + .where(MiniAppNoticePoint.notice_id == notice_id) + .order_by(MiniAppNoticePoint.attendance_point_name.asc()) + ).all() + if str(name or "").strip() + ] + + +def _replace_notice_points(db: Session, notice_id: int, point_names: list[str] | None) -> None: + selected_names = [str(name or "").strip() for name in point_names or _active_attendance_point_names(db) if str(name or "").strip()] + selected_names = list(dict.fromkeys(selected_names)) + if selected_names: + existing_names = set( + db.scalars(select(MiniAppAttendancePoint.name).where(MiniAppAttendancePoint.name.in_(selected_names))).all() + ) + missing = [name for name in selected_names if name not in existing_names] + if missing: + raise HTTPException(status_code=400, detail=f"小程序考勤点不存在:{'、'.join(missing)}") + db.execute(delete(MiniAppNoticePoint).where(MiniAppNoticePoint.notice_id == notice_id)) + for name in selected_names: + db.add(MiniAppNoticePoint(notice_id=notice_id, attendance_point_name=name)) + + +def _broadcast_read(db: Session, row: MiniAppNotice) -> BroadcastMessageRead: + return BroadcastMessageRead( + broadcast_id=row.id, + title=row.title, + content=row.content, + attendance_point_names=_notice_point_names(db, row.id), + tone="INFO", + status="ACTIVE" if row.is_active else "PAUSED", + starts_at=None, + ends_at=None, + sort_no=row.sort_order, + created_by=row.created_by, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +def _get_notice_creator_phone(db: Session) -> str: + admin_phone = db.scalar( + select(MiniAppPersonRole.phone) + .where(MiniAppPersonRole.role.in_(["admin", "manager"])) + .order_by(MiniAppPersonRole.role, MiniAppPersonRole.phone) + .limit(1) + ) + if admin_phone: + return admin_phone + first_phone = db.scalar(select(MiniAppPersonnel.phone).order_by(MiniAppPersonnel.phone).limit(1)) + if first_phone: + return first_phone + + system_person = MiniAppPersonnel( + phone="ERP_SYSTEM", + name="ERP系统", + is_temporary=False, + temporary_expires_at=None, + ) + db.add(system_person) + db.flush() + db.add(MiniAppPersonRole(phone=system_person.phone, role="manager")) + return system_person.phone + + +def _assistant_config_read(row: AiAssistantConfig | None) -> AiAssistantConfigRead: + if not row: + return AiAssistantConfigRead( + assistant_name="嘉恒工艺助手", + provider_name="", + api_base_url="", + api_key=None, + model_name="", + temperature=0.3, + system_prompt="你是宁波嘉恒智能五金 ERP 的企业助手,优先解释业务流程、字段含义、数据异常和操作步骤。", + enabled=False, + config_id=None, + api_key_configured=False, + updated_at=None, + ) + + return AiAssistantConfigRead( + config_id=row.id, + assistant_name=row.assistant_name, + provider_name=row.provider_name, + api_base_url=row.api_base_url, + api_key=None, + model_name=row.model_name, + temperature=float(row.temperature or 0.3), + system_prompt=row.system_prompt, + enabled=bool(row.enabled), + api_key_configured=bool(row.api_key), + updated_at=row.updated_at, + ) + + +def _raw_material_lot_prefix_read(row: SystemConfig | None = None) -> SystemConfigRead: + if not row: + return SystemConfigRead( + config_code=RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE, + config_name="原材料库存批次号前缀", + config_value=RAW_MATERIAL_LOT_PREFIX_DEFAULT, + remark="原材料库存批次号默认前缀,例如 YL0001", + updated_at=None, + ) + return SystemConfigRead.model_validate(row) + + +def _smart_operation_report_config_read(row: SystemConfig | None, db: Session) -> SmartOperationReportConfigRead: + enabled = get_smart_operation_report_enabled(db) if row is None else normalize_enabled_config_value(row.config_value) == "开启" + return SmartOperationReportConfigRead( + config_code=SMART_OPERATION_REPORT_CONFIG_CODE, + config_name="对接智能报工小程序", + enabled=enabled, + config_value="开启" if enabled else "关闭", + remark=row.remark if row else "开启后显示工序报工并按小程序报工数据计算生产进度;关闭后按ERP入库数据反推生产进度", + updated_at=row.updated_at if row else None, + ) + + +def _active_broadcast_rows(db: Session, limit: int = 30) -> list[MiniAppNotice]: + return db.scalars( + select(MiniAppNotice) + .where(MiniAppNotice.is_active.is_(True)) + .order_by(MiniAppNotice.sort_order.asc(), desc(MiniAppNotice.updated_at), desc(MiniAppNotice.id)) + .limit(limit) + ).all() + + +@router.get("/active-broadcast", response_model=BroadcastMessageRead | None) +def active_broadcast( + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> BroadcastMessageRead | None: + _ = context + rows = _active_broadcast_rows(db, limit=1) + row = rows[0] if rows else None + return _broadcast_read(db, row) if row else None + + +@router.get("/active-broadcasts", response_model=list[BroadcastMessageRead]) +def active_broadcasts( + limit: int = Query(default=30, ge=1, le=100), + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> list[BroadcastMessageRead]: + _ = context + return [_broadcast_read(db, row) for row in _active_broadcast_rows(db, limit=limit)] + + +@router.get("/broadcasts", response_model=list[BroadcastMessageRead]) +def list_broadcasts( + limit: int = Query(default=100, ge=1, le=300), + context: AuthContext = Depends(require_system_admin), + db: Session = Depends(get_db), +) -> list[BroadcastMessageRead]: + _ = context + rows = db.scalars(select(MiniAppNotice).order_by(MiniAppNotice.sort_order.asc(), desc(MiniAppNotice.updated_at)).limit(limit)).all() + return [_broadcast_read(db, row) for row in rows] + + +@router.post("/broadcasts", response_model=BroadcastMessageRead) +def create_broadcast( + payload: BroadcastMessageCreate, + context: AuthContext = Depends(require_system_admin), + db: Session = Depends(get_db), +) -> BroadcastMessageRead: + creator_phone = _get_notice_creator_phone(db) + row = MiniAppNotice( + title=payload.title, + content=payload.content, + sort_order=payload.sort_no, + is_active=payload.status == "ACTIVE", + created_by=creator_phone, + ) + db.add(row) + db.flush() + _replace_notice_points(db, row.id, payload.attendance_point_names) + db.commit() + db.refresh(row) + return _broadcast_read(db, row) + + +@router.put("/broadcasts/{broadcast_id}", response_model=BroadcastMessageRead) +def update_broadcast( + broadcast_id: int, + payload: BroadcastMessageCreate, + context: AuthContext = Depends(require_system_admin), + db: Session = Depends(get_db), +) -> BroadcastMessageRead: + _ = context + row = db.get(MiniAppNotice, broadcast_id) + if not row: + raise HTTPException(status_code=404, detail="广播内容不存在") + + row.title = payload.title + row.content = payload.content + row.sort_order = payload.sort_no + row.is_active = payload.status == "ACTIVE" + db.add(row) + _replace_notice_points(db, row.id, payload.attendance_point_names) + db.commit() + db.refresh(row) + return _broadcast_read(db, row) + + +@router.get("/assistant-config", response_model=AiAssistantConfigRead) +def get_assistant_config( + context: AuthContext = Depends(require_system_admin), + db: Session = Depends(get_db), +) -> AiAssistantConfigRead: + _ = context + row = db.scalars(select(AiAssistantConfig).order_by(desc(AiAssistantConfig.id)).limit(1)).first() + return _assistant_config_read(row) + + +@router.put("/assistant-config", response_model=AiAssistantConfigRead) +def save_assistant_config( + payload: AiAssistantConfigUpdate, + context: AuthContext = Depends(require_system_admin), + db: Session = Depends(get_db), +) -> AiAssistantConfigRead: + row = db.scalars(select(AiAssistantConfig).order_by(desc(AiAssistantConfig.id)).limit(1)).first() + if not row: + row = AiAssistantConfig(created_by=context.user.id) + + row.assistant_name = payload.assistant_name + row.provider_name = payload.provider_name + row.api_base_url = payload.api_base_url + if payload.api_key is not None: + row.api_key = payload.api_key + row.model_name = payload.model_name + row.temperature = payload.temperature + row.system_prompt = payload.system_prompt + row.enabled = 1 if payload.enabled else 0 + row.updated_by = context.user.id + db.add(row) + db.commit() + db.refresh(row) + return _assistant_config_read(row) + + +@router.get("/raw-material-lot-prefix", response_model=SystemConfigRead) +def get_raw_material_lot_prefix_config( + context: AuthContext = Depends(require_system_admin), + db: Session = Depends(get_db), +) -> SystemConfigRead: + _ = context + row = db.scalar( + select(SystemConfig).where( + SystemConfig.config_code == RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE, + SystemConfig.status == "ACTIVE", + ) + ) + return _raw_material_lot_prefix_read(row) + + +@router.put("/raw-material-lot-prefix", response_model=SystemConfigRead) +def save_raw_material_lot_prefix_config( + payload: SystemConfigUpdate, + context: AuthContext = Depends(require_system_admin), + db: Session = Depends(get_db), +) -> SystemConfigRead: + row = upsert_system_config( + db, + config_code=RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE, + config_name="原材料库存批次号前缀", + config_value=payload.config_value, + remark=payload.remark or "原材料库存批次号默认前缀,例如 YL0001", + updated_by=context.user.id, + ) + db.commit() + db.refresh(row) + return _raw_material_lot_prefix_read(row) + + +@router.get("/smart-operation-report", response_model=SmartOperationReportConfigRead) +def get_smart_operation_report_config( + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> SmartOperationReportConfigRead: + _ = context + row = db.scalar( + select(SystemConfig).where( + SystemConfig.config_code == SMART_OPERATION_REPORT_CONFIG_CODE, + SystemConfig.status == "ACTIVE", + ) + ) + return _smart_operation_report_config_read(row, db) + + +@router.put("/smart-operation-report", response_model=SmartOperationReportConfigRead) +def save_smart_operation_report_config( + payload: SmartOperationReportConfigUpdate, + context: AuthContext = Depends(require_system_admin), + db: Session = Depends(get_db), +) -> SmartOperationReportConfigRead: + row = upsert_smart_operation_report_config( + db, + enabled=payload.enabled, + updated_by=context.user.id, + remark=payload.remark, + ) + db.commit() + db.refresh(row) + return _smart_operation_report_config_read(row, db) diff --git a/backend/app/api/routes/system_permissions.py b/backend/app/api/routes/system_permissions.py new file mode 100644 index 0000000..2c31226 --- /dev/null +++ b/backend/app/api/routes/system_permissions.py @@ -0,0 +1,175 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.schemas.system_permissions import ( + EmployeeOption, + MenuPermissionNode, + OrgEmployeeBind, + OrgNodeCreate, + OrgNodeRead, + OrgNodeUpdate, + PermissionRead, + RoleCreate, + RolePermissionUpdate, + RoleRead, + RoleUpdate, + SystemUserCreate, + SystemUserPasswordReset, + SystemUserRead, + SystemUserRoleUpdate, + SystemUserStatusUpdate, +) +from app.services.auth import AuthContext, require_system_admin +from app.services.system_permissions import ( + build_org_tree, + bind_org_employee, + create_org_node, + create_role, + create_system_user, + delete_org_node, + list_employee_options, + list_menu_permission_tree, + list_permissions, + list_roles, + list_system_users, + reset_system_user_password, + unbind_org_employee, + update_org_node, + update_role, + update_system_user_roles, + update_system_user_status, +) + +router = APIRouter(dependencies=[Depends(require_system_admin)]) + + +@router.get("/org-tree", response_model=list[OrgNodeRead]) +def get_org_tree(db: Session = Depends(get_db)) -> list[OrgNodeRead]: + return build_org_tree(db) + + +@router.post("/org-nodes", response_model=OrgNodeRead) +def post_org_node(payload: OrgNodeCreate, db: Session = Depends(get_db)) -> OrgNodeRead: + return create_org_node(db, payload) + + +@router.put("/org-nodes/{node_id}", response_model=OrgNodeRead) +def put_org_node(node_id: int, payload: OrgNodeUpdate, db: Session = Depends(get_db)) -> OrgNodeRead: + return update_org_node(db, node_id, payload) + + +@router.delete("/org-nodes/{node_id}", status_code=204) +def delete_org_node_route(node_id: int, db: Session = Depends(get_db)) -> None: + delete_org_node(db, node_id) + + +@router.post("/org-nodes/{node_id}/employees", response_model=OrgNodeRead) +def post_org_node_employee( + node_id: int, + payload: OrgEmployeeBind, + db: Session = Depends(get_db), +) -> OrgNodeRead: + return bind_org_employee(db, node_id, payload) + + +@router.delete("/org-nodes/{node_id}/employees/{employee_id}", response_model=OrgNodeRead) +def delete_org_node_employee( + node_id: int, + employee_id: int, + db: Session = Depends(get_db), +) -> OrgNodeRead: + return unbind_org_employee(db, node_id, employee_id) + + +@router.get("/permissions", response_model=list[PermissionRead]) +def get_permissions(db: Session = Depends(get_db)) -> list[PermissionRead]: + return list_permissions(db) + + +@router.get("/menu-permission-tree", response_model=list[MenuPermissionNode]) +def get_menu_permission_tree() -> list[MenuPermissionNode]: + return list_menu_permission_tree() + + +@router.get("/roles", response_model=list[RoleRead]) +def get_roles(db: Session = Depends(get_db)) -> list[RoleRead]: + return list_roles(db) + + +@router.post("/roles", response_model=RoleRead) +def post_role(payload: RoleCreate, db: Session = Depends(get_db)) -> RoleRead: + return create_role(db, payload) + + +@router.put("/roles/{role_id}", response_model=RoleRead) +def put_role(role_id: int, payload: RoleUpdate, db: Session = Depends(get_db)) -> RoleRead: + return update_role(db, role_id, payload) + + +@router.put("/roles/{role_id}/permissions", response_model=RoleRead) +def put_role_permissions( + role_id: int, + payload: RolePermissionUpdate, + db: Session = Depends(get_db), +) -> RoleRead: + existing_role = next((role for role in list_roles(db) if role.role_id == role_id), None) + if not existing_role: + raise HTTPException(status_code=404, detail="角色不存在") + return update_role( + db, + role_id, + RoleUpdate( + role_name=existing_role.role_name, + role_scope=existing_role.role_scope, + status=existing_role.status, + remark=existing_role.remark, + permission_codes=payload.permission_codes, + ), + ) + + +@router.get("/employee-options", response_model=list[EmployeeOption]) +def get_employee_options( + limit: int = Query(default=500, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[EmployeeOption]: + return list_employee_options(db, limit=limit) + + +@router.get("/users", response_model=list[SystemUserRead]) +def get_system_users(db: Session = Depends(get_db)) -> list[SystemUserRead]: + return list_system_users(db) + + +@router.post("/users", response_model=SystemUserRead) +def post_system_user(payload: SystemUserCreate, db: Session = Depends(get_db)) -> SystemUserRead: + return create_system_user(db, payload) + + +@router.put("/users/{user_id}/roles", response_model=SystemUserRead) +def put_system_user_roles( + user_id: int, + payload: SystemUserRoleUpdate, + db: Session = Depends(get_db), +) -> SystemUserRead: + return update_system_user_roles(db, user_id, payload.role_ids) + + +@router.put("/users/{user_id}/status", response_model=SystemUserRead) +def put_system_user_status( + user_id: int, + payload: SystemUserStatusUpdate, + db: Session = Depends(get_db), + context: AuthContext = Depends(require_system_admin), +) -> SystemUserRead: + return update_system_user_status(db, user_id, payload.status, context.user.id) + + +@router.post("/users/{user_id}/reset-password", response_model=SystemUserRead) +def post_system_user_password_reset( + user_id: int, + payload: SystemUserPasswordReset, + db: Session = Depends(get_db), +) -> SystemUserRead: + return reset_system_user_password(db, user_id, payload.password) diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..b3654b8 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,43 @@ +from functools import lru_cache + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict +from sqlalchemy.engine import URL + + +class Settings(BaseSettings): + app_name: str = Field(default="Jiaheng Hardware ERP API", alias="APP_NAME") + app_env: str = Field(default="dev", alias="APP_ENV") + app_host: str = Field(default="0.0.0.0", alias="APP_HOST") + app_port: int = Field(default=8000, alias="APP_PORT") + api_prefix: str = "/api" + mysql_host: str = Field(default="127.0.0.1", alias="MYSQL_HOST") + mysql_port: int = Field(default=3306, alias="MYSQL_PORT") + mysql_database: str = Field(default="jiaheng_erp", alias="MYSQL_DATABASE") + mysql_user: str = Field(default="root", alias="MYSQL_USER") + mysql_password: str = Field(default="replace_me", alias="MYSQL_PASSWORD") + allowed_origins: str = Field(default="http://localhost:5173", alias="ALLOWED_ORIGINS") + auth_secret_key: str = Field(default="jiaheng-erp-dev-secret-change-me", alias="AUTH_SECRET_KEY") + auth_token_expire_minutes: int = Field(default=480, alias="AUTH_TOKEN_EXPIRE_MINUTES") + + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + @property + def mysql_dsn(self) -> URL: + return URL.create( + "mysql+pymysql", + username=self.mysql_user, + password=self.mysql_password, + host=self.mysql_host, + port=self.mysql_port, + database=self.mysql_database, + ) + + @property + def cors_origins(self) -> list[str]: + return [origin.strip() for origin in self.allowed_origins.split(",") if origin.strip()] + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/db/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..9fdb503 --- /dev/null +++ b/backend/app/db/session.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from functools import lru_cache + +from sqlalchemy import create_engine, text +from sqlalchemy.orm import Session, sessionmaker + +from app.core.config import get_settings + + +@lru_cache +def get_engine(): + settings = get_settings() + return create_engine( + settings.mysql_dsn, + pool_pre_ping=True, + future=True, + ) + + +SessionLocal = sessionmaker(bind=get_engine(), autoflush=False, autocommit=False, future=True) + + +def get_db() -> Session: + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def check_database_health() -> dict[str, object]: + settings = get_settings() + with get_engine().connect() as conn: + current_db = conn.execute(text("SELECT DATABASE()")).scalar() + version = conn.execute(text("SELECT VERSION()")).scalar() + table_count = conn.execute( + text( + """ + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = :schema_name + """ + ), + {"schema_name": settings.mysql_database}, + ).scalar() + return { + "connected": True, + "database": current_db, + "version": version, + "table_count": int(table_count or 0), + } + diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..87d0ba1 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,53 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.openapi.docs import get_swagger_ui_html, get_swagger_ui_oauth2_redirect_html + +from app.api.router import api_router +from app.core.config import get_settings + +settings = get_settings() + +app = FastAPI( + title=settings.app_name, + version="0.1.0", + description="面向五金加工企业的流程化、工序化、成本化 ERP API 服务", + docs_url=None, + redoc_url=None, + openapi_url="/openapi.json", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + expose_headers=["Content-Disposition"], +) + +app.include_router(api_router, prefix=settings.api_prefix) + + +@app.get("/docs", include_in_schema=False) +def custom_swagger_ui_html(): + return get_swagger_ui_html( + openapi_url=app.openapi_url, + title=f"{app.title} - API 文档", + oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url, + swagger_js_url="https://cdn.staticfile.net/swagger-ui/5.1.0/swagger-ui-bundle.js", + swagger_css_url="https://cdn.staticfile.net/swagger-ui/5.1.0/swagger-ui.css", + ) + + +@app.get(app.swagger_ui_oauth2_redirect_url, include_in_schema=False) +def swagger_ui_redirect(): + return get_swagger_ui_oauth2_redirect_html() + + +@app.get("/") +def root() -> dict[str, str]: + return { + "name": settings.app_name, + "env": settings.app_env, + "message": "Jiaheng Hardware ERP API is running.", + } diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..08077bd --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,27 @@ +from app.models.master_data import Bom, BomItem, Item, Material, Product, StockBalance, Warehouse +from app.models.document_archive import DocumentArchive +from app.models.org import Department, Employee, Permission, Role, RolePermission, User, UserRole +from app.models.planning import MaterialDemand +from app.models.sales import Customer, SalesOrder, SalesOrderItem + +__all__ = [ + "Customer", + "Department", + "DocumentArchive", + "Employee", + "Bom", + "BomItem", + "Item", + "Material", + "MaterialDemand", + "Permission", + "Product", + "Role", + "RolePermission", + "SalesOrder", + "SalesOrderItem", + "StockBalance", + "User", + "UserRole", + "Warehouse", +] diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 0000000..012f110 --- /dev/null +++ b/backend/app/models/base.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import event +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass + + +@event.listens_for(Base, "before_insert", propagate=True) +def set_audit_fields_before_insert(mapper, connection, target) -> None: + _ = mapper, connection + now = datetime.now() + if hasattr(target, "created_at") and getattr(target, "created_at", None) is None: + setattr(target, "created_at", now) + if hasattr(target, "updated_at") and getattr(target, "updated_at", None) is None: + setattr(target, "updated_at", now) + + +@event.listens_for(Base, "before_update", propagate=True) +def set_audit_fields_before_update(mapper, connection, target) -> None: + _ = mapper, connection + if hasattr(target, "updated_at"): + setattr(target, "updated_at", datetime.now()) diff --git a/backend/app/models/document_archive.py b/backend/app/models/document_archive.py new file mode 100644 index 0000000..c1727f3 --- /dev/null +++ b/backend/app/models/document_archive.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class DocumentArchive(Base): + __tablename__ = "document_archives" + __table_args__ = ( + UniqueConstraint( + "document_type", + "business_id", + "archive_version", + "file_format", + name="uk_document_archives_version", + ), + Index("idx_document_archives_business_latest", "document_type", "business_id", "archive_version"), + Index("idx_document_archives_document_no", "document_no"), + Index("idx_document_archives_status", "status", "created_at"), + ) + + id: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True) + document_type: Mapped[str] = mapped_column(String(50)) + business_id: Mapped[int] = mapped_column(BigInteger) + document_no: Mapped[str] = mapped_column(String(100)) + archive_version: Mapped[int] = mapped_column(Integer) + template_version: Mapped[str] = mapped_column(String(50)) + file_format: Mapped[str] = mapped_column(String(20)) + file_name: Mapped[str] = mapped_column(String(255)) + file_path: Mapped[str] = mapped_column(String(500)) + file_hash: Mapped[str | None] = mapped_column(String(128), nullable=True) + status: Mapped[str] = mapped_column(String(32)) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + created_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime) + updated_at: Mapped[datetime] = mapped_column(DateTime) diff --git a/backend/app/models/master_data.py b/backend/app/models/master_data.py new file mode 100644 index 0000000..53fafe9 --- /dev/null +++ b/backend/app/models/master_data.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from sqlalchemy import BigInteger, Date, DateTime, DECIMAL, ForeignKey, Integer, String, text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class Unit(Base): + __tablename__ = "md_unit" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + unit_code: Mapped[str] = mapped_column(String(50)) + unit_name: Mapped[str] = mapped_column(String(50)) + precision_digits: Mapped[int] = mapped_column(Integer, server_default=text("3")) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class Item(Base): + __tablename__ = "md_item" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + item_code: Mapped[str] = mapped_column(String(50)) + item_name: Mapped[str] = mapped_column(String(200)) + item_type: Mapped[str] = mapped_column(String(32)) + specification: Mapped[str | None] = mapped_column(String(200), nullable=True) + material_grade: Mapped[str | None] = mapped_column(String(100), nullable=True) + unit_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + sales_unit_id: Mapped[int | None] = mapped_column(ForeignKey("md_unit.id"), nullable=True) + stock_unit_id: Mapped[int | None] = mapped_column(ForeignKey("md_unit.id"), nullable=True) + purchase_unit_id: Mapped[int | None] = mapped_column(ForeignKey("md_unit.id"), nullable=True) + safety_stock_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + scrap_sale_price: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0")) + status: Mapped[str] = mapped_column(String(32)) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class Material(Base): + __tablename__ = "md_material" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + default_supplier_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + purchase_calc_mode: Mapped[str] = mapped_column(String(32)) + min_purchase_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + purchase_multiple_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + purchase_multiple_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + lead_time_days: Mapped[int] = mapped_column(server_default=text("0")) + quality_rule: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class Product(Base): + __tablename__ = "md_product" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + drawing_no: Mapped[str | None] = mapped_column(String(100), nullable=True) + product_family: Mapped[str | None] = mapped_column(String(100), nullable=True) + version_no: Mapped[str] = mapped_column(String(50)) + standard_output_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + allowed_scrap_rate: Mapped[float] = mapped_column(DECIMAL(8, 4), server_default=text("0")) + quality_standard: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class Warehouse(Base): + __tablename__ = "wh_warehouse" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + warehouse_code: Mapped[str] = mapped_column(String(50)) + warehouse_name: Mapped[str] = mapped_column(String(100)) + warehouse_type: Mapped[str] = mapped_column(String(32)) + manager_employee_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + status: Mapped[str] = mapped_column(String(32)) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class Bom(Base): + __tablename__ = "md_bom" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + bom_code: Mapped[str] = mapped_column(String(50)) + product_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + version_no: Mapped[str] = mapped_column(String(50)) + is_default: Mapped[int] = mapped_column(Integer) + status: Mapped[str] = mapped_column(String(32)) + effective_date: Mapped[object | None] = mapped_column(Date, nullable=True) + expire_date: Mapped[object | None] = mapped_column(Date, nullable=True) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class BomItem(Base): + __tablename__ = "md_bom_item" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + bom_id: Mapped[int] = mapped_column(ForeignKey("md_bom.id")) + seq_no: Mapped[int] = mapped_column(Integer) + material_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + usage_type: Mapped[str] = mapped_column(String(32)) + qty_per_piece: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + net_weight_per_piece_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + gross_weight_per_piece_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + loss_rate: Mapped[float] = mapped_column(DECIMAL(8, 4), server_default=text("0")) + issue_over_tolerance_rate: Mapped[float] = mapped_column(DECIMAL(8, 4), server_default=text("0")) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class StockBalance(Base): + __tablename__ = "wh_stock_balance" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id")) + location_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + qty_on_hand: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + weight_on_hand_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + qty_available: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + weight_available_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + qty_allocated: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + weight_allocated_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + avg_unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0")) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) diff --git a/backend/app/models/miniapp.py b/backend/app/models/miniapp.py new file mode 100644 index 0000000..b353303 --- /dev/null +++ b/backend/app/models/miniapp.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from sqlalchemy import BigInteger, Boolean, Date, DateTime, DECIMAL, ForeignKey, Integer, String, Text, text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class MiniAppAttendancePoint(Base): + __tablename__ = "attendance_points" + + name: Mapped[str] = mapped_column(String(128), primary_key=True) + latitude: Mapped[float | None] = mapped_column(DECIMAL(10, 7), nullable=True) + longitude: Mapped[float | None] = mapped_column(DECIMAL(10, 7), nullable=True) + radius_meters: Mapped[int] = mapped_column(Integer, server_default=text("500")) + remark: Mapped[str | None] = mapped_column(String(500), nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, server_default=text("1")) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class MiniAppPersonnel(Base): + __tablename__ = "personnel" + + phone: Mapped[str] = mapped_column(String(20), primary_key=True) + name: Mapped[str] = mapped_column(String(64)) + is_temporary: Mapped[bool] = mapped_column(Boolean, server_default=text("0")) + temporary_expires_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class MiniAppPersonRole(Base): + __tablename__ = "person_roles" + + phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"), primary_key=True) + role: Mapped[str] = mapped_column(String(32), primary_key=True) + created_at: Mapped[object] = mapped_column(DateTime) + + +class MiniAppPersonAttendancePoint(Base): + __tablename__ = "person_attendance_points" + + phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"), primary_key=True) + attendance_point_name: Mapped[str] = mapped_column(ForeignKey("attendance_points.name"), primary_key=True) + created_at: Mapped[object] = mapped_column(DateTime) + + +class MiniAppProduct(Base): + __tablename__ = "products" + + attendance_point_name: Mapped[str] = mapped_column(String(128), primary_key=True, server_default=text("'宁波嘉恒智能科技有限公司'")) + project_no: Mapped[str] = mapped_column(String(64), primary_key=True) + product_name: Mapped[str] = mapped_column(String(255), primary_key=True) + device_no: Mapped[str] = mapped_column(String(64), primary_key=True) + process_name: Mapped[str] = mapped_column(String(128), primary_key=True) + profile_no: Mapped[str | None] = mapped_column(String(128), nullable=True) + material_code: Mapped[str | None] = mapped_column(String(128), nullable=True) + material_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + supplier: Mapped[str | None] = mapped_column(String(255), nullable=True) + product_net_weight_kg: Mapped[float | None] = mapped_column(DECIMAL(12, 4), nullable=True) + product_gross_weight_kg: Mapped[float | None] = mapped_column(DECIMAL(12, 4), nullable=True) + scrap_loss_rate: Mapped[float | None] = mapped_column(DECIMAL(10, 6), nullable=True) + waste_price_yuan_per_kg: Mapped[float | None] = mapped_column(DECIMAL(12, 4), nullable=True) + stamping_method: Mapped[str | None] = mapped_column(String(128), nullable=True) + operator_count: Mapped[float] = mapped_column(DECIMAL(10, 2), server_default=text("1")) + standard_beat: Mapped[float] = mapped_column(DECIMAL(12, 3), server_default=text("0")) + standard_workload: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0")) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class MiniAppProductionReport(Base): + __tablename__ = "production_reports" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + attendance_point_name: Mapped[str] = mapped_column(String(128), server_default=text("'宁波嘉恒智能科技有限公司'")) + session_id: Mapped[int] = mapped_column(BigInteger) + employee_phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone")) + report_date: Mapped[object] = mapped_column(Date) + start_at: Mapped[object] = mapped_column(DateTime) + end_at: Mapped[object] = mapped_column(DateTime) + duration_minutes: Mapped[float] = mapped_column(DECIMAL(10, 2), server_default=text("0")) + break_minutes: Mapped[float] = mapped_column(DECIMAL(10, 2), server_default=text("0")) + effective_minutes: Mapped[float] = mapped_column(DECIMAL(10, 2), server_default=text("0")) + total_good_qty: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0")) + total_output_qty: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0")) + actual_beat: Mapped[float] = mapped_column(DECIMAL(12, 3), server_default=text("0")) + standard_beat: Mapped[float] = mapped_column(DECIMAL(12, 3), server_default=text("0")) + expected_workload: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0")) + pace_rate: Mapped[float] = mapped_column(DECIMAL(10, 2), server_default=text("0")) + workload_rate: Mapped[float] = mapped_column(DECIMAL(10, 2), server_default=text("0")) + status: Mapped[str] = mapped_column(String(32), server_default=text("'pending'")) + reviewer_phone: Mapped[str | None] = mapped_column(String(20), nullable=True) + reviewed_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) + reject_reason: Mapped[str | None] = mapped_column(String(500), nullable=True) + submitted_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class MiniAppProductionReportItem(Base): + __tablename__ = "production_report_items" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + report_id: Mapped[int] = mapped_column(ForeignKey("production_reports.id")) + attendance_point_name: Mapped[str] = mapped_column(String(128), server_default=text("'宁波嘉恒智能科技有限公司'")) + device_no: Mapped[str] = mapped_column(String(64)) + project_no: Mapped[str] = mapped_column(String(64)) + product_name: Mapped[str] = mapped_column(String(255)) + material_code: Mapped[str | None] = mapped_column(String(128), nullable=True) + material_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + raw_material_batch_no: Mapped[str | None] = mapped_column(String(128), nullable=True) + process_name: Mapped[str | None] = mapped_column(String(128), nullable=True) + stamping_method: Mapped[str | None] = mapped_column(String(128), nullable=True) + standard_beat: Mapped[float] = mapped_column(DECIMAL(12, 3), server_default=text("0")) + standard_workload: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0")) + good_qty: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0")) + defect_qty: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0")) + scrap_qty: Mapped[float] = mapped_column(DECIMAL(12, 2), server_default=text("0")) + allocated_minutes: Mapped[float] = mapped_column(DECIMAL(10, 2), server_default=text("0")) + started_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + + +class MiniAppNotice(Base): + __tablename__ = "notices" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + title: Mapped[str] = mapped_column(String(120)) + content: Mapped[str] = mapped_column(Text) + sort_order: Mapped[int] = mapped_column(Integer, server_default=text("0")) + is_active: Mapped[bool] = mapped_column(Boolean, server_default=text("1")) + created_by: Mapped[str] = mapped_column(ForeignKey("personnel.phone")) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class MiniAppNoticePoint(Base): + __tablename__ = "notice_points" + + notice_id: Mapped[int] = mapped_column(ForeignKey("notices.id"), primary_key=True) + attendance_point_name: Mapped[str] = mapped_column(ForeignKey("attendance_points.name"), primary_key=True) + created_at: Mapped[object] = mapped_column(DateTime) diff --git a/backend/app/models/operations.py b/backend/app/models/operations.py new file mode 100644 index 0000000..87f7bd1 --- /dev/null +++ b/backend/app/models/operations.py @@ -0,0 +1,875 @@ +from __future__ import annotations + +from sqlalchemy import JSON, BigInteger, Date, DateTime, DECIMAL, ForeignKey, Integer, String, UniqueConstraint, text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class Supplier(Base): + __tablename__ = "md_supplier" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + supplier_code: Mapped[str] = mapped_column(String(50)) + supplier_name: Mapped[str] = mapped_column(String(200)) + short_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + contact_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + contact_phone: Mapped[str | None] = mapped_column(String(50), nullable=True) + address: Mapped[str | None] = mapped_column(String(255), nullable=True) + lead_time_days: Mapped[int] = mapped_column(Integer, server_default=text("0")) + default_tax_rate: Mapped[float] = mapped_column(DECIMAL(8, 4), server_default=text("0")) + status: Mapped[str] = mapped_column(String(32)) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class Process(Base): + __tablename__ = "md_process" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + process_code: Mapped[str] = mapped_column(String(50)) + process_name: Mapped[str] = mapped_column(String(100)) + process_type: Mapped[str] = mapped_column(String(32)) + is_report_required: Mapped[int] = mapped_column(Integer, server_default=text("1")) + is_quality_gate: Mapped[int] = mapped_column(Integer, server_default=text("0")) + status: Mapped[str] = mapped_column(String(32)) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class WorkCenter(Base): + __tablename__ = "md_work_center" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + center_code: Mapped[str] = mapped_column(String(50)) + center_name: Mapped[str] = mapped_column(String(100)) + dept_id: Mapped[int] = mapped_column(ForeignKey("sys_department.id")) + center_type: Mapped[str] = mapped_column(String(50)) + shift_pattern: Mapped[str | None] = mapped_column(String(50), nullable=True) + capacity_hours_per_day: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + status: Mapped[str] = mapped_column(String(32)) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class ProcessRoute(Base): + __tablename__ = "md_process_route" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + route_code: Mapped[str] = mapped_column(String(50)) + route_name: Mapped[str] = mapped_column(String(100)) + product_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + version_no: Mapped[str] = mapped_column(String(50)) + is_default: Mapped[int] = mapped_column(Integer, server_default=text("1")) + status: Mapped[str] = mapped_column(String(32)) + effective_date: Mapped[object | None] = mapped_column(Date, nullable=True) + expire_date: Mapped[object | None] = mapped_column(Date, nullable=True) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class ProcessRouteOperation(Base): + __tablename__ = "md_process_route_operation" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + route_id: Mapped[int] = mapped_column(ForeignKey("md_process_route.id")) + seq_no: Mapped[int] = mapped_column(Integer) + process_id: Mapped[int] = mapped_column(BigInteger) + work_center_id: Mapped[int] = mapped_column(ForeignKey("md_work_center.id")) + operation_name: Mapped[str] = mapped_column(String(100)) + std_setup_minutes: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + std_cycle_seconds: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + std_labor_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0")) + std_machine_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0")) + std_outsource_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0")) + allowed_scrap_rate: Mapped[float] = mapped_column(DECIMAL(8, 4), server_default=text("0")) + report_required: Mapped[int] = mapped_column(Integer, server_default=text("1")) + is_key_operation: Mapped[int] = mapped_column(Integer, server_default=text("0")) + status: Mapped[str] = mapped_column(String(32)) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class Equipment(Base): + __tablename__ = "em_equipment" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + equipment_code: Mapped[str] = mapped_column(String(50)) + equipment_name: Mapped[str] = mapped_column(String(100)) + category_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + work_center_id: Mapped[int] = mapped_column(ForeignKey("md_work_center.id")) + manufacturer: Mapped[str | None] = mapped_column(String(100), nullable=True) + model_no: Mapped[str | None] = mapped_column(String(100), nullable=True) + install_date: Mapped[object | None] = mapped_column(Date, nullable=True) + rated_power_kw: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + occupied_area_sqm: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + maintenance_cycle_hours: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + last_maintenance_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) + next_maintenance_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) + status: Mapped[str] = mapped_column(String(32)) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class MaintenancePlan(Base): + __tablename__ = "em_maintenance_plan" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + plan_code: Mapped[str] = mapped_column(String(50)) + equipment_id: Mapped[int] = mapped_column(ForeignKey("em_equipment.id")) + plan_name: Mapped[str] = mapped_column(String(100)) + maintenance_type: Mapped[str] = mapped_column(String(32)) + cycle_hours: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + cycle_days: Mapped[int] = mapped_column(Integer, server_default=text("0")) + last_execute_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) + next_execute_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) + status: Mapped[str] = mapped_column(String(32)) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class MaintenanceOrder(Base): + __tablename__ = "em_maintenance_order" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + order_no: Mapped[str] = mapped_column(String(50)) + equipment_id: Mapped[int] = mapped_column(ForeignKey("em_equipment.id")) + plan_id: Mapped[int | None] = mapped_column(ForeignKey("em_maintenance_plan.id"), nullable=True) + maintenance_type: Mapped[str] = mapped_column(String(32)) + start_time: Mapped[object | None] = mapped_column(DateTime, nullable=True) + end_time: Mapped[object | None] = mapped_column(DateTime, nullable=True) + downtime_minutes: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + owner_employee_id: Mapped[int | None] = mapped_column(ForeignKey("hr_employee.id"), nullable=True) + result_summary: Mapped[str | None] = mapped_column(String(255), nullable=True) + cost_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + status: Mapped[str] = mapped_column(String(32)) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class PurchaseOrder(Base): + __tablename__ = "po_purchase_order" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + po_no: Mapped[str] = mapped_column(String(50)) + supplier_id: Mapped[int] = mapped_column(ForeignKey("md_supplier.id")) + order_date: Mapped[object] = mapped_column(Date) + expected_date: Mapped[object | None] = mapped_column(Date, nullable=True) + warning_lead_days: Mapped[int] = mapped_column(Integer, server_default=text("0")) + purchaser_employee_id: Mapped[int | None] = mapped_column(ForeignKey("hr_employee.id"), nullable=True) + target_warehouse_type: Mapped[str] = mapped_column(String(32), server_default=text("'RAW'")) + tax_rate: Mapped[float] = mapped_column(DECIMAL(8, 4), server_default=text("0")) + total_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + status: Mapped[str] = mapped_column(String(32)) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class PurchaseOrderSalesOrderLink(Base): + __tablename__ = "po_purchase_order_sales_order" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + purchase_order_id: Mapped[int] = mapped_column(ForeignKey("po_purchase_order.id")) + sales_order_id: Mapped[int] = mapped_column(ForeignKey("so_sales_order.id")) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class PurchaseOrderItem(Base): + __tablename__ = "po_purchase_order_item" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + purchase_order_id: Mapped[int] = mapped_column(ForeignKey("po_purchase_order.id")) + line_no: Mapped[int] = mapped_column(Integer) + material_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + source_demand_id: Mapped[int | None] = mapped_column(ForeignKey("mrp_material_demand.id"), nullable=True) + order_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + order_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + received_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + received_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + unit_price: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0")) + line_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + status: Mapped[str] = mapped_column(String(32)) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class WarehouseLocation(Base): + __tablename__ = "wh_location" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id")) + location_code: Mapped[str] = mapped_column(String(50)) + location_name: Mapped[str] = mapped_column(String(100)) + zone_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + is_default: Mapped[int] = mapped_column(Integer, server_default=text("0")) + is_locked: Mapped[int] = mapped_column(Integer, server_default=text("0")) + status: Mapped[str] = mapped_column(String(32)) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class PurchaseReceipt(Base): + __tablename__ = "po_receipt" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + receipt_no: Mapped[str] = mapped_column(String(50)) + purchase_order_id: Mapped[int] = mapped_column(ForeignKey("po_purchase_order.id")) + warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id")) + receipt_date: Mapped[object] = mapped_column(DateTime) + receiver_employee_id: Mapped[int | None] = mapped_column(ForeignKey("hr_employee.id"), nullable=True) + supplier_delivery_no: Mapped[str | None] = mapped_column(String(100), nullable=True) + logistics_waybill_no: Mapped[str | None] = mapped_column(String(100), nullable=True) + logistics_freight_amount: Mapped[float | None] = mapped_column(DECIMAL(18, 2), nullable=True) + logistics_photo_url: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[str] = mapped_column(String(32)) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class PurchaseReceiptItem(Base): + __tablename__ = "po_receipt_item" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + receipt_id: Mapped[int] = mapped_column(ForeignKey("po_receipt.id")) + line_no: Mapped[int] = mapped_column(Integer) + purchase_order_item_id: Mapped[int] = mapped_column(ForeignKey("po_purchase_order_item.id")) + material_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + location_id: Mapped[int | None] = mapped_column(ForeignKey("wh_location.id"), nullable=True) + lot_no: Mapped[str] = mapped_column(String(50)) + merge_to_lot_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + received_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + received_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + accepted_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + accepted_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0")) + status: Mapped[str] = mapped_column(String(32)) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class PurchaseReturn(Base): + __tablename__ = "po_purchase_return" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + return_no: Mapped[str] = mapped_column(String(50), unique=True) + purchase_order_id: Mapped[int] = mapped_column(ForeignKey("po_purchase_order.id")) + warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id")) + return_date: Mapped[object] = mapped_column(DateTime) + operator_user_id: Mapped[int | None] = mapped_column(ForeignKey("sys_user.id"), nullable=True) + logistics_waybill_no: Mapped[str | None] = mapped_column(String(100), nullable=True) + logistics_freight_amount: Mapped[float | None] = mapped_column(DECIMAL(18, 2), nullable=True) + logistics_photo_url: Mapped[str | None] = mapped_column(String(255), nullable=True) + reason: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[str] = mapped_column(String(32)) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class PurchaseReturnItem(Base): + __tablename__ = "po_purchase_return_item" + __table_args__ = ( + UniqueConstraint("purchase_return_id", "line_no", name="uk_po_purchase_return_item_line"), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + purchase_return_id: Mapped[int] = mapped_column(ForeignKey("po_purchase_return.id")) + line_no: Mapped[int] = mapped_column(Integer) + purchase_order_item_id: Mapped[int] = mapped_column(ForeignKey("po_purchase_order_item.id")) + material_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + lot_id: Mapped[int] = mapped_column(ForeignKey("wh_stock_lot.id")) + return_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0")) + amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + reason: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[str] = mapped_column(String(32)) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class StockLot(Base): + __tablename__ = "wh_stock_lot" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + lot_no: Mapped[str] = mapped_column(String(50)) + parent_lot_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + lot_role: Mapped[str] = mapped_column(String(32), server_default=text("'INVENTORY'")) + material_sub_batch_no: Mapped[str | None] = mapped_column(String(80), nullable=True) + item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id")) + location_id: Mapped[int | None] = mapped_column(ForeignKey("wh_location.id"), nullable=True) + source_doc_type: Mapped[str] = mapped_column(String(32)) + source_doc_id: Mapped[int] = mapped_column(BigInteger) + source_line_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + source_material_lot_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + source_material_sub_batch_no: Mapped[str | None] = mapped_column(String(80), nullable=True) + source_material_summary: Mapped[str | None] = mapped_column(String(1000), nullable=True) + logistics_waybill_no: Mapped[str | None] = mapped_column(String(100), nullable=True) + logistics_freight_amount: Mapped[float | None] = mapped_column(DECIMAL(18, 2), nullable=True) + logistics_photo_url: Mapped[str | None] = mapped_column(String(255), nullable=True) + inbound_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + inbound_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + remaining_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + remaining_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + locked_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + locked_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0")) + production_date: Mapped[object | None] = mapped_column(Date, nullable=True) + expire_date: Mapped[object | None] = mapped_column(Date, nullable=True) + quality_status: Mapped[str] = mapped_column(String(32)) + status: Mapped[str] = mapped_column(String(32)) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class InventoryTxn(Base): + __tablename__ = "wh_inventory_txn" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + txn_no: Mapped[str] = mapped_column(String(50)) + txn_type: Mapped[str] = mapped_column(String(32)) + item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id")) + location_id: Mapped[int | None] = mapped_column(ForeignKey("wh_location.id"), nullable=True) + lot_id: Mapped[int | None] = mapped_column(ForeignKey("wh_stock_lot.id"), nullable=True) + qty_change: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + weight_change_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0")) + amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + source_doc_type: Mapped[str] = mapped_column(String(32)) + source_doc_id: Mapped[int] = mapped_column(BigInteger) + source_line_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + logistics_waybill_no: Mapped[str | None] = mapped_column(String(100), nullable=True) + logistics_freight_amount: Mapped[float | None] = mapped_column(DECIMAL(18, 2), nullable=True) + logistics_photo_url: Mapped[str | None] = mapped_column(String(255), nullable=True) + biz_time: Mapped[object] = mapped_column(DateTime) + operator_user_id: Mapped[int | None] = mapped_column(ForeignKey("sys_user.id"), nullable=True) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class SpecialWarehouseAdjustment(Base): + __tablename__ = "wh_special_adjustment" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + adjustment_no: Mapped[str] = mapped_column(String(50), unique=True) + warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id")) + warehouse_type: Mapped[str] = mapped_column(String(32)) + direction: Mapped[str] = mapped_column(String(32)) + reason: Mapped[str] = mapped_column(String(1000)) + status: Mapped[str] = mapped_column(String(32)) + operator_user_id: Mapped[int | None] = mapped_column(ForeignKey("sys_user.id"), nullable=True) + adjusted_at: Mapped[object] = mapped_column(DateTime) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class SpecialWarehouseAdjustmentLine(Base): + __tablename__ = "wh_special_adjustment_line" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + adjustment_id: Mapped[int] = mapped_column(ForeignKey("wh_special_adjustment.id")) + line_no: Mapped[int] = mapped_column(Integer) + item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id")) + location_id: Mapped[int | None] = mapped_column(ForeignKey("wh_location.id"), nullable=True) + lot_id: Mapped[int] = mapped_column(ForeignKey("wh_stock_lot.id")) + inventory_txn_id: Mapped[int | None] = mapped_column(ForeignKey("wh_inventory_txn.id"), nullable=True) + before_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + change_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + after_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + before_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + change_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + after_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0")) + line_reason: Mapped[str | None] = mapped_column(String(1000), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class Stocktake(Base): + __tablename__ = "wh_stocktake" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + stocktake_no: Mapped[str] = mapped_column(String(50)) + status: Mapped[str] = mapped_column(String(32)) + started_by_user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + imported_by_user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + confirmed_by_user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + canceled_by_user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + started_at: Mapped[object] = mapped_column(DateTime) + exported_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) + imported_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) + confirmed_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) + canceled_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP")) + updated_at: Mapped[object] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP")) + + +class StocktakeWarehouse(Base): + __tablename__ = "wh_stocktake_warehouse" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + stocktake_id: Mapped[int] = mapped_column(ForeignKey("wh_stocktake.id")) + warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id")) + warehouse_type: Mapped[str] = mapped_column(String(32)) + warehouse_name: Mapped[str] = mapped_column(String(100)) + status: Mapped[str] = mapped_column(String(32)) + created_at: Mapped[object] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP")) + updated_at: Mapped[object] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP")) + + +class StocktakeLine(Base): + __tablename__ = "wh_stocktake_line" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + stocktake_id: Mapped[int] = mapped_column(ForeignKey("wh_stocktake.id")) + stocktake_warehouse_id: Mapped[int | None] = mapped_column(ForeignKey("wh_stocktake_warehouse.id"), nullable=True) + line_no: Mapped[int] = mapped_column(Integer) + warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id")) + warehouse_name: Mapped[str] = mapped_column(String(100)) + warehouse_type: Mapped[str] = mapped_column(String(32)) + location_id: Mapped[int | None] = mapped_column(ForeignKey("wh_location.id"), nullable=True) + location_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + item_code: Mapped[str] = mapped_column(String(50)) + item_name: Mapped[str] = mapped_column(String(200)) + item_type: Mapped[str] = mapped_column(String(32)) + lot_id: Mapped[int | None] = mapped_column(ForeignKey("wh_stock_lot.id"), nullable=True) + lot_no: Mapped[str | None] = mapped_column(String(80), nullable=True) + source_material_sub_batch_no: Mapped[str | None] = mapped_column(String(80), nullable=True) + snapshot_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + snapshot_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + snapshot_unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0")) + counted_qty: Mapped[float | None] = mapped_column(DECIMAL(18, 6), nullable=True) + counted_weight_kg: Mapped[float | None] = mapped_column(DECIMAL(18, 6), nullable=True) + diff_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + diff_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + diff_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + diff_type: Mapped[str] = mapped_column(String(32)) + row_status: Mapped[str] = mapped_column(String(32)) + error_message: Mapped[str | None] = mapped_column(String(500), nullable=True) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP")) + updated_at: Mapped[object] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP")) + + +class StocktakeAdjustment(Base): + __tablename__ = "wh_stocktake_adjustment" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + stocktake_id: Mapped[int] = mapped_column(ForeignKey("wh_stocktake.id")) + stocktake_line_id: Mapped[int] = mapped_column(ForeignKey("wh_stocktake_line.id")) + warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id")) + item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + lot_id: Mapped[int | None] = mapped_column(ForeignKey("wh_stock_lot.id"), nullable=True) + adjustment_lot_id: Mapped[int | None] = mapped_column(ForeignKey("wh_stock_lot.id"), nullable=True) + inventory_txn_id: Mapped[int | None] = mapped_column(ForeignKey("wh_inventory_txn.id"), nullable=True) + adjustment_type: Mapped[str] = mapped_column(String(32)) + before_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + after_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + before_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + after_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + qty_change: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + weight_change_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0")) + amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + created_at: Mapped[object] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP")) + updated_at: Mapped[object] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP")) + + +class WorkOrder(Base): + __tablename__ = "pp_work_order" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + work_order_no: Mapped[str] = mapped_column(String(80)) + work_order_type: Mapped[str] = mapped_column(String(32)) + source_sales_order_item_id: Mapped[int | None] = mapped_column(ForeignKey("so_sales_order_item.id"), nullable=True) + product_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + route_id: Mapped[int] = mapped_column(ForeignKey("md_process_route.id")) + bom_id: Mapped[int] = mapped_column(ForeignKey("md_bom.id")) + planned_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + released_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + finished_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + scrap_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + planned_start_time: Mapped[object | None] = mapped_column(DateTime, nullable=True) + planned_end_time: Mapped[object | None] = mapped_column(DateTime, nullable=True) + actual_start_time: Mapped[object | None] = mapped_column(DateTime, nullable=True) + actual_end_time: Mapped[object | None] = mapped_column(DateTime, nullable=True) + priority_level: Mapped[int] = mapped_column(Integer, server_default=text("3")) + status: Mapped[str] = mapped_column(String(32)) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class WorkOrderMaterial(Base): + __tablename__ = "pp_work_order_material" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + work_order_id: Mapped[int] = mapped_column(ForeignKey("pp_work_order.id")) + bom_item_id: Mapped[int] = mapped_column(ForeignKey("md_bom_item.id")) + material_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + required_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + required_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + issued_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + issued_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + returned_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + returned_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + status: Mapped[str] = mapped_column(String(32)) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class WorkOrderMaterialIssue(Base): + __tablename__ = "pp_work_order_material_issue" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + work_order_material_id: Mapped[int] = mapped_column(ForeignKey("pp_work_order_material.id")) + work_order_id: Mapped[int] = mapped_column(ForeignKey("pp_work_order.id")) + material_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + source_lot_id: Mapped[int] = mapped_column(ForeignKey("wh_stock_lot.id")) + material_sub_batch_no: Mapped[str] = mapped_column(String(80)) + issued_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + issued_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0")) + amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + issue_time: Mapped[object] = mapped_column(DateTime) + operator_user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class ProductionBatchLedger(Base): + __tablename__ = "pp_production_batch_ledger" + __table_args__ = ( + UniqueConstraint("material_lot_id", "product_item_id", name="uk_production_batch_ledger_lot_product"), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + material_lot_id: Mapped[int] = mapped_column(ForeignKey("wh_stock_lot.id")) + material_lot_no: Mapped[str] = mapped_column(String(128)) + material_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + product_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + status: Mapped[str] = mapped_column(String(32), server_default=text("'在生产'")) + miniapp_selectable: Mapped[int] = mapped_column(Integer, server_default=text("1")) + total_issued_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + outside_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + finished_inbound_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + surplus_inbound_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + scrap_inbound_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + writeoff_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + first_issue_time: Mapped[object | None] = mapped_column(DateTime, nullable=True) + last_issue_time: Mapped[object | None] = mapped_column(DateTime, nullable=True) + locked_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) + reopened_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) + lock_count: Mapped[int] = mapped_column(Integer, server_default=text("0")) + remark: Mapped[str | None] = mapped_column(String(500), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class ProductionBatchLedgerTxn(Base): + __tablename__ = "pp_production_batch_ledger_txn" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + production_ledger_id: Mapped[int] = mapped_column(ForeignKey("pp_production_batch_ledger.id")) + txn_type: Mapped[str] = mapped_column(String(32)) + qty_delta: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + weight_delta_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + outside_weight_after_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + source_doc_type: Mapped[str | None] = mapped_column(String(64), nullable=True) + source_doc_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + source_line_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + biz_time: Mapped[object] = mapped_column(DateTime) + operator_user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + remark: Mapped[str | None] = mapped_column(String(500), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class WorkOrderOperation(Base): + __tablename__ = "pp_work_order_operation" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + work_order_id: Mapped[int] = mapped_column(ForeignKey("pp_work_order.id")) + route_operation_id: Mapped[int] = mapped_column(ForeignKey("md_process_route_operation.id")) + seq_no: Mapped[int] = mapped_column(Integer) + operation_name: Mapped[str] = mapped_column(String(100)) + work_center_id: Mapped[int] = mapped_column(ForeignKey("md_work_center.id")) + planned_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + report_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + good_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + scrap_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + rework_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + std_labor_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0")) + std_machine_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0")) + std_outsource_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0")) + allowed_scrap_rate: Mapped[float] = mapped_column(DECIMAL(8, 4), server_default=text("0")) + is_abnormal: Mapped[int] = mapped_column(Integer, server_default=text("0")) + status: Mapped[str] = mapped_column(String(32)) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class OperationReport(Base): + __tablename__ = "pp_operation_report" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + report_no: Mapped[str] = mapped_column(String(50)) + work_order_id: Mapped[int | None] = mapped_column(ForeignKey("pp_work_order.id"), nullable=True) + work_order_operation_id: Mapped[int | None] = mapped_column(ForeignKey("pp_work_order_operation.id"), nullable=True) + production_ledger_id: Mapped[int | None] = mapped_column(ForeignKey("pp_production_batch_ledger.id"), nullable=True) + employee_id: Mapped[int] = mapped_column(ForeignKey("hr_employee.id")) + equipment_id: Mapped[int | None] = mapped_column(ForeignKey("em_equipment.id"), nullable=True) + report_source: Mapped[str] = mapped_column(String(32)) + shift_code: Mapped[str | None] = mapped_column(String(32), nullable=True) + start_time: Mapped[object] = mapped_column(DateTime) + end_time: Mapped[object] = mapped_column(DateTime) + report_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + good_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + scrap_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + rework_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + scrap_reason: Mapped[str | None] = mapped_column(String(255), nullable=True) + downtime_minutes: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + extra_cost_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + is_abnormal: Mapped[int] = mapped_column(Integer, server_default=text("0")) + approved_by: Mapped[int | None] = mapped_column(ForeignKey("sys_user.id"), nullable=True) + approved_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class ScrapRecord(Base): + __tablename__ = "pp_scrap_record" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + scrap_no: Mapped[str] = mapped_column(String(50)) + report_id: Mapped[int] = mapped_column(ForeignKey("pp_operation_report.id")) + work_order_id: Mapped[int] = mapped_column(ForeignKey("pp_work_order.id")) + work_order_operation_id: Mapped[int] = mapped_column(ForeignKey("pp_work_order_operation.id")) + item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + scrap_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + scrap_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + scrap_reason: Mapped[str | None] = mapped_column(String(255), nullable=True) + disposal_type: Mapped[str] = mapped_column(String(32)) + saleable_scrap_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + scrap_cost_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + status: Mapped[str] = mapped_column(String(32)) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class CompletionReceipt(Base): + __tablename__ = "pp_completion_receipt" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + receipt_no: Mapped[str] = mapped_column(String(50)) + work_order_id: Mapped[int] = mapped_column(ForeignKey("pp_work_order.id")) + warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id")) + receipt_time: Mapped[object] = mapped_column(DateTime) + receiver_employee_id: Mapped[int | None] = mapped_column(ForeignKey("hr_employee.id"), nullable=True) + status: Mapped[str] = mapped_column(String(32)) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class CompletionReceiptItem(Base): + __tablename__ = "pp_completion_receipt_item" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + completion_receipt_id: Mapped[int] = mapped_column(ForeignKey("pp_completion_receipt.id")) + line_no: Mapped[int] = mapped_column(Integer) + product_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + location_id: Mapped[int | None] = mapped_column(ForeignKey("wh_location.id"), nullable=True) + lot_no: Mapped[str] = mapped_column(String(50)) + source_material_lot_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + source_material_sub_batch_no: Mapped[str | None] = mapped_column(String(80), nullable=True) + source_material_summary: Mapped[str | None] = mapped_column(String(1000), nullable=True) + receipt_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + receipt_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0")) + status: Mapped[str] = mapped_column(String(32)) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class Delivery(Base): + __tablename__ = "so_delivery" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + delivery_no: Mapped[str] = mapped_column(String(50)) + sales_order_id: Mapped[int | None] = mapped_column(ForeignKey("so_sales_order.id"), nullable=True) + customer_id: Mapped[int | None] = mapped_column(ForeignKey("md_customer.id"), nullable=True) + warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id")) + delivery_date: Mapped[object] = mapped_column(DateTime) + shipper_employee_id: Mapped[int | None] = mapped_column(ForeignKey("hr_employee.id"), nullable=True) + consignee_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + consignee_phone: Mapped[str | None] = mapped_column(String(50), nullable=True) + delivery_address: Mapped[str | None] = mapped_column(String(255), nullable=True) + logistics_waybill_no: Mapped[str | None] = mapped_column(String(100), nullable=True) + logistics_freight_amount: Mapped[float | None] = mapped_column(DECIMAL(18, 2), nullable=True) + logistics_photo_url: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[str] = mapped_column(String(32)) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class DeliveryItem(Base): + __tablename__ = "so_delivery_item" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + delivery_id: Mapped[int] = mapped_column(ForeignKey("so_delivery.id")) + line_no: Mapped[int] = mapped_column(Integer) + sales_order_item_id: Mapped[int | None] = mapped_column(ForeignKey("so_sales_order_item.id"), nullable=True) + product_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + lot_id: Mapped[int | None] = mapped_column(ForeignKey("wh_stock_lot.id"), nullable=True) + delivery_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + delivery_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + unit_price: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0")) + line_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + status: Mapped[str] = mapped_column(String(32)) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class ReturnOrder(Base): + __tablename__ = "rt_return_order" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + return_no: Mapped[str] = mapped_column(String(50)) + customer_id: Mapped[int] = mapped_column(ForeignKey("md_customer.id")) + sales_order_id: Mapped[int | None] = mapped_column(ForeignKey("so_sales_order.id"), nullable=True) + delivery_id: Mapped[int | None] = mapped_column(ForeignKey("so_delivery.id"), nullable=True) + return_date: Mapped[object] = mapped_column(DateTime) + return_reason: Mapped[str | None] = mapped_column(String(255), nullable=True) + handler_employee_id: Mapped[int | None] = mapped_column(ForeignKey("hr_employee.id"), nullable=True) + status: Mapped[str] = mapped_column(String(32)) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class ReturnItem(Base): + __tablename__ = "rt_return_item" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + return_order_id: Mapped[int] = mapped_column(ForeignKey("rt_return_order.id")) + line_no: Mapped[int] = mapped_column(Integer) + sales_order_item_id: Mapped[int | None] = mapped_column(ForeignKey("so_sales_order_item.id"), nullable=True) + delivery_item_id: Mapped[int | None] = mapped_column(ForeignKey("so_delivery_item.id"), nullable=True) + product_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + source_lot_id: Mapped[int | None] = mapped_column(ForeignKey("wh_stock_lot.id"), nullable=True) + return_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + return_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + disposition_status: Mapped[str] = mapped_column(String(32)) + responsibility_type: Mapped[str | None] = mapped_column(String(32), nullable=True) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class ReturnDisposition(Base): + __tablename__ = "rt_return_disposition" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + disposition_no: Mapped[str] = mapped_column(String(50)) + return_item_id: Mapped[int] = mapped_column(ForeignKey("rt_return_item.id")) + disposition_type: Mapped[str] = mapped_column(String(32)) + rework_work_order_id: Mapped[int | None] = mapped_column(ForeignKey("pp_work_order.id"), nullable=True) + scrap_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + extra_cost_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + scrap_sale_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + closed_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) + status: Mapped[str] = mapped_column(String(32)) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class AccountingPeriod(Base): + __tablename__ = "fi_accounting_period" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + period_code: Mapped[str] = mapped_column(String(20)) + year_no: Mapped[int] = mapped_column(Integer) + month_no: Mapped[int] = mapped_column(Integer) + start_date: Mapped[object] = mapped_column(Date) + end_date: Mapped[object] = mapped_column(Date) + close_status: Mapped[str] = mapped_column(String(32)) + closed_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class OverheadEntry(Base): + __tablename__ = "fi_overhead_entry" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + period_id: Mapped[int] = mapped_column(ForeignKey("fi_accounting_period.id")) + overhead_type: Mapped[str] = mapped_column(String(32)) + dept_id: Mapped[int | None] = mapped_column(ForeignKey("sys_department.id"), nullable=True) + amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + allocation_basis: Mapped[str] = mapped_column(String(32)) + description: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_by: Mapped[int | None] = mapped_column(ForeignKey("sys_user.id"), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class CostAllocation(Base): + __tablename__ = "fi_cost_allocation" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + period_id: Mapped[int] = mapped_column(ForeignKey("fi_accounting_period.id")) + work_order_id: Mapped[int] = mapped_column(ForeignKey("pp_work_order.id")) + product_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + allocation_basis_value: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + overhead_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + material_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + labor_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + machine_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + abnormal_loss_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + total_cost_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class StatementSnapshot(Base): + __tablename__ = "fi_statement_snapshot" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + snapshot_no: Mapped[str] = mapped_column(String(50)) + period_id: Mapped[int] = mapped_column(ForeignKey("fi_accounting_period.id")) + statement_type: Mapped[str] = mapped_column(String(32)) + report_date: Mapped[object] = mapped_column(Date) + summary_json: Mapped[dict] = mapped_column(JSON) + created_by: Mapped[int | None] = mapped_column(ForeignKey("sys_user.id"), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) diff --git a/backend/app/models/org.py b/backend/app/models/org.py new file mode 100644 index 0000000..9b5fb56 --- /dev/null +++ b/backend/app/models/org.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from sqlalchemy import DECIMAL, BigInteger, Date, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class Department(Base): + __tablename__ = "sys_department" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + dept_code: Mapped[str] = mapped_column(String(50)) + dept_name: Mapped[str] = mapped_column(String(100)) + parent_id: Mapped[int | None] = mapped_column(ForeignKey("sys_department.id"), nullable=True) + org_node_type: Mapped[str] = mapped_column(String(32), server_default=text("'DEPARTMENT'")) + dept_type: Mapped[str] = mapped_column(String(32)) + manager_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + manager_employee_id: Mapped[int | None] = mapped_column(ForeignKey("hr_employee.id"), nullable=True) + status: Mapped[str] = mapped_column(String(32)) + sort_no: Mapped[int] = mapped_column(Integer, server_default=text("0")) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class Employee(Base): + __tablename__ = "hr_employee" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + employee_code: Mapped[str] = mapped_column(String(50)) + employee_name: Mapped[str] = mapped_column(String(100)) + dept_id: Mapped[int] = mapped_column(ForeignKey("sys_department.id")) + mobile: Mapped[str | None] = mapped_column(String(30), nullable=True) + gender: Mapped[str | None] = mapped_column(String(16), nullable=True) + hire_date: Mapped[object | None] = mapped_column(Date, nullable=True) + job_title: Mapped[str | None] = mapped_column(String(100), nullable=True) + shift_code: Mapped[str | None] = mapped_column(String(32), nullable=True) + manager_employee_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + is_operator: Mapped[int] = mapped_column(Integer, server_default=text("0")) + is_workshop_staff: Mapped[int] = mapped_column(Integer, server_default=text("0")) + status: Mapped[str] = mapped_column(String(32)) + remark: Mapped[str | None] = mapped_column(String(500), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class Role(Base): + __tablename__ = "sys_role" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + role_code: Mapped[str] = mapped_column(String(50)) + role_name: Mapped[str] = mapped_column(String(100)) + role_scope: Mapped[str] = mapped_column(String(32)) + status: Mapped[str] = mapped_column(String(32)) + remark: Mapped[str | None] = mapped_column(String(500), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class Permission(Base): + __tablename__ = "sys_permission" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + permission_code: Mapped[str] = mapped_column(String(100)) + permission_name: Mapped[str] = mapped_column(String(100)) + module_code: Mapped[str] = mapped_column(String(50)) + action_code: Mapped[str] = mapped_column(String(50)) + status: Mapped[str] = mapped_column(String(32)) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class RolePermission(Base): + __tablename__ = "sys_role_permission" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + role_id: Mapped[int] = mapped_column(ForeignKey("sys_role.id")) + permission_id: Mapped[int] = mapped_column(ForeignKey("sys_permission.id")) + created_at: Mapped[object] = mapped_column(DateTime) + + +class User(Base): + __tablename__ = "sys_user" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + username: Mapped[str] = mapped_column(String(50)) + password_hash: Mapped[str] = mapped_column(String(255)) + employee_id: Mapped[int] = mapped_column(ForeignKey("hr_employee.id")) + dept_id: Mapped[int] = mapped_column(ForeignKey("sys_department.id")) + nickname: Mapped[str | None] = mapped_column(String(100), nullable=True) + email: Mapped[str | None] = mapped_column(String(100), nullable=True) + is_super_admin: Mapped[int] = mapped_column(Integer, server_default=text("0")) + last_login_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) + status: Mapped[str] = mapped_column(String(32)) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class UserRole(Base): + __tablename__ = "sys_user_role" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id")) + role_id: Mapped[int] = mapped_column(ForeignKey("sys_role.id")) + created_at: Mapped[object] = mapped_column(DateTime) + + +class OrgEmployeeBinding(Base): + __tablename__ = "sys_org_employee_binding" + __table_args__ = (UniqueConstraint("dept_id", "employee_id", name="uk_org_employee_binding"),) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + dept_id: Mapped[int] = mapped_column(ForeignKey("sys_department.id")) + employee_id: Mapped[int] = mapped_column(ForeignKey("hr_employee.id")) + sort_no: Mapped[int] = mapped_column(Integer, server_default=text("0")) + status: Mapped[str] = mapped_column(String(32), server_default=text("'ACTIVE'")) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class OrgManagerBinding(Base): + __tablename__ = "sys_org_manager_binding" + __table_args__ = (UniqueConstraint("dept_id", "employee_id", name="uk_org_manager_binding"),) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + dept_id: Mapped[int] = mapped_column(ForeignKey("sys_department.id")) + employee_id: Mapped[int] = mapped_column(ForeignKey("hr_employee.id")) + sort_no: Mapped[int] = mapped_column(Integer, server_default=text("0")) + status: Mapped[str] = mapped_column(String(32), server_default=text("'ACTIVE'")) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class BroadcastMessage(Base): + __tablename__ = "sys_broadcast_message" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + title: Mapped[str] = mapped_column(String(100)) + content: Mapped[str] = mapped_column(String(500)) + tone: Mapped[str] = mapped_column(String(32), server_default=text("'INFO'")) + status: Mapped[str] = mapped_column(String(32), server_default=text("'ACTIVE'")) + starts_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) + ends_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) + sort_no: Mapped[int] = mapped_column(Integer, server_default=text("0")) + created_by: Mapped[int | None] = mapped_column(ForeignKey("sys_user.id"), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class SystemConfig(Base): + __tablename__ = "sys_system_config" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + config_code: Mapped[str] = mapped_column(String(100), unique=True) + config_name: Mapped[str] = mapped_column(String(100)) + config_value: Mapped[str] = mapped_column(String(500)) + remark: Mapped[str | None] = mapped_column(String(500), nullable=True) + status: Mapped[str] = mapped_column(String(32), server_default=text("'ACTIVE'")) + created_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + updated_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class AiAssistantConfig(Base): + __tablename__ = "sys_ai_assistant_config" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + assistant_name: Mapped[str] = mapped_column(String(100), server_default=text("'嘉恒工艺助手'")) + provider_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + api_base_url: Mapped[str | None] = mapped_column(String(255), nullable=True) + api_key: Mapped[str | None] = mapped_column(String(512), nullable=True) + model_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + temperature: Mapped[float] = mapped_column(DECIMAL(6, 3), server_default=text("0.300")) + system_prompt: Mapped[str | None] = mapped_column(Text, nullable=True) + enabled: Mapped[int] = mapped_column(Integer, server_default=text("0")) + created_by: Mapped[int | None] = mapped_column(ForeignKey("sys_user.id"), nullable=True) + updated_by: Mapped[int | None] = mapped_column(ForeignKey("sys_user.id"), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) diff --git a/backend/app/models/planning.py b/backend/app/models/planning.py new file mode 100644 index 0000000..3480907 --- /dev/null +++ b/backend/app/models/planning.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from sqlalchemy import BigInteger, DateTime, DECIMAL, ForeignKey, String, text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class MaterialDemand(Base): + __tablename__ = "mrp_material_demand" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + demand_no: Mapped[str] = mapped_column(String(50)) + sales_order_item_id: Mapped[int] = mapped_column(ForeignKey("so_sales_order_item.id")) + product_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + material_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + bom_item_id: Mapped[int] = mapped_column(ForeignKey("md_bom_item.id")) + order_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + theoretical_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + planned_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + available_stock_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + shortage_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + suggested_purchase_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + suggested_purchase_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + status: Mapped[str] = mapped_column(String(32)) + generated_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) diff --git a/backend/app/models/sales.py b/backend/app/models/sales.py new file mode 100644 index 0000000..da01566 --- /dev/null +++ b/backend/app/models/sales.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from sqlalchemy import BigInteger, Date, DateTime, DECIMAL, ForeignKey, Integer, String, text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class Customer(Base): + __tablename__ = "md_customer" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + customer_code: Mapped[str] = mapped_column(String(50)) + customer_name: Mapped[str] = mapped_column(String(200)) + short_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + contact_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + contact_phone: Mapped[str | None] = mapped_column(String(50), nullable=True) + address: Mapped[str | None] = mapped_column(String(255), nullable=True) + tax_no: Mapped[str | None] = mapped_column(String(100), nullable=True) + credit_days: Mapped[int] = mapped_column(server_default=text("0")) + status: Mapped[str] = mapped_column(String(32)) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class SalesOrder(Base): + __tablename__ = "so_sales_order" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + order_no: Mapped[str] = mapped_column(String(50)) + customer_id: Mapped[int] = mapped_column(ForeignKey("md_customer.id")) + order_date: Mapped[object] = mapped_column(Date) + promised_date: Mapped[object | None] = mapped_column(Date, nullable=True) + sales_employee_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + delivery_address: Mapped[str | None] = mapped_column(String(255), nullable=True) + tax_rate: Mapped[float] = mapped_column(DECIMAL(8, 4), server_default=text("0")) + total_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + status: Mapped[str] = mapped_column(String(32)) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class SalesOrderItem(Base): + __tablename__ = "so_sales_order_item" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + sales_order_id: Mapped[int] = mapped_column(ForeignKey("so_sales_order.id")) + line_no: Mapped[int] = mapped_column(Integer) + product_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + customer_part_no: Mapped[str | None] = mapped_column(String(100), nullable=True) + order_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + delivered_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + unit_price: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0")) + line_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + promised_date: Mapped[object | None] = mapped_column(Date, nullable=True) + status: Mapped[str] = mapped_column(String(32)) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/schemas/dashboard.py b/backend/app/schemas/dashboard.py new file mode 100644 index 0000000..8c71e0d --- /dev/null +++ b/backend/app/schemas/dashboard.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from datetime import date, datetime +from typing import Any + +from pydantic import BaseModel, Field + + +class OverviewMetric(BaseModel): + code: str + label: str + value: str + unit: str | None = None + highlight: str = "normal" + note: str | None = None + + +class AlertItem(BaseModel): + level: str + title: str + description: str + owner: str + action: str + + +class FlowStep(BaseModel): + step_code: str + title: str + status: str + summary: str + quantity: str + + +class BoardSection(BaseModel): + title: str + items: list[FlowStep] + + +class DashboardPayload(BaseModel): + company: str + overview: list[OverviewMetric] + alerts: list[AlertItem] + flow_board: list[BoardSection] + + +class DashboardSalesOrderRead(BaseModel): + order_id: int + order_no: str + customer_code: str | None = None + customer_name: str + order_date: date + promised_date: date | None = None + delivery_address: str | None = None + total_amount: float + status: str + line_count: int + total_order_qty: float + total_delivered_qty: float + mrp_count: int + purchase_order_count: int + work_order_count: int + completion_receipt_count: int + delivery_count: int + return_count: int + updated_at: datetime | None = None + + +class DashboardMaterialLotRead(BaseModel): + lot_id: int + lot_no: str + material_item_id: int + material_code: str + material_name: str + specification: str | None = None + warehouse_name: str + inbound_weight_kg: float + remaining_weight_kg: float + unit_cost: float + inbound_amount: float + issued_batch_count: int + issued_weight_kg: float + finished_batch_count: int + finished_qty: float + delivery_count: int + status: str + quality_status: str + created_at: datetime | None = None + + +class LifecycleDetailItem(BaseModel): + label: str + value: str + + +class LifecycleNode(BaseModel): + id: str + type: str + title: str + subtitle: str | None = None + status: str | None = None + stage: str + level: int + tone: str = "normal" + summary: str | None = None + metrics: list[LifecycleDetailItem] = Field(default_factory=list) + details: list[LifecycleDetailItem] = Field(default_factory=list) + raw: dict[str, Any] = Field(default_factory=dict) + + +class LifecycleEdge(BaseModel): + id: str + source: str + target: str + label: str | None = None + + +class SalesOrderLifecycleRead(BaseModel): + order: DashboardSalesOrderRead + nodes: list[LifecycleNode] + edges: list[LifecycleEdge] + + +class MaterialLotLifecycleRead(BaseModel): + lot: DashboardMaterialLotRead + nodes: list[LifecycleNode] + edges: list[LifecycleEdge] diff --git a/backend/app/schemas/database.py b/backend/app/schemas/database.py new file mode 100644 index 0000000..93bf389 --- /dev/null +++ b/backend/app/schemas/database.py @@ -0,0 +1,644 @@ +from __future__ import annotations + +from datetime import date, datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class DataCountItem(BaseModel): + code: str + label: str + count: int + + +class BroadcastMessageCreate(BaseModel): + title: str = Field(min_length=1, max_length=100) + content: str = Field(min_length=1, max_length=500) + attendance_point_names: list[str] = Field(default_factory=list) + tone: str = "INFO" + status: str = "ACTIVE" + starts_at: datetime | None = None + ends_at: datetime | None = None + sort_no: int = 0 + + +class BroadcastMessageRead(BroadcastMessageCreate): + broadcast_id: int + created_by: int | str | None = None + created_at: datetime + updated_at: datetime + + +class AiAssistantConfigUpdate(BaseModel): + assistant_name: str = Field(default="嘉恒工艺助手", min_length=1, max_length=100) + provider_name: str | None = None + api_base_url: str | None = None + api_key: str | None = None + model_name: str | None = None + temperature: float = Field(default=0.3, ge=0, le=2) + system_prompt: str | None = None + enabled: bool = False + + +class AiAssistantConfigRead(AiAssistantConfigUpdate): + config_id: int | None = None + api_key_configured: bool = False + updated_at: datetime | None = None + + +class SystemConfigUpdate(BaseModel): + config_value: str = Field(min_length=1, max_length=500) + remark: str | None = Field(default=None, max_length=500) + + +class SystemConfigRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + config_code: str + config_name: str + config_value: str + remark: str | None = None + updated_at: datetime | None = None + + +class SmartOperationReportConfigUpdate(BaseModel): + enabled: bool + remark: str | None = Field(default=None, max_length=500) + + +class SmartOperationReportConfigRead(BaseModel): + config_code: str = "SMART_OPERATION_REPORT_ENABLED" + config_name: str = "对接智能报工小程序" + enabled: bool = True + config_value: str = "开启" + remark: str | None = None + updated_at: datetime | None = None + + +class LoginUser(BaseModel): + user_id: int + username: str + employee_id: int + mobile: str | None = None + display_name: str + title: str + dept_name: str | None = None + role_codes: list[str] + role_names: list[str] + permission_codes: list[str] + + +class AuthLoginRequest(BaseModel): + username: str + password: str + + +class AuthChangePasswordRequest(BaseModel): + old_password: str = Field(min_length=1) + new_password: str = Field(min_length=6, max_length=128) + + +class AuthResetPasswordRequest(BaseModel): + username: str = Field(min_length=1, max_length=50) + new_password: str = Field(min_length=6, max_length=128) + + +class AuthSession(LoginUser): + access_token: str + token_type: str + expires_at: datetime + + +class DepartmentRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + dept_code: str + dept_name: str + dept_type: str + manager_name: str | None = None + status: str + + +class EmployeeRead(BaseModel): + id: int + employee_code: str + employee_name: str + dept_id: int + dept_name: str | None = None + mobile: str | None = None + gender: str | None = None + hire_date: date | None = None + job_title: str | None = None + shift_code: str | None = None + is_operator: bool + is_workshop_staff: bool + status: str + remark: str | None = None + username: str | None = None + role_names: list[str] = Field(default_factory=list) + + +class EmployeeCreate(BaseModel): + employee_name: str + dept_id: int = Field(gt=0) + mobile: str | None = None + gender: str | None = None + hire_date: date | None = None + job_title: str | None = None + shift_code: str | None = None + manager_employee_id: int | None = None + is_operator: bool = False + is_workshop_staff: bool = False + status: str = "ACTIVE" + remark: str | None = None + + +class EmployeeUpdate(BaseModel): + employee_name: str + dept_id: int = Field(gt=0) + mobile: str | None = None + gender: str | None = None + hire_date: date | None = None + job_title: str | None = None + shift_code: str | None = None + manager_employee_id: int | None = None + is_operator: bool = False + is_workshop_staff: bool = False + status: str = "ACTIVE" + remark: str | None = None + + +class MiniAppPersonnelRead(BaseModel): + phone: str + name: str + role: str | None = None + role_name: str | None = None + roles: list[str] = Field(default_factory=list) + role_names: list[str] = Field(default_factory=list) + attendance_point_names: list[str] = Field(default_factory=list) + is_temporary: bool = False + temporary_expires_at: datetime | None = None + temporary_expired: bool = False + created_at: datetime | None = None + updated_at: datetime | None = None + + +class MiniAppPersonnelUpsert(BaseModel): + phone: str = Field(min_length=1, max_length=20) + name: str = Field(min_length=1, max_length=64) + roles: list[str] = Field(default_factory=lambda: ["worker"]) + attendance_point_names: list[str] = Field(default_factory=list) + is_temporary: bool = False + temporary_expires_at: datetime | None = None + + +class MiniAppAttendancePointRead(BaseModel): + name: str + latitude: float | None = None + longitude: float | None = None + radius_meters: int = 500 + remark: str | None = None + is_active: bool = True + created_at: datetime | None = None + updated_at: datetime | None = None + + +class MiniAppProductRead(BaseModel): + attendance_point_name: str + project_no: str + product_name: str + profile_no: str | None = None + material_code: str | None = None + material_name: str | None = None + supplier: str | None = None + product_net_weight_kg: float | None = None + product_gross_weight_kg: float | None = None + allowed_scrap_rate: float | None = None + scrap_loss_rate: float | None = None + waste_price_yuan_per_kg: float | None = None + device_no: str + process_name: str | None = None + stamping_method: str | None = None + operator_count: float = 1 + standard_beat: float = 0 + standard_workload: float = 0 + display_name: str + created_at: datetime | None = None + updated_at: datetime | None = None + + +class MiniAppProductUpsert(BaseModel): + attendance_point_name: str = Field(min_length=1, max_length=128) + project_no: str = Field(min_length=1, max_length=64) + product_name: str = Field(min_length=1, max_length=255) + profile_no: str | None = None + material_code: str | None = None + material_name: str | None = None + supplier: str | None = None + product_net_weight_kg: float | None = Field(default=None, ge=0) + product_gross_weight_kg: float | None = Field(default=None, ge=0) + allowed_scrap_rate: float | None = Field(default=None, ge=0) + scrap_loss_rate: float | None = Field(default=None, ge=0) + waste_price_yuan_per_kg: float | None = Field(default=None, ge=0) + device_no: str = Field(default="", max_length=64) + process_name: str = Field(default="", max_length=128) + stamping_method: str | None = None + operator_count: float = Field(default=1, ge=0) + standard_beat: float = Field(default=0, ge=0) + standard_workload: float = Field(default=0, ge=0) + original_attendance_point_name: str | None = None + original_project_no: str | None = None + original_product_name: str | None = None + original_device_no: str | None = None + original_process_name: str | None = None + + +class MiniAppProductImportResult(BaseModel): + imported: int = 0 + updated: int = 0 + skipped: int = 0 + errors: list[str] = Field(default_factory=list) + material_sync: dict[str, object] = Field(default_factory=dict) + product_specs: dict[str, int] = Field(default_factory=dict) + message: str + + +class MiniAppMaterialWastePriceSync(BaseModel): + material_code: str | None = None + material_name: str | None = None + waste_price_yuan_per_kg: float = Field(default=0, ge=0) + + +class WarehouseRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + warehouse_code: str + warehouse_name: str + warehouse_type: str + status: str + + +class ItemRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + item_code: str + item_name: str + item_type: str + specification: str | None = None + material_grade: str | None = None + unit_weight_kg: float + safety_stock_weight_kg: float + status: str + + +class MaterialRead(BaseModel): + item_id: int + item_code: str + item_name: str + specification: str | None = None + material_grade: str | None = None + unit_weight_kg: float = 0 + safety_stock_weight_kg: float = 0 + scrap_sale_price: float = 0 + default_supplier_id: int | None = None + supplier_name: str | None = None + purchase_calc_mode: str + min_purchase_qty: float + purchase_multiple_qty: float + purchase_multiple_weight_kg: float + lead_time_days: int + quality_rule: str | None = None + remark: str | None = None + status: str = "ACTIVE" + + +class ProductRead(BaseModel): + item_id: int + item_code: str + item_name: str + specification: str | None = None + drawing_no: str | None = None + product_family: str | None = None + version_no: str + unit_weight_kg: float + allowed_scrap_rate: float + standard_output_qty: float = 0 + quality_standard: str | None = None + safety_stock_weight_kg: float = 0 + status: str + + +class ProductCreate(BaseModel): + item_code: str | None = None + item_name: str + specification: str | None = None + drawing_no: str | None = None + product_family: str | None = None + version_no: str = "V1.0" + unit_weight_kg: float = Field(gt=0) + allowed_scrap_rate: float = Field(default=0, ge=0) + standard_output_qty: float = Field(default=0, ge=0) + quality_standard: str | None = None + safety_stock_weight_kg: float = Field(default=0, ge=0) + status: str = "ACTIVE" + + +class ProductUpdate(BaseModel): + item_code: str | None = None + item_name: str + specification: str | None = None + drawing_no: str | None = None + product_family: str | None = None + version_no: str + unit_weight_kg: float = Field(gt=0) + allowed_scrap_rate: float = Field(default=0, ge=0) + standard_output_qty: float = Field(default=0, ge=0) + quality_standard: str | None = None + safety_stock_weight_kg: float = Field(default=0, ge=0) + status: str = "ACTIVE" + + +class MaterialCreate(BaseModel): + item_code: str | None = None + item_name: str + specification: str + material_grade: str + unit_weight_kg: float = Field(gt=0) + safety_stock_weight_kg: float = Field(default=0, ge=0) + scrap_sale_price: float = Field(default=0, ge=0) + default_supplier_id: int | None = None + purchase_calc_mode: str = "BY_WEIGHT" + min_purchase_qty: float = Field(default=0, ge=0) + purchase_multiple_qty: float = Field(default=0, ge=0) + purchase_multiple_weight_kg: float = Field(default=0, ge=0) + lead_time_days: int = Field(default=0, ge=0) + quality_rule: str | None = None + remark: str | None = None + status: str = "ACTIVE" + + +class MaterialUpdate(MaterialCreate): + pass + + +class BomItemPayload(BaseModel): + seq_no: int = Field(gt=0) + material_item_id: int = Field(gt=0) + usage_type: str = "WEIGHT" + qty_per_piece: float = Field(default=1, gt=0) + net_weight_per_piece_kg: float = Field(default=0, ge=0) + gross_weight_per_piece_kg: float = Field(default=0, ge=0) + loss_rate: float = Field(default=0, ge=0) + issue_over_tolerance_rate: float = Field(default=0, ge=0) + remark: str | None = None + + +class BomItemRead(BomItemPayload): + bom_item_id: int + material_code: str + material_name: str + seq_no: int + material_item_id: int + usage_type: str + qty_per_piece: float + net_weight_per_piece_kg: float + gross_weight_per_piece_kg: float + loss_rate: float + issue_over_tolerance_rate: float + remark: str | None = None + + +class BomRead(BaseModel): + bom_id: int + bom_code: str + product_item_id: int + product_code: str + product_name: str + version_no: str + is_default: bool + status: str + effective_date: date | None = None + expire_date: date | None = None + remark: str | None = None + item_count: int = 0 + items: list[BomItemRead] = Field(default_factory=list) + + +class BomUpsert(BaseModel): + bom_code: str | None = None + product_item_id: int + version_no: str = "V1.0" + is_default: bool = True + status: str = "ACTIVE" + effective_date: date | None = None + expire_date: date | None = None + remark: str | None = None + items: list[BomItemPayload] + + +class ProcessRead(BaseModel): + process_id: int + process_code: str + process_name: str + process_type: str + is_report_required: bool + is_quality_gate: bool + status: str + + +class RouteOperationPayload(BaseModel): + seq_no: int = Field(gt=0) + process_id: int = Field(gt=0) + work_center_id: int = Field(gt=0) + operation_name: str + std_setup_minutes: float = Field(default=0, ge=0) + std_cycle_seconds: float = Field(default=0, ge=0) + std_labor_cost: float = Field(default=0, ge=0) + std_machine_cost: float = Field(default=0, ge=0) + std_outsource_cost: float = Field(default=0, ge=0) + allowed_scrap_rate: float = Field(default=0, ge=0) + report_required: bool = True + is_key_operation: bool = False + status: str = "ACTIVE" + remark: str | None = None + + +class RouteOperationRead(RouteOperationPayload): + route_operation_id: int + process_code: str + process_name: str + stamping_method: str | None = None + work_center_code: str + work_center_name: str + seq_no: int + process_id: int + work_center_id: int + operation_name: str + std_setup_minutes: float + std_cycle_seconds: float + std_labor_cost: float + std_machine_cost: float + std_outsource_cost: float + allowed_scrap_rate: float + report_required: bool + is_key_operation: bool + status: str + remark: str | None = None + + +class ProcessRouteRead(BaseModel): + route_id: int + route_code: str + route_name: str + product_item_id: int + product_code: str + product_name: str + version_no: str + is_default: bool + status: str + effective_date: date | None = None + expire_date: date | None = None + remark: str | None = None + operation_count: int = 0 + operations: list[RouteOperationRead] = Field(default_factory=list) + + +class ProcessRouteUpsert(BaseModel): + route_code: str | None = None + route_name: str + product_item_id: int + version_no: str = "V1.0" + is_default: bool = True + status: str = "ACTIVE" + effective_date: date | None = None + expire_date: date | None = None + remark: str | None = None + operations: list[RouteOperationPayload] + + +class SalesOrderRead(BaseModel): + order_id: int + order_no: str + customer_id: int + customer_code: str + customer_name: str + sales_employee_id: int | None = None + sales_employee_name: str | None = None + order_date: date + promised_date: date | None = None + delivery_address: str | None = None + total_amount: float + status: str + line_count: int + mrp_count: int = 0 + purchase_order_count: int = 0 + delivery_count: int = 0 + delivery_item_count: int = 0 + work_order_count: int = 0 + can_delete: bool = False + archive_status: str = "未生成" + archive_version: int | None = None + archive_created_at: datetime | None = None + archive_error_message: str | None = None + + +class CustomerRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + customer_code: str + customer_name: str + short_name: str | None = None + contact_name: str | None = None + contact_phone: str | None = None + contacts: list[dict[str, str]] = Field(default_factory=list, max_length=3) + address: str | None = None + credit_days: int = 0 + status: str + + +class CustomerCreate(BaseModel): + customer_name: str + short_name: str | None = None + contact_name: str | None = None + contact_phone: str | None = None + contacts: list[dict[str, str]] = Field(default_factory=list, max_length=3) + address: str | None = None + tax_no: str | None = None + credit_days: int = 30 + + +class SalesOrderItemCreate(BaseModel): + product_item_id: int + order_qty: float + unit_price: float + promised_date: date | None = None + customer_part_no: str | None = None + + +class SalesOrderCreate(BaseModel): + customer_id: int + sales_employee_id: int | None = None + promised_date: date | None = None + delivery_address: str | None = None + tax_rate: float = 0 + remark: str | None = None + items: list[SalesOrderItemCreate] + + +class SalesOrderItemRead(BaseModel): + sales_order_item_id: int + sales_order_id: int + order_no: str + line_no: int + product_item_id: int + product_code: str + product_name: str + customer_part_no: str | None = None + order_qty: float + delivered_qty: float + unit_price: float + line_amount: float + promised_date: date | None = None + status: str + + +class SalesOrderCreateResult(BaseModel): + order_id: int + order_no: str + total_amount: float + item_count: int + status: str + archive_status: str = "未生成" + archive_version: int | None = None + archive_error_message: str | None = None + + +class MaterialDemandRead(BaseModel): + demand_id: int + demand_no: str + order_no: str + material_item_id: int + product_code: str + product_name: str + material_code: str + material_name: str + purchase_calc_mode: str + order_qty: float + theoretical_weight_kg: float + planned_weight_kg: float + shortage_weight_kg: float + suggested_purchase_qty: float + suggested_purchase_weight_kg: float + status: str + generated_at: datetime | None = None + + +class MrpGenerationResult(BaseModel): + sales_order_id: int + order_no: str + generated_count: int + demand_rows: list[MaterialDemandRead] diff --git a/backend/app/schemas/document_archives.py b/backend/app/schemas/document_archives.py new file mode 100644 index 0000000..0e8ec6b --- /dev/null +++ b/backend/app/schemas/document_archives.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class DocumentArchiveRead(BaseModel): + archive_id: int + document_type: str + business_id: int + document_no: str + archive_version: int + template_version: str + file_format: str + file_name: str + status: str + error_message: str | None = None + created_at: datetime + + +class DocumentArchiveGenerateResult(BaseModel): + business_id: int + document_type: str + document_no: str + archive_status: str + archive_version: int | None = None + archive_error_message: str | None = None + + +class DocumentArchiveBatchDownloadRequest(BaseModel): + model_config = ConfigDict(validate_default=True) + + document_type: str + business_ids: list[int] = Field(default_factory=list, min_length=1) diff --git a/backend/app/schemas/domain.py b/backend/app/schemas/domain.py new file mode 100644 index 0000000..5fedc2e --- /dev/null +++ b/backend/app/schemas/domain.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from pydantic import BaseModel + + +class ProductSummary(BaseModel): + product_code: str + product_name: str + material_name: str + order_qty: int + unit_weight_kg: float + gross_material_kg_per_piece: float + planned_material_weight_kg: float + suggested_purchase_weight_kg: float + purchase_multiple_weight_kg: float + + +class OperationCostLine(BaseModel): + seq_no: int + operation_name: str + work_center: str + std_labor_cost: float + std_machine_cost: float + allowed_scrap_rate: float + reported_good_qty: int + reported_scrap_qty: int + abnormal: bool + + +class InventoryLotSummary(BaseModel): + lot_no: str + item_name: str + warehouse: str + location: str + inbound_weight_kg: float + remaining_weight_kg: float + status: str + + +class MiniAppField(BaseModel): + field_name: str + label: str + required: bool + description: str + + +class CostInsight(BaseModel): + category: str + amount: float + ratio: float + note: str + diff --git a/backend/app/schemas/operations.py b/backend/app/schemas/operations.py new file mode 100644 index 0000000..2aeb0f4 --- /dev/null +++ b/backend/app/schemas/operations.py @@ -0,0 +1,1756 @@ +from __future__ import annotations + +from datetime import date, datetime +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict, Field + + +class ContactPayload(BaseModel): + contact_name: str | None = None + contact_phone: str | None = None + + +class SupplierRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + supplier_code: str + supplier_name: str + short_name: str | None = None + contact_name: str | None = None + contact_phone: str | None = None + contacts: list[ContactPayload] = Field(default_factory=list, max_length=3) + address: str | None = None + lead_time_days: int + default_tax_rate: float + status: str + remark: str | None = None + + +class SupplierCreate(BaseModel): + supplier_name: str + short_name: str | None = None + contact_name: str | None = None + contact_phone: str | None = None + contacts: list[ContactPayload] = Field(default_factory=list, max_length=3) + address: str | None = None + lead_time_days: int = 0 + default_tax_rate: float = 0 + status: str = "ACTIVE" + remark: str | None = None + + +class SupplierUpdate(SupplierCreate): + pass + + +class WorkCenterRead(BaseModel): + id: int + center_code: str + center_name: str + dept_id: int + dept_name: str | None = None + center_type: str + shift_pattern: str | None = None + capacity_hours_per_day: float + status: str + + +class WarehouseLocationRead(BaseModel): + id: int + warehouse_id: int + warehouse_code: str + warehouse_name: str + location_code: str + location_name: str + zone_name: str | None = None + is_default: bool + is_locked: bool + status: str + + +class PurchaseOrderItemCreate(BaseModel): + material_item_id: int + source_demand_id: int | None = None + order_qty: float = 0 + order_weight_kg: float = 0 + unit_price: float = 0 + remark: str | None = None + + +class PurchaseOrderCreate(BaseModel): + supplier_id: int + order_date: date | None = None + expected_date: date | None = None + warning_lead_days: int = Field(default=0, ge=0) + purchaser_employee_id: int | None = None + target_warehouse_type: str = "RAW" + sales_order_ids: list[int] = Field(default_factory=list) + tax_rate: float = 0 + remark: str | None = None + items: list[PurchaseOrderItemCreate] + + +class PurchaseOrderUpdate(PurchaseOrderCreate): + pass + + +class PurchaseOrderRead(BaseModel): + purchase_order_id: int + po_no: str + source_type: str = "STOCKING" + source_order_nos: str | None = None + sales_order_ids: list[int] = Field(default_factory=list) + supplier_id: int + supplier_code: str + supplier_name: str + order_date: date + expected_date: date | None = None + warning_lead_days: int = 0 + warning_start_date: date | None = None + is_warning_active: bool = False + purchaser_employee_id: int | None = None + purchaser_name: str | None = None + target_warehouse_type: str = "RAW" + tax_rate: float + total_amount: float + status: str + display_status: str | None = None + remark: str | None = None + line_count: int + total_order_qty: float = 0 + total_order_weight_kg: float + total_received_qty: float = 0 + total_received_weight_kg: float + has_return: bool = False + total_return_weight_kg: float = 0 + archive_status: str = "未生成" + archive_version: int | None = None + archive_created_at: datetime | None = None + archive_error_message: str | None = None + + +class PurchaseOrderItemRead(BaseModel): + purchase_order_item_id: int + purchase_order_id: int + po_no: str + line_no: int + material_item_id: int + material_code: str + material_name: str + specification: str | None = None + material_grade: str | None = None + performance_requirement: str | None = None + purchase_calc_mode: str | None = None + source_demand_id: int | None = None + order_qty: float + order_weight_kg: float + received_qty: float + received_weight_kg: float + returned_weight_kg: float = 0 + net_received_weight_kg: float = 0 + has_return: bool = False + unit_price: float + line_amount: float + status: str + display_status: str | None = None + remark: str | None = None + + +class PurchaseReceiptItemCreate(BaseModel): + purchase_order_item_id: int + material_item_id: int + location_id: int | None = None + lot_no: str | None = None + received_qty: float = 0 + received_weight_kg: float = 0 + unit_cost: float = 0 + remark: str | None = None + + +class PurchaseReceiptCreate(BaseModel): + purchase_order_id: int + warehouse_id: int + receipt_date: datetime | None = None + receiver_employee_id: int | None = None + supplier_delivery_no: str | None = None + waybill_no: str | None = None + order_photo_url: str | None = None + freight_amount: float | None = None + remark: str | None = None + items: list[PurchaseReceiptItemCreate] + + +class PurchaseReceiptRead(BaseModel): + receipt_id: int + receipt_no: str + purchase_order_id: int + po_no: str + supplier_id: int + supplier_name: str + warehouse_id: int + warehouse_name: str + target_warehouse_type: str = "RAW" + receipt_date: datetime + receiver_employee_id: int | None = None + receiver_name: str | None = None + supplier_delivery_no: str | None = None + logistics_waybill_no: str | None = None + logistics_freight_amount: float | None = None + logistics_photo_url: str | None = None + material_names: str | None = None + status: str + remark: str | None = None + line_count: int + total_received_qty: float = 0 + total_received_weight_kg: float + archive_status: str = "未生成" + archive_version: int | None = None + archive_created_at: datetime | None = None + archive_error_message: str | None = None + + +class PurchaseReceiptItemRead(BaseModel): + receipt_item_id: int + receipt_id: int + receipt_no: str + line_no: int + purchase_order_item_id: int + material_item_id: int + material_code: str + material_name: str + warehouse_id: int + warehouse_name: str + location_id: int | None = None + location_name: str | None = None + lot_no: str + received_qty: float + received_weight_kg: float + accepted_qty: float + accepted_weight_kg: float + unit_cost: float + status: str + quality_status: str | None = None + remark: str | None = None + + +class StockLotPurchaseLinkRead(BaseModel): + purchase_order_id: int + po_no: str + purchase_order_item_id: int + material_item_id: int + material_code: str + material_name: str + lot_id: int + lot_no: str + accepted_weight_kg: float + returned_weight_kg: float + returnable_weight_kg: float + unit_cost: float + + +class RawMaterialReturnOutboundItemCreate(BaseModel): + lot_id: int + purchase_order_item_id: int + return_weight_kg: float + reason: str | None = None + remark: str | None = None + + +class RawMaterialReturnOutboundCreate(BaseModel): + warehouse_id: int + return_date: datetime | None = None + waybill_no: str | None = None + order_photo_url: str | None = None + freight_amount: float | None = None + reason: str | None = None + remark: str | None = None + items: list[RawMaterialReturnOutboundItemCreate] + + +class PurchaseReturnItemRead(BaseModel): + purchase_return_item_id: int + purchase_return_id: int + line_no: int + purchase_order_item_id: int + material_item_id: int + material_code: str + material_name: str + lot_id: int + lot_no: str + return_weight_kg: float + unit_cost: float + amount: float + reason: str | None = None + status: str + remark: str | None = None + + +class PurchaseReturnRead(BaseModel): + purchase_return_id: int + return_no: str + purchase_order_id: int + po_no: str + warehouse_id: int + warehouse_name: str + return_date: datetime + logistics_waybill_no: str | None = None + logistics_freight_amount: float | None = None + logistics_photo_url: str | None = None + reason: str | None = None + status: str + remark: str | None = None + total_return_weight_kg: float + total_amount: float + items: list[PurchaseReturnItemRead] = Field(default_factory=list) + archive_status: str | None = None + archive_business_id: int | None = None + archive_document_type: str | None = None + archive_error_message: str | None = None + + +class StockLotRead(BaseModel): + lot_id: int + lot_no: str + parent_lot_id: int | None = None + lot_role: str | None = None + material_sub_batch_no: str | None = None + item_id: int + item_code: str + item_name: str + item_type: str + specification: str | None = None + item_remark: str | None = None + stock_unit_name: str | None = None + purchase_unit_name: str | None = None + sales_unit_name: str | None = None + warehouse_id: int + warehouse_name: str + warehouse_type: str | None = None + location_id: int | None = None + location_name: str | None = None + source_doc_type: str + source_doc_id: int + source_line_id: int | None = None + source_receipt_no: str | None = None + source_purchase_order_no: str | None = None + source_material_lot_id: int | None = None + source_material_lot_no: str | None = None + source_material_sub_batch_no: str | None = None + source_material_summary: str | None = None + logistics_waybill_no: str | None = None + logistics_freight_amount: float | None = None + logistics_photo_url: str | None = None + inbound_qty: float + inbound_weight_kg: float + remaining_qty: float + remaining_weight_kg: float + locked_qty: float + locked_weight_kg: float + unit_cost: float + quality_status: str + quality_user_name: str | None = None + status: str + created_at: datetime + archive_status: str | None = None + archive_business_id: int | None = None + archive_document_type: str | None = None + archive_error_message: str | None = None + + +class StockLotOptionRead(BaseModel): + lot_id: int + lot_no: str + item_id: int + item_code: str + item_name: str + warehouse_id: int + warehouse_name: str + remaining_weight_kg: float + unit_cost: float + quality_status: str + status: str + is_available_for_issue: bool + disabled_reason: str | None = None + + +class StockBalanceRead(BaseModel): + stock_balance_id: int + item_id: int + item_code: str + item_name: str + item_type: str + specification: str | None = None + item_remark: str | None = None + stock_unit_name: str | None = None + purchase_unit_name: str | None = None + sales_unit_name: str | None = None + warehouse_id: int + warehouse_name: str + warehouse_type: str | None = None + location_id: int | None = None + location_name: str | None = None + qty_on_hand: float + weight_on_hand_kg: float + qty_available: float + weight_available_kg: float + qty_allocated: float + weight_allocated_kg: float + avg_unit_cost: float + updated_at: datetime + + +class InventoryTxnRead(BaseModel): + inventory_txn_id: int + txn_no: str + txn_type: str + item_id: int + item_code: str + item_name: str + warehouse_id: int + warehouse_name: str + warehouse_type: str | None = None + location_id: int | None = None + location_name: str | None = None + lot_id: int | None = None + lot_no: str | None = None + qty_change: float + weight_change_kg: float + unit_cost: float + amount: float + source_doc_type: str + source_doc_id: int + source_line_id: int | None = None + source_material_lot_id: int | None = None + source_material_lot_no: str | None = None + source_material_sub_batch_no: str | None = None + source_material_summary: str | None = None + biz_time: datetime + logistics_waybill_no: str | None = None + logistics_freight_amount: float | None = None + logistics_photo_url: str | None = None + remark: str | None = None + archive_status: str = "未生成" + archive_business_id: int | None = None + archive_document_type: str | None = None + archive_error_message: str | None = None + + +class InventoryTxnLedgerRowRead(InventoryTxnRead): + direction: str + source_line_id: int | None = None + operator_user_id: int | None = None + operator_name: str | None = None + + +class InventoryTxnLedgerSummaryRead(BaseModel): + in_count: int = 0 + out_count: int = 0 + adjust_count: int = 0 + in_qty: float = 0 + out_qty: float = 0 + in_weight_kg: float = 0 + out_weight_kg: float = 0 + latest_biz_time: datetime | None = None + + +class InventoryTxnLedgerResponse(BaseModel): + items: list[InventoryTxnLedgerRowRead] + total: int + summary: InventoryTxnLedgerSummaryRead + + +class SelectedStockLotIssueCreate(BaseModel): + source_lot_id: int + issued_weight_kg: float + material_item_id: int | None = None + + +class WorkOrderCreate(BaseModel): + source_sales_order_item_id: int | None = None + product_item_id: int + planned_qty: float = 0 + issue_weight_kg: float = 0 + selected_stock_lots: list[SelectedStockLotIssueCreate] = Field(default_factory=list) + planned_start_time: datetime | None = None + planned_end_time: datetime | None = None + priority_level: int = 3 + auto_issue_materials: bool = False + remark: str | None = None + + +class WorkOrderRead(BaseModel): + work_order_id: int + work_order_no: str + work_order_type: str + source_sales_order_item_id: int | None = None + source_order_no: str | None = None + product_item_id: int + product_code: str + product_name: str + product_version_no: str | None = None + route_id: int + route_name: str + bom_id: int + planned_qty: float + released_qty: float + finished_qty: float + scrap_qty: float + planned_start_time: datetime | None = None + planned_end_time: datetime | None = None + actual_start_time: datetime | None = None + actual_end_time: datetime | None = None + priority_level: int + status: str + remark: str | None = None + operation_count: int + abnormal_operation_count: int + reported_good_qty: float = 0 + received_qty: float = 0 + max_receivable_qty: float = 0 + all_operations_done: bool = False + archive_status: str | None = None + archive_business_id: int | None = None + archive_document_type: str | None = None + archive_error_message: str | None = None + + +class WorkOrderMaterialRead(BaseModel): + work_order_material_id: int + work_order_id: int + work_order_no: str + bom_item_id: int + material_item_id: int + material_code: str + material_name: str + required_qty: float + required_weight_kg: float + issued_qty: float + issued_weight_kg: float + returned_qty: float + returned_weight_kg: float + material_sub_batch_summary: str | None = None + issue_amount: float = 0 + status: str + remark: str | None = None + + +class WorkOrderMaterialIssueRead(BaseModel): + material_issue_id: int + work_order_material_id: int + work_order_id: int + work_order_no: str + material_item_id: int + material_code: str + material_name: str + source_lot_id: int + source_lot_no: str + material_sub_batch_no: str + issued_qty: float + issued_weight_kg: float + unit_cost: float + amount: float + issue_time: datetime + remark: str | None = None + + +class ProductionBatchLedgerTxnRead(BaseModel): + production_ledger_txn_id: int + production_ledger_id: int + txn_type: str + qty_delta: float = 0 + weight_delta_kg: float = 0 + outside_weight_after_kg: float = 0 + source_doc_type: str | None = None + source_doc_id: int | None = None + source_line_id: int | None = None + biz_time: datetime + operator_user_id: int | None = None + remark: str | None = None + archive_status: str | None = None + archive_business_id: int | None = None + archive_document_type: str | None = None + archive_error_message: str | None = None + + +class ProductionBatchLedgerRead(BaseModel): + production_ledger_id: int + material_lot_id: int + material_lot_no: str + material_item_id: int + material_code: str | None = None + material_name: str | None = None + product_item_id: int + product_code: str | None = None + product_name: str | None = None + status: str + miniapp_selectable: bool = True + total_issued_weight_kg: float = 0 + outside_weight_kg: float = 0 + finished_inbound_qty: float = 0 + surplus_inbound_weight_kg: float = 0 + scrap_inbound_weight_kg: float = 0 + writeoff_weight_kg: float = 0 + first_issue_time: datetime | None = None + last_issue_time: datetime | None = None + locked_at: datetime | None = None + reopened_at: datetime | None = None + lock_count: int = 0 + remark: str | None = None + material_out_archive_status: str | None = None + material_out_archive_business_id: int | None = None + material_out_archive_document_type: str | None = None + material_out_archive_error_message: str | None = None + txns: list[ProductionBatchLedgerTxnRead] = Field(default_factory=list) + + +class ProductionBatchLedgerInboundPreviewRead(BaseModel): + production_ledger_id: int + material_lot_no: str + product_item_id: int + product_code: str | None = None + product_name: str | None = None + material_item_id: int + material_code: str | None = None + material_name: str | None = None + gross_weight_per_piece_kg: float = 0 + net_weight_per_piece_kg: float = 0 + total_issued_weight_kg: float = 0 + outside_weight_kg: float = 0 + finished_inbound_qty: float = 0 + surplus_inbound_weight_kg: float = 0 + scrap_inbound_weight_kg: float = 0 + writeoff_weight_kg: float = 0 + reference_finished_qty: float = 0 + used_material_weight_kg: float = 0 + theoretical_surplus_weight_kg: float = 0 + theoretical_scrap_weight_kg: float = 0 + + +class ProductionBatchLedgerInboundCreate(BaseModel): + production_ledger_id: int + finished_qty: float = 0 + surplus_weight_kg: float = 0 + scrap_weight_kg: float = 0 + lock_material_batch: bool = False + lock_confirmed: bool = False + deviation_remark: str | None = None + remark: str | None = None + + +class ProductionBatchLedgerInboundRead(BaseModel): + production_ledger_id: int + material_lot_no: str + finished_qty: float = 0 + surplus_weight_kg: float = 0 + scrap_weight_kg: float = 0 + finished_lot_id: int | None = None + surplus_lot_id: int | None = None + scrap_lot_id: int | None = None + finished_txn_id: int | None = None + surplus_txn_id: int | None = None + scrap_txn_id: int | None = None + locked: bool = False + status: str + remark: str | None = None + archive_status: str | None = None + archive_business_id: int | None = None + archive_document_type: str | None = None + archive_error_message: str | None = None + + +class WorkOrderOperationRead(BaseModel): + work_order_operation_id: int + work_order_id: int + work_order_no: str + route_operation_id: int + seq_no: int + operation_name: str + work_center_id: int + work_center_name: str + planned_qty: float + report_qty: float + good_qty: float + scrap_qty: float + rework_qty: float + std_labor_cost: float + std_machine_cost: float + std_outsource_cost: float + allowed_scrap_rate: float + is_abnormal: bool + status: str + remark: str | None = None + + +class WorkOrderLedgerMaterialRead(BaseModel): + material_item_id: int + material_code: str + material_name: str + source_lot_id: int + source_lot_no: str + issued_weight_kg: float + returned_weight_kg: float = 0 + unit_cost: float = 0 + amount: float = 0 + + +class WorkOrderLedgerOperationRead(BaseModel): + work_order_operation_id: int + seq_no: int + operation_name: str + report_qty: float + good_qty: float + scrap_qty: float + status: str + + +class WorkOrderLedgerLimitRead(BaseModel): + gross_weight_per_piece_kg: float + net_weight_per_piece_kg: float + issued_weight_kg: float + used_material_weight_kg: float + returned_material_weight_kg: float + final_operation_good_qty: float + finished_received_qty: float + total_operation_scrap_qty: float + scrap_inbound_weight_kg: float + max_finished_inbound_qty: float + max_scrap_inbound_weight_kg: float + max_surplus_inbound_weight_kg: float + tolerance_finished_inbound_qty: float + tolerance_scrap_inbound_weight_kg: float + tolerance_surplus_inbound_weight_kg: float + + +class WorkOrderLedgerRead(BaseModel): + work_order_id: int + work_order_no: str + product_item_id: int + product_code: str + product_name: str + planned_qty: float + status: str + remark: str | None = None + material_summary: str + source_lot_summary: str + issued_weight_kg: float + used_material_weight_kg: float + returned_material_weight_kg: float + finished_received_qty: float + max_finished_inbound_qty: float + max_scrap_inbound_weight_kg: float + max_surplus_inbound_weight_kg: float + limits: WorkOrderLedgerLimitRead + materials: list[WorkOrderLedgerMaterialRead] + operations: list[WorkOrderLedgerOperationRead] + + +class ProductionWorkOrderInboundPreviewRead(BaseModel): + work_order_id: int + work_order_no: str + product_item_id: int + product_code: str + product_name: str + source_lot_id: int + source_lot_no: str + material_item_id: int + material_code: str + material_name: str + gross_weight_per_piece_kg: float + net_weight_per_piece_kg: float + issued_weight_kg: float + returned_weight_kg: float + final_operation_good_qty: float + finished_inbound_qty: float + reported_scrap_qty: float + scrap_inbound_weight_kg: float + reference_finished_qty: float + remaining_reference_finished_qty: float + finished_qty_upper_limit: float + finished_qty: float + theoretical_surplus_weight_kg: float + theoretical_scrap_weight_kg: float + + +class ProductionWorkOrderInboundCreate(BaseModel): + work_order_id: int + finished_qty: float = 0 + surplus_weight_kg: float = 0 + scrap_weight_kg: float = 0 + settle_work_order: bool = False + settle_confirmed: bool = False + deviation_remark: str | None = None + remark: str | None = None + + +class ProductionWorkOrderInboundRead(BaseModel): + work_order_id: int + work_order_no: str + finished_qty: float = 0 + surplus_weight_kg: float = 0 + scrap_weight_kg: float = 0 + finished_lot_id: int | None = None + surplus_lot_id: int | None = None + scrap_lot_id: int | None = None + finished_txn_id: int | None = None + surplus_txn_id: int | None = None + scrap_txn_id: int | None = None + settled: bool = False + status: str + remark: str | None = None + archive_status: str | None = None + archive_business_id: int | None = None + archive_document_type: str | None = None + archive_error_message: str | None = None + + +class OperationReportCreate(BaseModel): + work_order_id: int + work_order_operation_id: int + employee_id: int + equipment_id: int | None = None + report_source: str = "MANUAL" + shift_code: str | None = None + start_time: datetime + end_time: datetime + report_qty: float + good_qty: float + scrap_qty: float = 0 + rework_qty: float = 0 + scrap_reason: str | None = None + downtime_minutes: float = 0 + extra_cost_amount: float = 0 + remark: str | None = None + + +class OperationReportRead(BaseModel): + report_id: int + report_no: str + work_order_id: int + work_order_no: str + work_order_operation_id: int + seq_no: int + operation_name: str + employee_id: int + employee_name: str + equipment_id: int | None = None + equipment_name: str | None = None + report_source: str + shift_code: str | None = None + start_time: datetime + end_time: datetime + report_qty: float + good_qty: float + scrap_qty: float + rework_qty: float + downtime_minutes: float + extra_cost_amount: float + is_abnormal: bool + ref_cycle_seconds: float = 0 + actual_cycle_seconds: float | None = None + abnormal_reason: str | None = None + scrap_reason: str | None = None + remark: str | None = None + + +class MiniAppOperationReportRead(BaseModel): + source_report_id: int + source_item_id: int + report_no: str + attendance_point_name: str = "" + employee_phone: str + employee_name: str + report_date: date + start_time: datetime + end_time: datetime + duration_minutes: float = 0 + effective_minutes: float = 0 + status: str + status_name: str + reviewer_phone: str | None = None + reviewed_at: datetime | None = None + reject_reason: str | None = None + device_no: str + project_no: str + product_name: str + material_code: str | None = None + material_name: str | None = None + raw_material_batch_no: str | None = None + process_name: str | None = None + standard_beat: float = 0 + standard_workload: float = 0 + good_qty: float = 0 + defect_qty: float = 0 + scrap_qty: float = 0 + erp_scrap_qty: float = 0 + allocated_minutes: float = 0 + started_at: datetime | None = None + work_order_id: int | None = None + work_order_no: str | None = None + work_order_operation_id: int | None = None + operation_name: str | None = None + erp_report_id: int | None = None + erp_report_no: str | None = None + is_synced_to_erp: bool = False + abnormal_reason: str | None = None + + +class MiniAppOperationReportSyncRead(BaseModel): + sync_from: datetime | None = None + sync_to: datetime + last_synced_at: datetime + total: int = 0 + synced: int = 0 + skipped: int = 0 + failed: int = 0 + + +class MiniAppOperationReportSyncStateRead(BaseModel): + last_synced_at: datetime | None = None + + +class MiniAppScrapRecordCreate(BaseModel): + source_item_id: int + scrap_qty: float = 0 + scrap_weight_kg: float = 0 + scrap_reason: str | None = None + saleable_scrap_weight_kg: float = 0 + scrap_cost_amount: float = 0 + + +class ScrapRecordCreate(BaseModel): + report_id: int + work_order_id: int + work_order_operation_id: int + item_id: int + scrap_qty: float + scrap_weight_kg: float = 0 + scrap_reason: str | None = None + disposal_type: str = "SCRAP" + saleable_scrap_weight_kg: float = 0 + scrap_cost_amount: float = 0 + + +class ScrapRecordRead(BaseModel): + scrap_record_id: int + scrap_no: str + report_id: int + report_no: str + work_order_id: int + work_order_no: str + work_order_operation_id: int + operation_name: str + item_id: int + item_code: str + item_name: str + scrap_qty: float + scrap_weight_kg: float + scrap_reason: str | None = None + disposal_type: str + saleable_scrap_weight_kg: float + scrap_cost_amount: float + status: str + created_at: datetime + + +class CompletionReceiptItemCreate(BaseModel): + product_item_id: int + location_id: int | None = None + receipt_qty: float + receipt_weight_kg: float = 0 + remark: str | None = None + + +class CompletionReceiptCreate(BaseModel): + work_order_id: int + warehouse_id: int + receipt_time: datetime | None = None + receiver_employee_id: int | None = None + remark: str | None = None + settle_work_order: bool = False + settle_remark: str | None = None + items: list[CompletionReceiptItemCreate] + + +class CompletionReceiptRead(BaseModel): + completion_receipt_id: int + receipt_no: str + work_order_id: int + work_order_no: str + product_item_id: int + product_code: str + product_name: str + warehouse_id: int + warehouse_name: str + receipt_time: datetime + receiver_employee_id: int | None = None + receiver_name: str | None = None + status: str + line_count: int + total_receipt_qty: float + total_receipt_weight_kg: float + remark: str | None = None + + +class CompletionReceiptItemRead(BaseModel): + completion_receipt_item_id: int + completion_receipt_id: int + receipt_no: str + line_no: int + product_item_id: int + product_code: str + product_name: str + warehouse_id: int + warehouse_name: str + location_id: int | None = None + location_name: str | None = None + lot_no: str + source_material_lot_id: int | None = None + source_material_lot_no: str | None = None + source_material_sub_batch_no: str | None = None + source_material_summary: str | None = None + receipt_qty: float + receipt_weight_kg: float + unit_cost: float + status: str + remark: str | None = None + + +class DeliveryItemCreate(BaseModel): + sales_order_item_id: int | None = None + product_item_id: int + lot_id: int | None = None + delivery_qty: float + delivery_weight_kg: float = 0 + unit_price: float = 0 + remark: str | None = None + + +class DeliveryCreate(BaseModel): + sales_order_id: int | None = None + customer_id: int | None = None + warehouse_id: int + delivery_date: datetime | None = None + shipper_employee_id: int | None = None + consignee_name: str | None = None + consignee_phone: str | None = None + delivery_address: str | None = None + waybill_no: str | None = None + order_photo_url: str | None = None + freight_amount: float | None = None + remark: str | None = None + items: list[DeliveryItemCreate] + + +class DeliveryRead(BaseModel): + delivery_id: int + delivery_no: str + sales_order_id: int | None = None + customer_id: int | None = None + order_no: str | None = None + customer_name: str | None = None + warehouse_id: int + warehouse_name: str + delivery_date: datetime + shipper_employee_id: int | None = None + shipper_name: str | None = None + consignee_name: str | None = None + consignee_phone: str | None = None + delivery_address: str | None = None + logistics_waybill_no: str | None = None + logistics_freight_amount: float | None = None + logistics_photo_url: str | None = None + product_names: str | None = None + status: str + line_count: int + total_delivery_qty: float + total_line_amount: float + remark: str | None = None + archive_status: str | None = None + archive_business_id: int | None = None + archive_document_type: str | None = None + archive_error_message: str | None = None + + +class DeliveryItemRead(BaseModel): + delivery_item_id: int + delivery_id: int + delivery_no: str + line_no: int + sales_order_item_id: int | None = None + order_no: str | None = None + product_item_id: int + product_code: str + product_name: str + lot_id: int | None = None + lot_no: str | None = None + source_material_lot_id: int | None = None + source_material_lot_no: str | None = None + source_material_code: str | None = None + source_material_name: str | None = None + source_material_sub_batch_no: str | None = None + source_material_summary: str | None = None + delivery_qty: float + delivery_weight_kg: float + unit_price: float + line_amount: float + status: str + remark: str | None = None + + +class CustomerSuppliedInboundCreate(BaseModel): + material_item_id: int + warehouse_id: int + location_id: int | None = None + provider_name: str | None = None + inbound_weight_kg: float + lot_no: str | None = None + remark: str | None = None + + +class InventorySettlementRead(BaseModel): + settlement_type: str + source_doc_type: str + source_doc_id: int + doc_no: str + counterparty_name: str + biz_date: datetime + amount: float + settled_amount: float = 0 + open_amount: float + status: str + remark: str | None = None + + +class EquipmentCreate(BaseModel): + equipment_name: str + category_name: str | None = None + work_center_id: int + manufacturer: str | None = None + model_no: str | None = None + install_date: date | None = None + rated_power_kw: float = 0 + occupied_area_sqm: float = 0 + maintenance_cycle_hours: float = 0 + next_maintenance_at: datetime | None = None + status: str = "IDLE" + remark: str | None = None + + +class EquipmentRead(BaseModel): + equipment_id: int + equipment_code: str + equipment_name: str + category_name: str | None = None + work_center_id: int + work_center_name: str + manufacturer: str | None = None + model_no: str | None = None + install_date: date | None = None + rated_power_kw: float + occupied_area_sqm: float + maintenance_cycle_hours: float + last_maintenance_at: datetime | None = None + next_maintenance_at: datetime | None = None + status: str + remark: str | None = None + + +class MaintenanceOrderCreate(BaseModel): + equipment_id: int + maintenance_type: str + start_time: datetime | None = None + end_time: datetime | None = None + downtime_minutes: float = 0 + owner_employee_id: int | None = None + result_summary: str | None = None + cost_amount: float = 0 + status: str = "CREATED" + + +class MaintenanceOrderRead(BaseModel): + maintenance_order_id: int + order_no: str + equipment_id: int + equipment_code: str + equipment_name: str + maintenance_type: str + start_time: datetime | None = None + end_time: datetime | None = None + downtime_minutes: float + owner_employee_id: int | None = None + owner_name: str | None = None + result_summary: str | None = None + cost_amount: float + status: str + + +class QualityInspectionRead(BaseModel): + receipt_item_id: int + receipt_id: int + receipt_no: str + po_no: str + supplier_name: str + target_warehouse_type: str = "RAW" + material_item_id: int + material_code: str + material_name: str + warehouse_name: str + location_name: str | None = None + lot_no: str + received_qty: float + received_weight_kg: float + accepted_qty: float + accepted_weight_kg: float + status: str + quality_status: str + inspector_name: str | None = None + receipt_date: datetime + remark: str | None = None + archive_status: str = "未生成" + archive_version: int | None = None + archive_created_at: datetime | None = None + archive_error_message: str | None = None + + +class QualityInspectionAction(BaseModel): + result: str + remark: str | None = None + + +class AccountingPeriodCreate(BaseModel): + year_no: int + month_no: int + start_date: date + end_date: date + close_status: str = "OPEN" + + +class AccountingPeriodRead(BaseModel): + period_id: int + period_code: str + year_no: int + month_no: int + start_date: date + end_date: date + close_status: str + closed_at: datetime | None = None + + +class OverheadEntryCreate(BaseModel): + period_id: int + overhead_type: str + dept_id: int | None = None + amount: float + allocation_basis: str = "WORK_HOURS" + description: str | None = None + + +class OverheadEntryRead(BaseModel): + overhead_entry_id: int + period_id: int + period_code: str + year_no: int + month_no: int + overhead_type: str + dept_id: int | None = None + dept_name: str | None = None + amount: float + allocation_basis: str + description: str | None = None + created_at: datetime + + +class ProductMaterialCostExpenseRead(BaseModel): + key: str + label: str + amount: float + + +class ProductMaterialCostRowRead(BaseModel): + work_order_id: int + work_order_no: str + row_no: int + biz_date: date | None = None + product_code: str + customer_name: str | None = None + product_name: str + supply_mode: str + delivery_note_no: str | None = None + order_qty: float + actual_production_qty: float + shipped_qty: float + inventory_qty: float + inventory_value: float + untaxed_unit_price: float + untaxed_total_amount: float + purchase_material_weight_kg: float + used_material_weight_kg: float + material_inventory_weight_kg: float + material_inventory_producible_qty: float + material_inventory_value: float + material_grade: str | None = None + material_unit_price_kg: float + gross_weight_g: float + gross_material_cost_per_piece: float + gross_material_total_cost: float + net_weight_g: float + scrap_weight_g: float + scrap_unit_price_kg: float + scrap_revenue_per_piece: float + scrap_total_revenue: float + material_cost_per_piece: float + material_total_cost: float + loss_rate: float + loss_total_cost: float + labor_cost_per_piece: float + labor_total_cost: float + outsource_cost_per_piece: float + outsource_total_cost: float + product_cost_per_piece: float + product_cost_with_adjustment_per_piece: float + product_total_cost: float + rework_qty: float + rework_cost_per_piece: float + rework_total_cost: float + scrap_qty: float + scrap_residual_value_per_piece: float + scrap_total_cost: float + batch_adjustment_avg_cost: float + + +class ProductMaterialCostSummaryRead(BaseModel): + row_count: int + sales_total: float + gross_material_total: float + scrap_revenue_total: float + material_cost_total: float + material_loss_total: float + labor_total: float + outsource_total: float + rework_total: float + scrap_total: float + overhead_total: float + direct_cost_total: float + profit_amount: float + cost_ratio: float + + +class ProductMaterialCostBoardRead(BaseModel): + title: str + date_from: date + date_to: date + generated_at: datetime + rows: list[ProductMaterialCostRowRead] + expense_rows: list[ProductMaterialCostExpenseRead] + summary: ProductMaterialCostSummaryRead + + +class CostAllocationRead(BaseModel): + cost_allocation_id: int + period_id: int + period_code: str + work_order_id: int + work_order_no: str + product_item_id: int + product_code: str + product_name: str + allocation_basis_value: float + overhead_amount: float + material_amount: float + labor_amount: float + machine_amount: float + abnormal_loss_amount: float + total_cost_amount: float + unit_cost: float + + +class CostAllocationGenerateResult(BaseModel): + period_id: int + period_code: str + generated_count: int + rows: list[CostAllocationRead] + + +class StatementSnapshotCreate(BaseModel): + period_id: int + statement_type: str + report_date: date + + +class StatementSnapshotRead(BaseModel): + statement_snapshot_id: int + snapshot_no: str + period_id: int + period_code: str + statement_type: str + report_date: date + summary_json: dict + created_at: datetime + + +class ReturnItemCreate(BaseModel): + sales_order_item_id: int | None = None + delivery_item_id: int | None = None + product_item_id: int + source_lot_id: int | None = None + return_qty: float + return_weight_kg: float = 0 + responsibility_type: str | None = None + remark: str | None = None + + +class ReturnOrderCreate(BaseModel): + customer_id: int + sales_order_id: int | None = None + delivery_id: int | None = None + return_date: datetime | None = None + return_reason: str | None = None + handler_employee_id: int | None = None + warehouse_id: int | None = None + remark: str | None = None + items: list[ReturnItemCreate] + + +class ReturnScrapInboundItemCreate(BaseModel): + delivery_item_id: int + product_item_id: int + source_lot_id: int | None = None + scrap_qty: float = 0 + scrap_weight_kg: float + unit_cost: float | None = None + remark: str | None = None + + +class ReturnScrapInboundCreate(BaseModel): + customer_id: int + delivery_id: int + warehouse_id: int + inbound_date: datetime | None = None + return_reason: str | None = None + handler_employee_id: int | None = None + remark: str | None = None + items: list[ReturnScrapInboundItemCreate] + + +class ReturnOrderRead(BaseModel): + return_order_id: int + return_no: str + customer_id: int + customer_name: str + sales_order_id: int | None = None + order_no: str | None = None + delivery_id: int | None = None + delivery_no: str | None = None + return_date: datetime + return_reason: str | None = None + handler_employee_id: int | None = None + handler_name: str | None = None + product_names: str | None = None + status: str + line_count: int + total_return_qty: float + remark: str | None = None + archive_status: str | None = None + archive_business_id: int | None = None + archive_document_type: str | None = None + archive_error_message: str | None = None + + +class ReturnItemRead(BaseModel): + return_item_id: int + return_order_id: int + return_no: str + line_no: int + sales_order_item_id: int | None = None + delivery_item_id: int | None = None + delivery_no: str | None = None + product_item_id: int + product_code: str + product_name: str + source_lot_id: int | None = None + source_lot_no: str | None = None + source_material_lot_id: int | None = None + source_material_lot_no: str | None = None + source_material_code: str | None = None + source_material_name: str | None = None + source_material_sub_batch_no: str | None = None + source_material_summary: str | None = None + return_qty: float + return_weight_kg: float + disposition_status: str + responsibility_type: str | None = None + remark: str | None = None + + +class ReturnDispositionCreate(BaseModel): + return_item_id: int + disposition_type: str + scrap_weight_kg: float = 0 + extra_cost_amount: float = 0 + scrap_sale_amount: float = 0 + remark: str | None = None + + +class ReturnDispositionRead(BaseModel): + return_disposition_id: int + disposition_no: str + return_item_id: int + return_no: str + product_item_id: int | None = None + product_code: str + product_name: str + return_qty: float = 0 + return_weight_kg: float = 0 + disposition_type: str + rework_work_order_id: int | None = None + rework_work_order_no: str | None = None + rework_report_good_qty: float = 0 + rework_report_scrap_qty: float = 0 + rework_finished_inbound_qty: float = 0 + rework_scrap_inbound_qty: float = 0 + remaining_rework_finished_inbound_qty: float = 0 + remaining_rework_scrap_inbound_qty: float = 0 + rework_close_ready: bool = False + rework_close_status: str | None = None + scrap_weight_kg: float + extra_cost_amount: float + scrap_sale_amount: float + closed_at: datetime | None = None + status: str + remark: str | None = None + archive_status: str | None = None + archive_business_id: int | None = None + archive_document_type: str | None = None + archive_error_message: str | None = None + + +# --- Warehouse unified inbound/outbound schemas --- + +BIZ_TYPE_INBOUND = { + "OPENING", # 期初入库 + "CUSTOMER_SUPPLIED", # 客料入库 + "PRODUCTION_SURPLUS", # 生产余料入库 + "OUTSOURCING_SURPLUS",# 委外余料入库 + "PRODUCTION_SCRAP_IN",# 生产废料入库 + "REWORK_SCRAP_IN", # 返工废料入库(废料库) + "OUTSOURCING_SCRAP_IN",# 委外废料入库 + "RETURN_IN", # 退货入库(退货库) + "RETURN_SCRAP_IN", # 退货废料入库(废料库) + "WIP_IN", # 产中入库(半成品) + "OUTSOURCING_IN", # 委外入库(半成品/成品) + "PRODUCTION_FINISHED",# 生产入库(成品) +} + +BIZ_TYPE_OUTBOUND = { + "PRODUCTION_OUT", # 生产出库(原材料/辅料) + "OUTSOURCING_OUT", # 委外出库(原材料/半成品) + "SCRAP_OUT", # 报废出库(原材料) + "SCRAP_SALE_OUT", # 废料销售出库(废料库) + "RETURN_OUT", # 返工出库(退货库) + "WIP_OUT", # 产中出库(半成品) + "SALES_OUT", # 销售出库(成品) +} + + +class WarehouseInboundCreate(BaseModel): + biz_type: str + item_id: int + warehouse_id: int + location_id: int | None = None + source_lot_id: int | None = None + inbound_weight_kg: float = 0 + inbound_qty: float | None = 0 + unit_cost: float | None = None + lot_no: str | None = None + source_doc_type: str | None = None + source_doc_id: int | None = None + source_line_id: int | None = None + source_lot_ids: list[int] = Field(default_factory=list) + source_material_sub_batch_no: str | None = None + source_material_summary: str | None = None + provider_name: str | None = None + waybill_no: str | None = None + order_photo_url: str | None = None + freight_amount: float | None = None + remark: str | None = None + settle_work_order: bool = False + settle_remark: str | None = None + + +class OpeningInventoryImportResult(BaseModel): + imported: int + warehouse_type: str + message: str + + +class WarehouseOutboundSourceLotCreate(BaseModel): + source_lot_id: int = Field(gt=0) + outbound_weight_kg: float = Field(gt=0) + + +class WarehouseOutboundCreate(BaseModel): + biz_type: str + item_id: int + warehouse_id: int + location_id: int | None = None + outbound_weight_kg: float = 0 + outbound_qty: float | None = 0 + target_doc_type: str | None = None + target_doc_id: int | None = None + target_doc_line_id: int | None = None + target_material_sub_batch_no: str | None = None + sale_unit_price: float | None = None + buyer_name: str | None = None + sale_remark: str | None = None + waybill_no: str | None = None + order_photo_url: str | None = None + freight_amount: float | None = None + outsourcing_party_name: str | None = None + remark: str | None = None + source_lots: list[WarehouseOutboundSourceLotCreate] = Field(default_factory=list) + + +class WarehouseTransferCreate(BaseModel): + item_id: int + source_warehouse_id: int + target_warehouse_id: int + transfer_weight_kg: float + lot_id: int | None = None + remark: str | None = None + + +# --- Stocktake schemas --- + + +class StocktakeStartCreate(BaseModel): + warehouse_ids: list[int] = Field(min_length=1, max_length=1) + remark: str | None = None + + +class StocktakeWarehouseRead(BaseModel): + id: int + warehouse_id: int + warehouse_name: str + warehouse_type: str + status: str + + +class StocktakeLineRead(BaseModel): + id: int + stocktake_id: int + line_no: int + warehouse_id: int + warehouse_name: str + warehouse_type: str + location_id: int | None = None + location_name: str | None = None + item_id: int + item_code: str + item_name: str + item_type: str + lot_id: int | None = None + lot_no: str | None = None + source_material_sub_batch_no: str | None = None + snapshot_qty: float + snapshot_weight_kg: float + snapshot_unit_cost: float + counted_qty: float | None = None + counted_weight_kg: float | None = None + diff_qty: float + diff_weight_kg: float + diff_amount: float + diff_type: str + row_status: str + error_message: str | None = None + remark: str | None = None + + +class StocktakeRead(BaseModel): + id: int + stocktake_no: str + status: str + started_by_user_id: int | None = None + imported_by_user_id: int | None = None + confirmed_by_user_id: int | None = None + canceled_by_user_id: int | None = None + started_at: datetime + exported_at: datetime | None = None + imported_at: datetime | None = None + confirmed_at: datetime | None = None + canceled_at: datetime | None = None + remark: str | None = None + warehouses: list[StocktakeWarehouseRead] = Field(default_factory=list) + summary: dict[str, float | int] = Field(default_factory=dict) + + +class StocktakeImportPreviewRead(BaseModel): + stocktake: StocktakeRead + lines: list[StocktakeLineRead] + summary: dict[str, float | int] + + +class StocktakeConfirmCreate(BaseModel): + confirm_text: str | None = None + remark: str | None = None + + +# --- Special warehouse adjustment schemas --- + + +class SpecialWarehouseAdjustmentLineCreate(BaseModel): + item_id: int + lot_id: int | None = None + location_id: int | None = None + adjust_qty: Decimal | None = None + adjust_weight_kg: Decimal | None = None + unit_cost: Decimal | None = None + line_reason: str | None = None + + +class SpecialWarehouseAdjustmentCreate(BaseModel): + warehouse_id: int + direction: str + reason: str | None = None + lines: list[SpecialWarehouseAdjustmentLineCreate] = Field(min_length=1) + + +class SpecialWarehouseAdjustmentLineRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + adjustment_id: int + line_no: int + item_id: int + warehouse_id: int + location_id: int | None = None + lot_id: int + inventory_txn_id: int | None = None + before_qty: float + change_qty: float + after_qty: float + before_weight_kg: float + change_weight_kg: float + after_weight_kg: float + unit_cost: float + line_reason: str | None = None + + +class SpecialWarehouseAdjustmentRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + adjustment_no: str + warehouse_id: int + warehouse_type: str + direction: str + reason: str + status: str + operator_user_id: int | None = None + adjusted_at: datetime + lines: list[SpecialWarehouseAdjustmentLineRead] = Field(default_factory=list) + archive_status: str | None = None + archive_business_id: int | None = None + archive_document_type: str | None = None + archive_error_message: str | None = None diff --git a/backend/app/schemas/system_permissions.py b/backend/app/schemas/system_permissions.py new file mode 100644 index 0000000..ecd3e53 --- /dev/null +++ b/backend/app/schemas/system_permissions.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class PermissionRead(BaseModel): + permission_id: int + permission_code: str + permission_name: str + module_code: str + action_code: str + status: str + + +class MenuPermissionNode(BaseModel): + permission_code: str + permission_name: str + children: list["MenuPermissionNode"] = Field(default_factory=list) + + +class OrgEmployeeNode(BaseModel): + employee_id: int + employee_name: str + mobile: str | None = None + job_title: str | None = None + username: str | None = None + role_names: list[str] = Field(default_factory=list) + status: str + + +class OrgNodeRead(BaseModel): + node_id: int + node_type: str + node_label: str + parent_id: int | None = None + dept_code: str + dept_type: str + manager_employee_id: int | None = None + manager_name: str | None = None + manager_employee_ids: list[int] = Field(default_factory=list) + manager_names: list[str] = Field(default_factory=list) + status: str + sort_no: int = 0 + remark: str | None = None + employees: list[OrgEmployeeNode] = Field(default_factory=list) + children: list["OrgNodeRead"] = Field(default_factory=list) + + +class OrgNodeCreate(BaseModel): + parent_id: int | None = None + node_type: str = Field(pattern="^(COMPANY|BRANCH|DEPARTMENT|GROUP)$") + node_label: str = Field(min_length=1, max_length=100) + dept_code: str | None = Field(default=None, max_length=50) + dept_type: str = Field(default="ADMIN", max_length=32) + manager_employee_id: int | None = None + manager_employee_ids: list[int] = Field(default_factory=list) + sort_no: int = 0 + remark: str | None = Field(default=None, max_length=255) + status: str = Field(default="ACTIVE", pattern="^(ACTIVE|INACTIVE)$") + + +class OrgNodeUpdate(BaseModel): + node_label: str = Field(min_length=1, max_length=100) + dept_type: str = Field(default="ADMIN", max_length=32) + manager_employee_id: int | None = None + manager_employee_ids: list[int] = Field(default_factory=list) + sort_no: int = 0 + remark: str | None = Field(default=None, max_length=255) + status: str = Field(default="ACTIVE", pattern="^(ACTIVE|INACTIVE)$") + + +class OrgEmployeeBind(BaseModel): + employee_id: int = Field(gt=0) + sort_no: int = 0 + + +class RoleRead(BaseModel): + role_id: int + role_code: str + role_name: str + role_scope: str + status: str + remark: str | None = None + permission_codes: list[str] = Field(default_factory=list) + user_count: int = 0 + created_at: datetime | None = None + updated_at: datetime | None = None + + +class RoleCreate(BaseModel): + role_code: str = Field(min_length=2, max_length=50, pattern="^[A-Z][A-Z0-9_]*$") + role_name: str = Field(min_length=1, max_length=100) + role_scope: str = Field(default="SYSTEM", max_length=32) + status: str = Field(default="ACTIVE", pattern="^(ACTIVE|INACTIVE)$") + remark: str | None = Field(default=None, max_length=500) + permission_codes: list[str] = Field(default_factory=list) + + +class RoleUpdate(BaseModel): + role_name: str = Field(min_length=1, max_length=100) + role_scope: str = Field(default="SYSTEM", max_length=32) + status: str = Field(default="ACTIVE", pattern="^(ACTIVE|INACTIVE)$") + remark: str | None = Field(default=None, max_length=500) + permission_codes: list[str] = Field(default_factory=list) + + +class RolePermissionUpdate(BaseModel): + permission_codes: list[str] = Field(default_factory=list) + + +class SystemUserRead(BaseModel): + user_id: int + username: str + employee_id: int + employee_name: str + dept_id: int + dept_name: str | None = None + mobile: str | None = None + nickname: str | None = None + is_super_admin: bool = False + status: str + role_ids: list[int] = Field(default_factory=list) + role_names: list[str] = Field(default_factory=list) + last_login_at: datetime | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + +class SystemUserCreate(BaseModel): + employee_id: int = Field(gt=0) + role_ids: list[int] = Field(min_length=1) + password: str = Field(min_length=6, max_length=128) + status: str = Field(default="ACTIVE", pattern="^(ACTIVE|INACTIVE)$") + + +class SystemUserRoleUpdate(BaseModel): + role_ids: list[int] = Field(min_length=1) + + +class SystemUserStatusUpdate(BaseModel): + status: str = Field(pattern="^(ACTIVE|INACTIVE)$") + + +class SystemUserPasswordReset(BaseModel): + password: str = Field(min_length=6, max_length=128) + + +class EmployeeOption(BaseModel): + employee_id: int + employee_name: str + dept_id: int + dept_name: str | None = None + mobile: str | None = None + job_title: str | None = None + username: str | None = None + role_names: list[str] = Field(default_factory=list) + permission_codes: list[str] = Field(default_factory=list) + has_system_user: bool = False diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py new file mode 100644 index 0000000..074d8d9 --- /dev/null +++ b/backend/app/services/auth.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import os +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Annotated, Any + +from fastapi import Depends, HTTPException, Security +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.db.session import get_db +from app.models.org import Department, Employee, Permission, Role, RolePermission, User, UserRole +from app.schemas.database import AuthSession, LoginUser + +PBKDF2_ITERATIONS = 600000 + +ROLE_TITLE_MAP = { + "ADMIN": "总控驾驶舱", + "SALES": "订单与客户协同", + "PLANNER": "MRP与排产控制", + "PURCHASER": "采购执行中心", + "WAREHOUSE": "仓储作业中心", + "SUPERVISOR": "车间调度中心", + "OPERATOR": "现场报工中心", + "FINANCE": "成本与财务中心", + "EQUIP_ADMIN": "设备维护中心", +} + + +@dataclass +class AuthContext: + user: User + employee: Employee + department: Department | None + role_codes: list[str] + role_names: list[str] + permission_codes: list[str] + + +bearer_scheme = HTTPBearer(auto_error=False, description="登录接口返回的 Bearer Token") + + +def hash_password(password: str, salt: str | None = None) -> str: + salt_bytes = os.urandom(16) if salt is None else salt.encode("utf-8") + digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt_bytes, PBKDF2_ITERATIONS) + return ( + f"pbkdf2_sha256${PBKDF2_ITERATIONS}$" + f"{base64.urlsafe_b64encode(salt_bytes).decode('utf-8')}$" + f"{base64.urlsafe_b64encode(digest).decode('utf-8')}" + ) + + +def verify_password(password: str, stored_hash: str) -> bool: + try: + algorithm, iterations, salt_b64, digest_b64 = stored_hash.split("$", 3) + except ValueError: + return False + + if algorithm != "pbkdf2_sha256": + return False + + salt = base64.urlsafe_b64decode(salt_b64.encode("utf-8")) + expected = base64.urlsafe_b64decode(digest_b64.encode("utf-8")) + actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, int(iterations)) + return hmac.compare_digest(actual, expected) + + +def get_auth_context(db: Session, username: str) -> AuthContext: + stmt = ( + select(User, Employee, Department) + .join(Employee, Employee.id == User.employee_id) + .outerjoin(Department, Department.id == User.dept_id) + .where(User.username == username, User.status == "ACTIVE") + ) + row = db.execute(stmt).first() + if not row: + raise HTTPException(status_code=401, detail="用户名或密码错误") + + user, employee, department = row + + role_rows = db.execute( + select(Role.role_code, Role.role_name) + .join(UserRole, UserRole.role_id == Role.id) + .where(UserRole.user_id == user.id, Role.status == "ACTIVE") + .order_by(Role.id) + ).all() + role_codes = [row[0] for row in role_rows] + role_names = [row[1] for row in role_rows] + + permission_rows = db.execute( + select(Permission.permission_code) + .join(RolePermission, RolePermission.permission_id == Permission.id) + .join(UserRole, UserRole.role_id == RolePermission.role_id) + .where( + UserRole.user_id == user.id, + Permission.status == "ACTIVE", + ) + .group_by(Permission.permission_code) + .order_by(Permission.permission_code) + ).all() + permission_codes = [row[0] for row in permission_rows] + if user.is_super_admin or "ADMIN" in role_codes: + for permission_code in ("MENU_SYSTEM_EXTENSION", "MENU_SYSTEM_PERMISSION"): + if permission_code not in permission_codes: + permission_codes.append(permission_code) + + return AuthContext( + user=user, + employee=employee, + department=department, + role_codes=role_codes, + role_names=role_names, + permission_codes=permission_codes, + ) + + +def login_with_password(db: Session, username: str, password: str) -> AuthSession: + context = get_auth_context(db, username) + if not verify_password(password, context.user.password_hash): + raise HTTPException(status_code=401, detail="用户名或密码错误") + context.user.last_login_at = datetime.now(timezone.utc).replace(tzinfo=None) + db.add(context.user) + db.commit() + return build_auth_session(context) + + +def build_login_user(context: AuthContext) -> LoginUser: + title = ROLE_TITLE_MAP.get(context.role_codes[0], "工业风管理台") if context.role_codes else "工业风管理台" + return LoginUser( + user_id=context.user.id, + username=context.user.username, + employee_id=context.employee.id, + mobile=context.employee.mobile, + display_name=context.user.nickname or context.employee.employee_name, + title=title, + dept_name=context.department.dept_name if context.department else None, + role_codes=context.role_codes, + role_names=context.role_names, + permission_codes=context.permission_codes, + ) + + +def build_auth_session(context: AuthContext) -> AuthSession: + access_token, expires_at = create_access_token(context) + return AuthSession( + access_token=access_token, + token_type="bearer", + expires_at=expires_at, + **build_login_user(context).model_dump(), + ) + + +def next_employee_code(db: Session) -> str: + total = db.scalar(select(func.count(Employee.id))) or 0 + return f"EMP-{int(total) + 1:04d}" + + +def create_access_token(context: AuthContext) -> tuple[str, datetime]: + settings = get_settings() + now = datetime.now(timezone.utc) + expires_at = now + timedelta(minutes=settings.auth_token_expire_minutes) + header = {"alg": "HS256", "typ": "JWT"} + payload = { + "sub": context.user.username, + "uid": context.user.id, + "iat": int(now.timestamp()), + "exp": int(expires_at.timestamp()), + } + encoded_header = _urlsafe_b64encode(json.dumps(header, separators=(",", ":")).encode("utf-8")) + encoded_payload = _urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode("utf-8")) + signing_input = f"{encoded_header}.{encoded_payload}" + signature = _sign_token(signing_input, settings.auth_secret_key) + return f"{signing_input}.{signature}", expires_at + + +def decode_access_token(token: str) -> dict[str, Any]: + settings = get_settings() + try: + encoded_header, encoded_payload, signature = token.split(".", 2) + except ValueError as exc: + raise HTTPException(status_code=401, detail="登录凭证无效") from exc + + signing_input = f"{encoded_header}.{encoded_payload}" + expected_signature = _sign_token(signing_input, settings.auth_secret_key) + if not hmac.compare_digest(expected_signature, signature): + raise HTTPException(status_code=401, detail="登录凭证无效") + + try: + payload = json.loads(_urlsafe_b64decode(encoded_payload).decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError, ValueError) as exc: + raise HTTPException(status_code=401, detail="登录凭证无效") from exc + + expires_at = payload.get("exp") + username = payload.get("sub") + if not isinstance(expires_at, int) or not username: + raise HTTPException(status_code=401, detail="登录凭证无效") + if expires_at <= int(datetime.now(timezone.utc).timestamp()): + raise HTTPException(status_code=401, detail="登录已过期,请重新登录") + + return payload + + +def get_current_auth_context( + credentials: Annotated[HTTPAuthorizationCredentials | None, Security(bearer_scheme)], + db: Session = Depends(get_db), +) -> AuthContext: + if credentials is None or credentials.scheme.lower() != "bearer": + raise HTTPException(status_code=401, detail="未登录或登录已过期") + + payload = decode_access_token(credentials.credentials) + return get_auth_context(db, str(payload["sub"])) + + +def require_authenticated_user( + context: Annotated[AuthContext, Depends(get_current_auth_context)], +) -> AuthContext: + return context + + +def require_system_admin( + context: Annotated[AuthContext, Depends(get_current_auth_context)], +) -> AuthContext: + if context.user.is_super_admin or "ADMIN" in context.role_codes: + return context + raise HTTPException(status_code=403, detail="仅系统管理员可访问系统管理") + + +def _sign_token(signing_input: str, secret_key: str) -> str: + digest = hmac.new(secret_key.encode("utf-8"), signing_input.encode("utf-8"), hashlib.sha256).digest() + return _urlsafe_b64encode(digest) + + +def _urlsafe_b64encode(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=") + + +def _urlsafe_b64decode(data: str) -> bytes: + padding = "=" * (-len(data) % 4) + return base64.urlsafe_b64decode(f"{data}{padding}".encode("utf-8")) diff --git a/backend/app/services/contacts.py b/backend/app/services/contacts.py new file mode 100644 index 0000000..0876c25 --- /dev/null +++ b/backend/app/services/contacts.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import re +from typing import Any + +CONTACT_SEPARATOR = "、" +MAX_CONTACTS = 3 + + +def _clean_text(value: Any) -> str: + return str(value or "").strip() + + +def _entry_value(entry: Any, *keys: str) -> str: + for key in keys: + if isinstance(entry, dict): + value = entry.get(key) + else: + value = getattr(entry, key, None) + cleaned = _clean_text(value) + if cleaned: + return cleaned + return "" + + +def _split_contacts(value: str | None) -> list[str]: + text = _clean_text(value) + if not text: + return [] + return [part.strip() for part in re.split(r"[、,,;;\n]+", text) if part.strip()] + + +def normalize_contacts( + contacts: list[Any] | None, + *, + fallback_name: str | None = None, + fallback_phone: str | None = None, + max_contacts: int = MAX_CONTACTS, +) -> list[dict[str, str]]: + normalized: list[dict[str, str]] = [] + + for entry in contacts or []: + contact_name = _entry_value(entry, "contact_name", "name") + contact_phone = _entry_value(entry, "contact_phone", "phone") + if not contact_name and not contact_phone: + continue + normalized.append({"contact_name": contact_name, "contact_phone": contact_phone}) + + if not normalized: + contact_name = _clean_text(fallback_name) + contact_phone = _clean_text(fallback_phone) + if contact_name or contact_phone: + normalized.append({"contact_name": contact_name, "contact_phone": contact_phone}) + + if len(normalized) > max_contacts: + raise ValueError(f"联系人最多维护 {max_contacts} 位") + + return normalized + + +def serialize_contacts(contacts: list[dict[str, str]]) -> tuple[str | None, str | None]: + names = [item["contact_name"] for item in contacts if item.get("contact_name")] + phones = [item["contact_phone"] for item in contacts if item.get("contact_phone")] + return ( + CONTACT_SEPARATOR.join(names) if names else None, + CONTACT_SEPARATOR.join(phones) if phones else None, + ) + + +def parse_contacts(contact_name: str | None, contact_phone: str | None, *, max_contacts: int = MAX_CONTACTS) -> list[dict[str, str]]: + names = _split_contacts(contact_name) + phones = _split_contacts(contact_phone) + total = min(max(len(names), len(phones)), max_contacts) + return [ + { + "contact_name": names[index] if index < len(names) else "", + "contact_phone": phones[index] if index < len(phones) else "", + } + for index in range(total) + ] diff --git a/backend/app/services/document_archives.py b/backend/app/services/document_archives.py new file mode 100644 index 0000000..33888cb --- /dev/null +++ b/backend/app/services/document_archives.py @@ -0,0 +1,1371 @@ +from __future__ import annotations + +import hashlib +import re +import zipfile +from dataclasses import dataclass, field +from datetime import date, datetime +from decimal import Decimal +from pathlib import Path +from typing import Iterable + +from fastapi import HTTPException +from reportlab.lib import colors +from reportlab.lib.pagesizes import A4 +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.cidfonts import UnicodeCIDFont +from reportlab.pdfgen import canvas +from sqlalchemy import case, func, or_, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session, aliased + +from app.models.document_archive import DocumentArchive +from app.models.master_data import Item, Warehouse +from app.models.org import Employee, User +from app.models.operations import ( + CompletionReceipt, + InventoryTxn, + ProductionBatchLedger, + ProductionBatchLedgerTxn, + PurchaseOrder, + PurchaseOrderItem, + PurchaseReceipt, + PurchaseReceiptItem, + StockLot, + Supplier, + WarehouseLocation, + WorkOrder, +) +from app.models.sales import Customer, SalesOrder, SalesOrderItem +from app.schemas.document_archives import DocumentArchiveGenerateResult + +DOCUMENT_TYPE_SALES_ORDER = "销售订单" +DOCUMENT_TYPE_PURCHASE_ORDER = "采购订单" +DOCUMENT_TYPE_PURCHASE_RECEIPT = "到货入库单" +DOCUMENT_TYPE_QUALITY_INSPECTION = "质量校验单" +DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT = "生产领料出库单" +DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT = "生产入库结算单" +DOCUMENT_TYPE_WAREHOUSE_OPERATION = "仓库出入库单" +ARCHIVE_STATUS_READY = "已归档" +ARCHIVE_STATUS_FAILED = "归档失败" +ARCHIVE_STATUS_MISSING = "未生成" +TEMPLATE_VERSION = "单据纸面V1" +FILE_FORMAT_PDF = "PDF" +ARCHIVE_TABLE_X = 42 +ARCHIVE_TABLE_RIGHT_MARGIN = 42 +ARCHIVE_TABLE_TOP_Y_OFFSET = 190 +ARCHIVE_TABLE_BOTTOM_Y = 132 +ARCHIVE_MIN_ROW_HEIGHT = 24 +ARCHIVE_CELL_HORIZONTAL_PADDING = 8 +ARCHIVE_CELL_VERTICAL_PADDING = 8 +ARCHIVE_CELL_FONT_SIZE = 8 + +ARCHIVE_STATUS_LABELS = { + "ACTIVE": "启用", + "ENABLED": "启用", + "INACTIVE": "停用", + "DISABLED": "停用", + "OPEN": "未开始", + "NEW": "新建", + "DRAFT": "草稿", + "SUBMITTED": "已提交", + "APPROVED": "已审批", + "PARTIAL": "部分完成", + "PARTIAL_RECEIVED": "部分收货", + "CLOSED": "已关闭", + "CANCELED": "已取消", + "CANCELLED": "已取消", + "ORDERED": "已下单", + "RECEIVED": "已收货", + "RELEASED": "已下达", + "IN_PROGRESS": "进行中", + "READY_TO_RECEIPT": "待成品入库", + "COMPLETED": "已完工", + "SETTLED": "已结单", + "DONE": "已完成", + "PENDING": "待处理", + "PENDING_QC": "待质检", + "PASS": "已放行", + "PASSED": "已放行", + "REJECT": "已拒收", + "REJECTED": "已拒收", + "POSTED": "已过账", + "AVAILABLE": "可用", + "LOCKED": "已锁单", + "DEPLETED": "已耗尽", + "CONFIRMED": "已确认", + "OVERRIDDEN": "已覆盖", + "CREATED": "已创建", + "IDLE": "空闲", + "READY": "就绪", + "ERROR": "异常", + "FAILED": "失败", + "EXPORTED": "已导出", + "IMPORTED": "已导入", + "SUPERSEDED": "已替代", + "MINIAPP_ONLY": "小程序清单", + "HAS_RETURN": "存在退货", +} + +PRODUCTION_INBOUND_TXN_LABELS = { + "FG_IN": "成品入库", + "PRODUCTION_FINISHED_IN": "成品入库", + "PRODUCTION_SURPLUS_IN": "生产余料入库", + "PRODUCTION_SCRAP_IN": "生产废料入库", +} +WAREHOUSE_OPERATION_TXN_LABELS = { + "CUSTOMER_SUPPLIED": "客料入库", + "CUSTOMER_SUPPLIED_IN": "客料入库", + "OPENING_IN": "期初入库", + "PURCHASE_IN": "采购入库", + "PRODUCTION_SURPLUS_IN": "生产余料入库", + "OUTSOURCING_SURPLUS_IN": "委外余料入库", + "PRODUCTION_SCRAP_IN": "生产废料入库", + "REWORK_SCRAP_IN": "返工废料入库", + "OUTSOURCING_SCRAP_IN": "委外废料入库", + "RETURN_SCRAP_IN": "退货废料入库", + "WIP_IN": "产中入库", + "WIP_OUT": "产中出库", + "OUTSOURCING_IN": "委外入库", + "OUTSOURCING_OUT": "委外出库", + "PRODUCTION_FINISHED_IN": "生产入库", + "FG_IN": "成品入库", + "REWORK_FINISHED_IN": "返工入库", + "PRODUCTION_OUT": "生产出库", + "MATERIAL_ISSUE": "生产领料出库", + "SCRAP_SALE_OUT": "售卖出库", + "PURCHASE_RETURN_OUT": "退货出库", + "SPECIAL_IN": "特殊入库", + "SPECIAL_OUT": "特殊出库", + "SALES_OUT": "销售出库", + "SCRAP_IN": "废料入库", + "SCRAP_OUT": "废料出库", + "RETURN_IN": "退货入库", + "RETURN_OUT": "退货出库", + "AUX_IN": "辅料入库", + "AUX_OUT": "辅料出库", + "RAW_IN": "原材料入库", + "RAW_OUT": "原材料出库", + "SEMI_FINISHED_IN": "半成品入库", + "SEMI_FINISHED_OUT": "半成品出库", + "FINISHED_IN": "成品入库", + "FINISHED_OUT": "成品出库", + "TRANSFER_IN": "调拨入库", + "TRANSFER_OUT": "调拨出库", + "STOCKTAKE_GAIN": "盘盈入库", + "STOCKTAKE_LOSS": "盘亏出库", +} +PRODUCTION_DOCUMENT_BATCH_PREFIX = "单据批次号:" + + +def production_document_batch_no_from_text(value: str | None) -> str | None: + text = str(value or "").strip() + if not text: + return None + for part in re.split(r"[;;]", text): + current = part.strip() + if current.startswith(PRODUCTION_DOCUMENT_BATCH_PREFIX): + batch_no = current[len(PRODUCTION_DOCUMENT_BATCH_PREFIX):].strip() + return batch_no or None + return None + + +@dataclass(slots=True) +class ArchiveLine: + line_no: int + item_code: str + item_name: str + specification: str | None + quantity: Decimal | float | int | None + delivered_or_received_quantity: Decimal | float | int | None + unit_price: Decimal | float | int | None + line_amount: Decimal | float | int | None + promised_or_expected_date: date | datetime | str | None = None + remark: str | None = None + + +@dataclass(slots=True) +class ArchiveContext: + document_type: str + business_id: int + document_no: str + title: str + partner_label: str + partner_name: str + document_date: date | datetime | str | None + due_date_label: str + due_date: date | datetime | str | None + status: str + tax_rate: Decimal | float | int | None + total_amount: Decimal | float | int | None + contact_name: str | None = None + contact_phone: str | None = None + address_label: str = "地址" + address: str | None = None + remark: str | None = None + lines: list[ArchiveLine] = field(default_factory=list) + prepared_by: str | None = None + operator_name: str | None = None + + +def default_archive_root() -> Path: + return Path(__file__).resolve().parents[2] / "storage" / "document_archives" + + +def safe_filename(value: str) -> str: + safe = re.sub(r'[\\/:*?"<>|\s]+', "_", value.strip()) + safe = re.sub(r"_+", "_", safe).strip("._") + return safe or "document" + + +def sha256_file(path: str | Path) -> str: + digest = hashlib.sha256() + with Path(path).open("rb") as file: + for chunk in iter(lambda: file.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def decimal_text(value: Decimal | float | int | None, places: int = 2) -> str: + if value is None: + return "" + number = Decimal(str(value)) + quant = Decimal("1") if places <= 0 else Decimal("1").scaleb(-places) + text = f"{number.quantize(quant):f}" + return text.rstrip("0").rstrip(".") if "." in text else text + + +def date_text(value: date | datetime | str | None) -> str: + if value is None: + return "" + if isinstance(value, datetime): + return value.strftime("%Y-%m-%d") + if isinstance(value, date): + return value.strftime("%Y-%m-%d") + return str(value) + + +def archive_status_text(value: str | None) -> str: + text = str(value or "").strip() + if not text: + return "" + normalized = text.upper() + if normalized in ARCHIVE_STATUS_LABELS: + return ARCHIVE_STATUS_LABELS[normalized] + if re.fullmatch(r"[A-Z0-9_]+", text): + return "未识别状态" + return text + + +def resolve_archive_user_label(db: Session, user_id: int | None) -> str | None: + if user_id is None: + return None + row = db.execute( + select(User, Employee) + .outerjoin(Employee, Employee.id == User.employee_id) + .where(User.id == user_id) + ).first() + if row is None: + return None + user, employee = row + for value in (user.nickname, employee.employee_name if employee else None, user.username): + text = str(value or "").strip() + if text: + return text + return None + + +def apply_archive_operator_context(db: Session, context: ArchiveContext, created_by: int | None) -> ArchiveContext: + operator_label = resolve_archive_user_label(db, created_by) + if operator_label: + context.prepared_by = operator_label + context.operator_name = context.operator_name or operator_label + return context + + +def get_latest_archive(db: Session, document_type: str, business_id: int) -> DocumentArchive | None: + return db.execute( + select(DocumentArchive) + .where( + DocumentArchive.document_type == document_type, + DocumentArchive.business_id == business_id, + ) + .order_by(DocumentArchive.archive_version.desc(), DocumentArchive.id.desc()) + .limit(1) + ).scalar_one_or_none() + + +def next_archive_version(db: Session, document_type: str, business_id: int) -> int: + latest_version = db.execute( + select(func.max(DocumentArchive.archive_version)).where( + DocumentArchive.document_type == document_type, + DocumentArchive.business_id == business_id, + DocumentArchive.file_format == FILE_FORMAT_PDF, + ) + ).scalar_one() + return int(latest_version or 0) + 1 + + +def next_available_archive_version( + db: Session, + document_type: str, + business_id: int, + preferred_version: int, +) -> int: + version = max(1, int(preferred_version)) + used_versions = set( + db.execute( + select(DocumentArchive.archive_version).where( + DocumentArchive.document_type == document_type, + DocumentArchive.business_id == business_id, + DocumentArchive.file_format == FILE_FORMAT_PDF, + DocumentArchive.archive_version >= version, + ) + ).scalars() + ) + while version in used_versions: + version += 1 + return version + + +def collect_sales_order_archive_context(db: Session, sales_order_id: int) -> ArchiveContext: + row = db.execute( + select(SalesOrder, Customer) + .join(Customer, Customer.id == SalesOrder.customer_id) + .where(SalesOrder.id == sales_order_id) + ).one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="销售订单不存在") + + order, customer = row + item_rows = db.execute( + select(SalesOrderItem, Item) + .join(Item, Item.id == SalesOrderItem.product_item_id) + .where(SalesOrderItem.sales_order_id == sales_order_id) + .order_by(SalesOrderItem.line_no) + ).all() + + lines = [ + ArchiveLine( + line_no=order_item.line_no, + item_code=item.item_code, + item_name=item.item_name, + specification=item.specification, + quantity=order_item.order_qty, + delivered_or_received_quantity=order_item.delivered_qty, + unit_price=order_item.unit_price, + line_amount=order_item.line_amount, + promised_or_expected_date=order_item.promised_date, + remark=order_item.customer_part_no, + ) + for order_item, item in item_rows + ] + return ArchiveContext( + document_type=DOCUMENT_TYPE_SALES_ORDER, + business_id=order.id, + document_no=order.order_no, + title="销售订单归档", + partner_label="客户", + partner_name=customer.customer_name, + document_date=order.order_date, + due_date_label="承诺日期", + due_date=order.promised_date, + status=archive_status_text(order.status), + tax_rate=order.tax_rate, + total_amount=order.total_amount, + contact_name=customer.contact_name, + contact_phone=customer.contact_phone, + address=order.delivery_address or customer.address, + lines=lines, + ) + + +def collect_purchase_order_archive_context(db: Session, purchase_order_id: int) -> ArchiveContext: + row = db.execute( + select(PurchaseOrder, Supplier) + .join(Supplier, Supplier.id == PurchaseOrder.supplier_id) + .where(PurchaseOrder.id == purchase_order_id) + ).one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="采购订单不存在") + + order, supplier = row + item_rows = db.execute( + select(PurchaseOrderItem, Item) + .join(Item, Item.id == PurchaseOrderItem.material_item_id) + .where(PurchaseOrderItem.purchase_order_id == purchase_order_id) + .order_by(PurchaseOrderItem.line_no) + ).all() + + is_raw_purchase = str(order.target_warehouse_type or "").upper() == "RAW" + lines = [ + ArchiveLine( + line_no=order_item.line_no, + item_code=item.item_code, + item_name=item.item_name, + specification=item.specification, + quantity=order_item.order_weight_kg if is_raw_purchase else order_item.order_qty, + delivered_or_received_quantity=order_item.received_weight_kg if is_raw_purchase else order_item.received_qty, + unit_price=order_item.unit_price, + line_amount=order_item.line_amount, + promised_or_expected_date=None, + remark=f"kg: {order_item.remark}" if is_raw_purchase and order_item.remark else ("kg" if is_raw_purchase else order_item.remark), + ) + for order_item, item in item_rows + ] + return ArchiveContext( + document_type=DOCUMENT_TYPE_PURCHASE_ORDER, + business_id=order.id, + document_no=order.po_no, + title="采购订单归档", + partner_label="供应商", + partner_name=supplier.supplier_name, + document_date=order.order_date, + due_date_label="预计到货", + due_date=order.expected_date, + status=archive_status_text(order.status), + tax_rate=order.tax_rate, + total_amount=order.total_amount, + contact_name=supplier.contact_name, + contact_phone=supplier.contact_phone, + address=supplier.address, + remark=order.remark, + lines=lines, + ) + + +def collect_purchase_receipt_archive_context(db: Session, purchase_receipt_id: int) -> ArchiveContext: + row = db.execute( + select(PurchaseReceipt, PurchaseOrder, Supplier, Warehouse) + .join(PurchaseOrder, PurchaseOrder.id == PurchaseReceipt.purchase_order_id) + .join(Supplier, Supplier.id == PurchaseOrder.supplier_id) + .join(Warehouse, Warehouse.id == PurchaseReceipt.warehouse_id) + .where(PurchaseReceipt.id == purchase_receipt_id) + ).one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="到货入库单不存在") + + receipt, order, supplier, warehouse = row + item_rows = db.execute( + select(PurchaseReceiptItem, Item) + .join(Item, Item.id == PurchaseReceiptItem.material_item_id) + .where(PurchaseReceiptItem.receipt_id == purchase_receipt_id) + .order_by(PurchaseReceiptItem.line_no) + ).all() + + is_raw_receipt = str(order.target_warehouse_type or "").upper() != "AUX" + lines = [] + total_amount = Decimal("0") + for receipt_item, item in item_rows: + received_value = receipt_item.received_weight_kg if is_raw_receipt else receipt_item.received_qty + unit_cost = Decimal(str(receipt_item.unit_cost or 0)) + line_amount = Decimal(str(received_value or 0)) * unit_cost + total_amount += line_amount + remark_parts = [f"批次:{receipt_item.lot_no}", f"状态:{archive_status_text(receipt_item.status)}"] + if receipt_item.remark: + remark_parts.append(str(receipt_item.remark)) + lines.append( + ArchiveLine( + line_no=receipt_item.line_no, + item_code=item.item_code, + item_name=item.item_name, + specification=item.specification, + quantity=received_value, + delivered_or_received_quantity=receipt_item.accepted_weight_kg if is_raw_receipt else receipt_item.accepted_qty, + unit_price=receipt_item.unit_cost, + line_amount=line_amount, + promised_or_expected_date=None, + remark=";".join(remark_parts), + ) + ) + + remark_parts = [] + if receipt.logistics_waybill_no: + remark_parts.append(f"运单号:{receipt.logistics_waybill_no}") + if receipt.logistics_freight_amount is not None: + remark_parts.append(f"运费:{decimal_text(receipt.logistics_freight_amount, 2)}") + if receipt.logistics_photo_url: + remark_parts.append("辅助照片:已上传") + if receipt.remark: + remark_parts.append(str(receipt.remark)) + + return ArchiveContext( + document_type=DOCUMENT_TYPE_PURCHASE_RECEIPT, + business_id=receipt.id, + document_no=receipt.receipt_no, + title="到货入库单归档", + partner_label="供应商", + partner_name=supplier.supplier_name, + document_date=receipt.receipt_date, + due_date_label="采购订单", + due_date=order.po_no, + status=archive_status_text(receipt.status), + tax_rate=order.tax_rate, + total_amount=total_amount, + contact_name=supplier.contact_name, + contact_phone=supplier.contact_phone, + address=warehouse.warehouse_name, + remark=";".join(remark_parts), + lines=lines, + ) + + +def collect_quality_inspection_archive_context(db: Session, receipt_item_id: int) -> ArchiveContext: + row = db.execute( + select(PurchaseReceiptItem, PurchaseReceipt, PurchaseOrder, Supplier, Warehouse, Item) + .join(PurchaseReceipt, PurchaseReceipt.id == PurchaseReceiptItem.receipt_id) + .join(PurchaseOrder, PurchaseOrder.id == PurchaseReceipt.purchase_order_id) + .join(Supplier, Supplier.id == PurchaseOrder.supplier_id) + .join(Warehouse, Warehouse.id == PurchaseReceipt.warehouse_id) + .join(Item, Item.id == PurchaseReceiptItem.material_item_id) + .where(PurchaseReceiptItem.id == receipt_item_id) + ).one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="质量校验明细不存在") + + receipt_item, receipt, order, supplier, warehouse, item = row + lot = db.scalar( + select(StockLot).where( + StockLot.source_doc_type == "PURCHASE_RECEIPT", + StockLot.source_doc_id == receipt.id, + StockLot.source_line_id == receipt_item.id, + ) + ) + if not lot and receipt_item.merge_to_lot_id: + lot = db.get(StockLot, receipt_item.merge_to_lot_id) + + is_raw_receipt = str(order.target_warehouse_type or "").upper() != "AUX" + received_value = receipt_item.received_weight_kg if is_raw_receipt else receipt_item.received_qty + accepted_value = receipt_item.accepted_weight_kg if is_raw_receipt else receipt_item.accepted_qty + line_amount = Decimal(str(accepted_value or 0)) * Decimal(str(receipt_item.unit_cost or 0)) + inspection_result = archive_status_text((lot.quality_status if lot else None) or receipt_item.status) + inspector_name = str(lot.remark or "").strip() if lot and lot.remark else "" + line_remark_parts = [f"库存批次号:{receipt_item.lot_no}", f"质检结论:{inspection_result}"] + if receipt_item.remark: + line_remark_parts.append(str(receipt_item.remark)) + + remark_parts = [f"采购订单:{order.po_no}", f"入库仓库:{warehouse.warehouse_name}"] + if inspector_name: + remark_parts.append(f"质检人员:{inspector_name}") + if receipt.remark: + remark_parts.append(str(receipt.remark)) + + return ArchiveContext( + document_type=DOCUMENT_TYPE_QUALITY_INSPECTION, + business_id=receipt_item.id, + document_no=f"质检-{receipt.receipt_no}-{receipt_item.line_no}", + title="质量校验单归档", + partner_label="供应商", + partner_name=supplier.supplier_name, + document_date=getattr(receipt_item, "updated_at", None) or receipt.receipt_date, + due_date_label="入库单", + due_date=receipt.receipt_no, + status=archive_status_text(receipt_item.status), + tax_rate=order.tax_rate, + total_amount=line_amount, + contact_name=supplier.contact_name, + contact_phone=supplier.contact_phone, + address=warehouse.warehouse_name, + remark=";".join(remark_parts), + lines=[ + ArchiveLine( + line_no=receipt_item.line_no, + item_code=item.item_code, + item_name=item.item_name, + specification=item.specification, + quantity=received_value, + delivered_or_received_quantity=accepted_value, + unit_price=receipt_item.unit_cost, + line_amount=line_amount, + promised_or_expected_date=None, + remark=";".join(line_remark_parts), + ) + ], + ) + + +def collect_production_material_out_archive_context(db: Session, production_ledger_id: int) -> ArchiveContext: + material_item = aliased(Item) + product_item = aliased(Item) + row = db.execute( + select(ProductionBatchLedger, StockLot, material_item, product_item, Warehouse) + .join(StockLot, StockLot.id == ProductionBatchLedger.material_lot_id) + .join(material_item, material_item.id == ProductionBatchLedger.material_item_id) + .join(product_item, product_item.id == ProductionBatchLedger.product_item_id) + .join(Warehouse, Warehouse.id == StockLot.warehouse_id) + .where(ProductionBatchLedger.id == production_ledger_id) + ).one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="生产台账不存在") + + ledger, material_lot, material, product, warehouse = row + issued_weight = Decimal(str(ledger.total_issued_weight_kg or 0)) + unit_cost = Decimal(str(material_lot.unit_cost or 0)) + total_amount = issued_weight * unit_cost + issue_count = db.scalar( + select(func.count(ProductionBatchLedgerTxn.id)).where( + ProductionBatchLedgerTxn.production_ledger_id == ledger.id, + ProductionBatchLedgerTxn.txn_type == "生产出库", + ) + ) or 0 + + line_remark_parts = [ + f"库存批次号:{ledger.material_lot_no}", + f"累计生产出库次数:{issue_count}", + f"库外未闭环重量:{decimal_text(ledger.outside_weight_kg, 6)}kg", + ] + if ledger.remark: + line_remark_parts.append(str(ledger.remark)) + + remark_parts = [ + f"材料库存批次号:{ledger.material_lot_no}", + f"生产产品:{product.item_name}", + ] + if ledger.remark: + remark_parts.append(str(ledger.remark)) + + return ArchiveContext( + document_type=DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, + business_id=ledger.id, + document_no=f"生产领料-{ledger.material_lot_no}-{product.item_name}", + title="生产领料出库单归档", + partner_label="生产产品", + partner_name=product.item_name, + document_date=ledger.last_issue_time or ledger.first_issue_time or ledger.updated_at, + due_date_label="生产台账", + due_date=ledger.id, + status=archive_status_text(ledger.status), + tax_rate=0, + total_amount=total_amount, + address=warehouse.warehouse_name, + remark=";".join(remark_parts), + lines=[ + ArchiveLine( + line_no=1, + item_code=material.item_code, + item_name=material.item_name, + specification=material.specification, + quantity=ledger.total_issued_weight_kg, + delivered_or_received_quantity=ledger.outside_weight_kg, + unit_price=material_lot.unit_cost, + line_amount=total_amount, + promised_or_expected_date=ledger.last_issue_time or ledger.first_issue_time, + remark=";".join(line_remark_parts), + ) + ], + ) + + +def collect_production_inbound_settlement_archive_context(db: Session, inventory_txn_id: int) -> ArchiveContext: + main_txn = db.get(InventoryTxn, inventory_txn_id) + if main_txn is None: + raise HTTPException(status_code=404, detail="生产入库库存流水不存在") + if main_txn.txn_type not in PRODUCTION_INBOUND_TXN_LABELS: + raise HTTPException(status_code=400, detail="该库存流水不是生产入库结算流水") + + ledger_row = None + if main_txn.source_doc_type == "生产台账": + product_item = aliased(Item) + ledger_row = db.execute( + select(ProductionBatchLedger, product_item) + .join(product_item, product_item.id == ProductionBatchLedger.product_item_id) + .where(ProductionBatchLedger.id == main_txn.source_doc_id) + ).one_or_none() + ledger = ledger_row[0] if ledger_row else None + product = ledger_row[1] if ledger_row else None + work_order_id: int | None = None + if ledger is None and main_txn.source_doc_type == "FG_RECEIPT": + receipt = db.get(CompletionReceipt, main_txn.source_doc_id) + work_order_id = int(receipt.work_order_id) if receipt else None + elif ledger is None and main_txn.source_doc_type == "WORK_ORDER": + work_order_id = int(main_txn.source_doc_id or 0) or None + if ledger is None and work_order_id: + work_order_row = db.execute( + select(WorkOrder, Item) + .join(Item, Item.id == WorkOrder.product_item_id) + .where(WorkOrder.id == work_order_id) + ).one_or_none() + product = work_order_row[1] if work_order_row else None + + txn_order = case( + (InventoryTxn.txn_type.in_(("FG_IN", "PRODUCTION_FINISHED_IN")), 1), + (InventoryTxn.txn_type == "PRODUCTION_SURPLUS_IN", 2), + (InventoryTxn.txn_type == "PRODUCTION_SCRAP_IN", 3), + else_=99, + ) + related_stmt = ( + select(InventoryTxn, Item, Warehouse, StockLot) + .join(Item, Item.id == InventoryTxn.item_id) + .join(Warehouse, Warehouse.id == InventoryTxn.warehouse_id) + .outerjoin(StockLot, StockLot.id == InventoryTxn.lot_id) + .order_by(txn_order, InventoryTxn.id.asc()) + ) + batch_no = production_document_batch_no_from_text(main_txn.remark) + if batch_no: + related_stmt = related_stmt.where( + InventoryTxn.remark.like(f"%{PRODUCTION_DOCUMENT_BATCH_PREFIX}{batch_no}%"), + InventoryTxn.txn_type.in_(tuple(PRODUCTION_INBOUND_TXN_LABELS)), + ) + elif work_order_id and main_txn.source_doc_type in {"FG_RECEIPT", "WORK_ORDER"}: + receipt_ids = db.scalars(select(CompletionReceipt.id).where(CompletionReceipt.work_order_id == work_order_id)).all() + source_filters = [ + (InventoryTxn.source_doc_type == "WORK_ORDER") & (InventoryTxn.source_doc_id == work_order_id), + ] + if receipt_ids: + source_filters.append( + (InventoryTxn.source_doc_type == "FG_RECEIPT") & (InventoryTxn.source_doc_id.in_([int(row_id) for row_id in receipt_ids])) + ) + related_stmt = related_stmt.where( + or_(*source_filters), + InventoryTxn.biz_time == main_txn.biz_time, + InventoryTxn.txn_type.in_(tuple(PRODUCTION_INBOUND_TXN_LABELS)), + ) + else: + related_stmt = related_stmt.where( + InventoryTxn.source_doc_type == main_txn.source_doc_type, + InventoryTxn.source_doc_id == main_txn.source_doc_id, + InventoryTxn.biz_time == main_txn.biz_time, + InventoryTxn.txn_type.in_(tuple(PRODUCTION_INBOUND_TXN_LABELS)), + ) + related_rows = db.execute(related_stmt).all() + + lines: list[ArchiveLine] = [] + total_amount = Decimal("0") + related_warehouse_names: list[str] = [] + for line_no, (txn, item, warehouse, lot) in enumerate(related_rows, start=1): + total_amount += Decimal(str(txn.amount or 0)) + if warehouse.warehouse_name and warehouse.warehouse_name not in related_warehouse_names: + related_warehouse_names.append(warehouse.warehouse_name) + lot_no = lot.lot_no if lot else "" + line_remark_parts = [ + f"入库类型:{PRODUCTION_INBOUND_TXN_LABELS.get(txn.txn_type, txn.txn_type)}", + f"入库仓库:{warehouse.warehouse_name}", + ] + if lot_no: + line_remark_parts.append(f"库存批次号:{lot_no}") + if ledger: + line_remark_parts.append(f"原材料库存批次号:{ledger.material_lot_no}") + if txn.remark: + line_remark_parts.append(str(txn.remark)) + + lines.append( + ArchiveLine( + line_no=line_no, + item_code=item.item_code, + item_name=item.item_name, + specification=item.specification, + quantity=txn.qty_change if txn.txn_type in ("FG_IN", "PRODUCTION_FINISHED_IN") else txn.weight_change_kg, + delivered_or_received_quantity=txn.weight_change_kg, + unit_price=txn.unit_cost, + line_amount=txn.amount, + promised_or_expected_date=txn.biz_time, + remark=";".join(line_remark_parts), + ) + ) + + remark_parts = [ + f"库存流水号:{main_txn.txn_no}", + f"入库类型:{PRODUCTION_INBOUND_TXN_LABELS.get(main_txn.txn_type, main_txn.txn_type)}", + ] + if ledger: + remark_parts.append(f"材料库存批次号:{ledger.material_lot_no}") + if related_warehouse_names: + remark_parts.append(f"涉及仓库:{'、'.join(related_warehouse_names)}") + if main_txn.remark: + remark_parts.append(str(main_txn.remark)) + due_date_label = "生产台账" if ledger else "生产工单" if work_order_id else "库存流水" + due_date_value = str(ledger.id if ledger else work_order_id if work_order_id else main_txn.id) + + return ArchiveContext( + document_type=DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + business_id=main_txn.id, + document_no=f"生产入库结算-{main_txn.txn_no}", + title="生产入库结算单归档", + partner_label="生产产品", + partner_name=product.item_name if product else "", + document_date=main_txn.biz_time, + due_date_label=due_date_label, + due_date=due_date_value, + status=PRODUCTION_INBOUND_TXN_LABELS.get(main_txn.txn_type, archive_status_text(main_txn.txn_type)), + tax_rate=0, + total_amount=total_amount, + address_label="涉及仓库", + address="、".join(related_warehouse_names), + remark=";".join(remark_parts), + lines=lines, + ) + + +def warehouse_operation_txn_label(txn_type: str | None) -> str: + text = str(txn_type or "").strip() + if not text: + return "仓库操作" + normalized = text.upper() + label = WAREHOUSE_OPERATION_TXN_LABELS.get(normalized) + if label: + return label + if re.fullmatch(r"[A-Za-z0-9_]+", text): + return "未识别仓库操作" + return text + + +def warehouse_operation_document_title(txn_type: str | None) -> str: + label = warehouse_operation_txn_label(txn_type) + if label.endswith("单"): + return label + if label.endswith(("入库", "出库", "操作")): + return f"{label}单" + return f"{label}单" + + +def warehouse_operation_document_no(title: str, txn_no: str) -> str: + title_prefix = title[:-1] if title.endswith("单") else title + return f"{title_prefix}-{txn_no}" + + +def _warehouse_operation_quantity(txn: InventoryTxn) -> Decimal: + qty_change = Decimal(str(txn.qty_change or 0)) + weight_change = Decimal(str(txn.weight_change_kg or 0)) + value = qty_change if qty_change != 0 else weight_change + return abs(value) + + +def collect_warehouse_operation_archive_context(db: Session, inventory_txn_id: int) -> ArchiveContext: + source_lot = aliased(StockLot) + row = db.execute( + select(InventoryTxn, Item, Warehouse, WarehouseLocation, StockLot, source_lot) + .join(Item, Item.id == InventoryTxn.item_id) + .join(Warehouse, Warehouse.id == InventoryTxn.warehouse_id) + .outerjoin(WarehouseLocation, WarehouseLocation.id == InventoryTxn.location_id) + .outerjoin(StockLot, StockLot.id == InventoryTxn.lot_id) + .outerjoin(source_lot, source_lot.id == StockLot.source_material_lot_id) + .where(InventoryTxn.id == inventory_txn_id) + ).one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="仓库库存流水不存在") + + txn, item, warehouse, location, lot, source_lot_row = row + document_title = warehouse_operation_document_title(txn.txn_type) + operation_label = warehouse_operation_txn_label(txn.txn_type) + location_name = location.location_name if location else None + lot_no = lot.lot_no if lot else None + source_lot_no = None + if source_lot_row: + source_lot_no = source_lot_row.lot_no + elif lot and lot.source_material_sub_batch_no: + source_lot_no = lot.source_material_sub_batch_no + + quantity = _warehouse_operation_quantity(txn) + received_or_delivered = abs(Decimal(str(txn.weight_change_kg or 0))) or quantity + line_remark_parts = [ + f"库存流水号:{txn.txn_no}", + f"操作类型:{operation_label}", + ] + if lot_no: + line_remark_parts.append(f"库存批次号:{lot_no}") + if source_lot_no: + line_remark_parts.append(f"来源库存批次号:{source_lot_no}") + if location_name: + line_remark_parts.append(f"库位:{location_name}") + if txn.logistics_waybill_no: + line_remark_parts.append(f"运单号:{txn.logistics_waybill_no}") + if txn.logistics_freight_amount is not None: + line_remark_parts.append(f"运费:{decimal_text(txn.logistics_freight_amount, 2)}") + if txn.logistics_photo_url: + line_remark_parts.append("辅助照片:已上传") + if txn.remark: + line_remark_parts.append(str(txn.remark)) + + remark_parts = [ + f"库存流水号:{txn.txn_no}", + f"操作类型:{operation_label}", + f"仓库:{warehouse.warehouse_name}", + ] + if lot_no: + remark_parts.append(f"库存批次号:{lot_no}") + if source_lot_no: + remark_parts.append(f"来源库存批次号:{source_lot_no}") + if location_name: + remark_parts.append(f"库位:{location_name}") + if txn.source_doc_type: + remark_parts.append(f"来源单据:{txn.source_doc_type}-{txn.source_doc_id}") + if txn.remark: + remark_parts.append(str(txn.remark)) + + address_parts = [warehouse.warehouse_name] + if location_name: + address_parts.append(location_name) + + return ArchiveContext( + document_type=DOCUMENT_TYPE_WAREHOUSE_OPERATION, + business_id=txn.id, + document_no=warehouse_operation_document_no(document_title, txn.txn_no), + title=f"{document_title}归档", + partner_label="仓库", + partner_name=warehouse.warehouse_name, + document_date=txn.biz_time, + due_date_label="库存流水", + due_date=txn.txn_no, + status=operation_label, + tax_rate=0, + total_amount=txn.amount, + address=" / ".join(address_parts), + remark=";".join(remark_parts), + lines=[ + ArchiveLine( + line_no=1, + item_code=item.item_code, + item_name=item.item_name, + specification=item.specification, + quantity=quantity, + delivered_or_received_quantity=received_or_delivered, + unit_price=txn.unit_cost, + line_amount=txn.amount, + promised_or_expected_date=txn.biz_time, + remark=";".join(line_remark_parts), + ) + ], + ) + + +def collect_archive_context(db: Session, document_type: str, business_id: int) -> ArchiveContext: + if document_type == DOCUMENT_TYPE_SALES_ORDER: + return collect_sales_order_archive_context(db, business_id) + if document_type == DOCUMENT_TYPE_PURCHASE_ORDER: + return collect_purchase_order_archive_context(db, business_id) + if document_type == DOCUMENT_TYPE_PURCHASE_RECEIPT: + return collect_purchase_receipt_archive_context(db, business_id) + if document_type == DOCUMENT_TYPE_QUALITY_INSPECTION: + return collect_quality_inspection_archive_context(db, business_id) + if document_type == DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT: + return collect_production_material_out_archive_context(db, business_id) + if document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT: + return collect_production_inbound_settlement_archive_context(db, business_id) + if document_type == DOCUMENT_TYPE_WAREHOUSE_OPERATION: + return collect_warehouse_operation_archive_context(db, business_id) + raise HTTPException(status_code=400, detail="不支持的单据类型") + + +def _register_archive_font() -> tuple[str, str]: + try: + pdfmetrics.registerFont(UnicodeCIDFont("STSong-Light")) + return "STSong-Light", "STSong-Light" + except Exception: + return "Helvetica", "Helvetica-Bold" + + +def _cell_line_height(size: int) -> float: + return size + 3 + + +def _wrap_text_to_lines(text: str, font_name: str, size: int, max_width: float) -> list[str]: + if max_width <= 0: + return [""] + + value = str(text or "") + if value == "": + return [""] + + lines: list[str] = [] + for paragraph in value.replace("\r\n", "\n").replace("\r", "\n").split("\n"): + if paragraph == "": + lines.append("") + continue + + current = "" + for char in paragraph: + candidate = f"{current}{char}" + if current and pdfmetrics.stringWidth(candidate, font_name, size) > max_width: + lines.append(current) + current = char + else: + current = candidate + lines.append(current) + return lines or [""] + + +def _cell_required_height(text: str, font_name: str, size: int, width: float) -> float: + lines = _wrap_text_to_lines(text, font_name, size, max(0, width - ARCHIVE_CELL_HORIZONTAL_PADDING)) + return max(ARCHIVE_MIN_ROW_HEIGHT, len(lines) * _cell_line_height(size) + ARCHIVE_CELL_VERTICAL_PADDING) + + +def _row_required_height(values: list[str], columns: list[tuple[str, int, str]], font_name: str, size: int) -> float: + return max( + _cell_required_height(value, font_name, size, col_w) + for value, (_, col_w, _) in zip(values, columns, strict=True) + ) + + +def _draw_cell( + pdf: canvas.Canvas, + x: float, + y: float, + width: float, + height: float, + text: str, + font_name: str, + size: int = 8, + align: str = "left", +) -> None: + pdf.rect(x, y - height, width, height, stroke=1, fill=0) + pdf.setFont(font_name, size) + lines = _wrap_text_to_lines(text, font_name, size, max(0, width - ARCHIVE_CELL_HORIZONTAL_PADDING)) + line_height = _cell_line_height(size) + text_height = len(lines) * line_height + top_padding = max(4, (height - text_height) / 2) + text_y = y - top_padding - size + for line in lines: + if align == "right": + pdf.drawRightString(x + width - 4, text_y, line) + elif align == "center": + pdf.drawCentredString(x + width / 2, text_y, line) + else: + pdf.drawString(x + 4, text_y, line) + text_y -= line_height + + +def _archive_table_columns() -> list[tuple[str, int, str]]: + return [ + ("行", 26, "center"), + ("物料编码", 64, "left"), + ("物料名称", 94, "left"), + ("规格", 60, "left"), + ("数量", 50, "right"), + ("已交/收", 52, "right"), + ("单价", 48, "right"), + ("金额", 55, "right"), + ("日期/备注", 62, "left"), + ] + + +def _draw_archive_page_frame( + pdf: canvas.Canvas, + context: ArchiveContext, + font_name: str, + bold_font_name: str, + page_no: int, + total_pages: int, +) -> None: + width, height = A4 + pdf.setFillColorRGB(0.99, 0.975, 0.92) + pdf.rect(0, 0, width, height, stroke=0, fill=1) + pdf.setStrokeColor(colors.black) + pdf.setFillColor(colors.black) + pdf.rect(28, 28, width - 56, height - 56, stroke=1, fill=0) + + pdf.setFont(bold_font_name, 22) + title = context.title if page_no == 1 else f"{context.title}(续页)" + pdf.drawCentredString(width / 2, height - 58, title) + pdf.setFont(font_name, 10) + pdf.drawRightString(width - 42, height - 44, "ERP留存联") + pdf.drawRightString(width - 42, height - 62, "PDF归档") + pdf.drawRightString(width - 42, height - 80, f"第 {page_no}/{total_pages} 页") + + pdf.setFont(font_name, 10) + pdf.drawString(42, height - 92, f"单据编号: {context.document_no}") + pdf.drawString(42, height - 112, f"{context.partner_label}: {context.partner_name}") + pdf.drawString(42, height - 132, f"联系人: {context.contact_name or ''} 电话: {context.contact_phone or ''}") + pdf.drawString(42, height - 152, f"{context.address_label or '地址'}: {context.address or ''}") + pdf.drawString(340, height - 92, f"单据日期: {date_text(context.document_date)}") + pdf.drawString(340, height - 112, f"{context.due_date_label}: {date_text(context.due_date)}") + pdf.drawString(340, height - 132, f"状态: {context.status}") + pdf.drawString(340, height - 152, f"税率: {decimal_text((context.tax_rate or 0) * 100)}%") + + pdf.setFont(bold_font_name, 26) + pdf.setStrokeColorRGB(0.55, 0.05, 0.05) + pdf.setFillColorRGB(0.65, 0.05, 0.05) + pdf.rotate(12) + pdf.drawString(390, 500, "PDF归档") + pdf.rotate(-12) + pdf.setStrokeColor(colors.black) + pdf.setFillColor(colors.black) + + +def _draw_archive_table_header(pdf: canvas.Canvas, y: float, bold_font_name: str) -> None: + x = ARCHIVE_TABLE_X + for header, col_w, align in _archive_table_columns(): + _draw_cell(pdf, x, y, col_w, ARCHIVE_MIN_ROW_HEIGHT, header, bold_font_name, ARCHIVE_CELL_FONT_SIZE, align) + x += col_w + + +def _archive_line_values(line: ArchiveLine) -> list[str]: + return [ + str(line.line_no), + line.item_code, + line.item_name, + line.specification or "", + decimal_text(line.quantity, 6), + decimal_text(line.delivered_or_received_quantity, 6), + decimal_text(line.unit_price, 4), + decimal_text(line.line_amount, 2), + line.remark or date_text(line.promised_or_expected_date), + ] + + +def _draw_archive_table_rows( + pdf: canvas.Canvas, + lines: list[ArchiveLine], + y: float, + font_name: str, + minimum_blank_rows: int, +) -> float: + columns = _archive_table_columns() + for line in lines: + values = _archive_line_values(line) + row_h = _row_required_height(values, columns, font_name, ARCHIVE_CELL_FONT_SIZE) + x = ARCHIVE_TABLE_X + for value, (_, col_w, align) in zip(values, columns, strict=True): + _draw_cell(pdf, x, y, col_w, row_h, value, font_name, ARCHIVE_CELL_FONT_SIZE, align) + x += col_w + y -= row_h + + for _ in range(max(0, minimum_blank_rows - len(lines))): + x = ARCHIVE_TABLE_X + for _, col_w, align in columns: + _draw_cell(pdf, x, y, col_w, ARCHIVE_MIN_ROW_HEIGHT, "", font_name, ARCHIVE_CELL_FONT_SIZE, align) + x += col_w + y -= ARCHIVE_MIN_ROW_HEIGHT + return y + + +def _draw_archive_footer( + pdf: canvas.Canvas, + context: ArchiveContext, + y: float, + font_name: str, + bold_font_name: str, +) -> None: + width, _ = A4 + pdf.setFont(bold_font_name, 10) + pdf.drawRightString(width - 42, y - 20, f"合计金额: {decimal_text(context.total_amount, 2)}") + pdf.setFont(font_name, 9) + remark_lines = _wrap_text_to_lines(f"备注: {context.remark or ''}", font_name, 9, width - 84) + remark_y = 104 + for line in remark_lines: + pdf.drawString(42, remark_y, line) + remark_y -= _cell_line_height(9) + signature_y = min(70, remark_y - 4) + signature_y = max(46, signature_y) + pdf.drawString(42, signature_y, f"制单: {context.prepared_by or ''}") + pdf.drawString(176, signature_y, f"经办人: {context.operator_name or context.prepared_by or ''}") + pdf.drawString(310, signature_y, "审核: ") + pdf.drawString(444, signature_y, f"归档模板: {TEMPLATE_VERSION}") + + +def _archive_line_height(line: ArchiveLine, font_name: str) -> float: + return _row_required_height(_archive_line_values(line), _archive_table_columns(), font_name, ARCHIVE_CELL_FONT_SIZE) + + +def _line_pages(lines: list[ArchiveLine], max_body_height: float, font_name: str) -> list[list[ArchiveLine]]: + if not lines: + return [[]] + pages: list[list[ArchiveLine]] = [] + page_lines: list[ArchiveLine] = [] + used_height = 0.0 + for line in lines: + row_height = _archive_line_height(line, font_name) + if page_lines and used_height + row_height > max_body_height: + pages.append(page_lines) + page_lines = [] + used_height = 0.0 + page_lines.append(line) + used_height += row_height + if page_lines: + pages.append(page_lines) + return pages + + +def render_pdf_archive(context: ArchiveContext, output_path: str | Path) -> None: + output = Path(output_path) + output.parent.mkdir(parents=True, exist_ok=True) + font_name, bold_font_name = _register_archive_font() + pdf = canvas.Canvas(str(output), pagesize=A4) + height = A4[1] + table_y = height - ARCHIVE_TABLE_TOP_Y_OFFSET + row_h = ARCHIVE_MIN_ROW_HEIGHT + max_body_height = table_y - row_h - ARCHIVE_TABLE_BOTTOM_Y + pages = _line_pages(context.lines, max_body_height, font_name) + + for page_index, page_lines in enumerate(pages, start=1): + _draw_archive_page_frame(pdf, context, font_name, bold_font_name, page_index, len(pages)) + _draw_archive_table_header(pdf, table_y, bold_font_name) + y = _draw_archive_table_rows( + pdf, + page_lines, + table_y - row_h, + font_name, + minimum_blank_rows=6 if page_index == len(pages) else 0, + ) + _draw_archive_footer(pdf, context, y, font_name, bold_font_name) + pdf.showPage() + pdf.save() + + +def _archive_output_path(root: Path, context: ArchiveContext, archive_version: int) -> Path: + type_dir = safe_filename(context.document_type) + file_name = safe_filename(f"{context.document_no}_V{archive_version}") + ".pdf" + return root / type_dir / file_name + + +def _failed_archive( + *, + document_type: str, + business_id: int, + document_no: str, + archive_version: int, + error_message: str, + created_by: int | None, +) -> DocumentArchive: + return DocumentArchive( + document_type=document_type, + business_id=business_id, + document_no=document_no, + archive_version=archive_version, + template_version=TEMPLATE_VERSION, + file_format=FILE_FORMAT_PDF, + file_name="", + file_path="", + file_hash=None, + status=ARCHIVE_STATUS_FAILED, + error_message=error_message[:2000], + created_by=created_by, + ) + + +def _fallback_failed_archive_version(db: Session, document_type: str, business_id: int) -> int: + try: + return next_available_archive_version(db, document_type, business_id, 1) + except Exception: + db.rollback() + return 1 + + +def _persist_failed_archive_with_retry( + db: Session, + *, + document_type: str, + business_id: int, + document_no: str, + preferred_version: int, + error_message: str, + created_by: int | None, + max_attempts: int = 3, +) -> tuple[int, str]: + archive_version = preferred_version + last_error = error_message + for _ in range(max_attempts): + try: + archive_version = next_available_archive_version(db, document_type, business_id, archive_version) + failed = _failed_archive( + document_type=document_type, + business_id=business_id, + document_no=document_no, + archive_version=archive_version, + error_message=error_message, + created_by=created_by, + ) + db.add(failed) + db.commit() + return failed.archive_version, failed.error_message or error_message + except IntegrityError as exc: + db.rollback() + archive_version += 1 + last_error = f"{error_message}; failed archive version collision: {exc}" + except Exception as exc: + db.rollback() + return archive_version, f"{error_message}; failed archive persist error: {exc}" + return archive_version, last_error + + +def generate_document_archive( + db: Session, + document_type: str, + business_id: int, + created_by: int | None = None, + archive_root: str | Path | None = None, +) -> DocumentArchiveGenerateResult: + archive_version: int | None = None + document_no = f"{document_type}-{business_id}" + try: + archive_version = next_archive_version(db, document_type, business_id) + context = collect_archive_context(db, document_type, business_id) + apply_archive_operator_context(db, context, created_by) + document_no = context.document_no + root = Path(archive_root) if archive_root is not None else Path(default_archive_root()) + for _ in range(3): + archive_version = next_available_archive_version(db, document_type, business_id, archive_version) + output_path = _archive_output_path(root, context, archive_version) + render_pdf_archive(context, output_path) + + archive = DocumentArchive( + document_type=document_type, + business_id=business_id, + document_no=context.document_no, + archive_version=archive_version, + template_version=TEMPLATE_VERSION, + file_format=FILE_FORMAT_PDF, + file_name=output_path.name, + file_path=str(output_path), + file_hash=sha256_file(output_path), + status=ARCHIVE_STATUS_READY, + error_message=None, + created_by=created_by, + ) + db.add(archive) + try: + db.commit() + db.refresh(archive) + return DocumentArchiveGenerateResult( + business_id=business_id, + document_type=document_type, + document_no=context.document_no, + archive_status=ARCHIVE_STATUS_READY, + archive_version=archive.archive_version, + archive_error_message=None, + ) + except IntegrityError: + db.rollback() + archive_version += 1 + raise RuntimeError("archive version collision after retries") + except HTTPException: + db.rollback() + raise + except Exception as exc: + db.rollback() + failed_archive_version, failed_error_message = _persist_failed_archive_with_retry( + db, + document_type=document_type, + business_id=business_id, + document_no=document_no, + preferred_version=archive_version or _fallback_failed_archive_version(db, document_type, business_id), + error_message=str(exc), + created_by=created_by, + ) + return DocumentArchiveGenerateResult( + business_id=business_id, + document_type=document_type, + document_no=document_no, + archive_status=ARCHIVE_STATUS_FAILED, + archive_version=failed_archive_version, + archive_error_message=failed_error_message, + ) + + +def build_archive_zip( + db: Session, + document_type: str, + business_ids: Iterable[int], + output_path: str | Path, +) -> Path: + output = Path(output_path) + output.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive_zip: + for business_id in business_ids: + latest = get_latest_archive(db, document_type, business_id) + if latest is None or latest.status != ARCHIVE_STATUS_READY: + continue + file_path = Path(latest.file_path) + if not file_path.exists(): + continue + archive_zip.write(file_path, arcname=latest.file_name or file_path.name) + return output diff --git a/backend/app/services/logistics.py b/backend/app/services/logistics.py new file mode 100644 index 0000000..25e59b5 --- /dev/null +++ b/backend/app/services/logistics.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from decimal import Decimal +from typing import Any + +from fastapi import HTTPException + +from app.services.operations import to_decimal + + +def normalize_logistics_fields( + waybill_no: str | None, + freight_amount: Any, + *, + required: bool, + order_photo_url: str | None = None, + freight_required: bool | None = None, +) -> tuple[str | None, Decimal | None, str | None]: + clean_waybill = str(waybill_no or "").strip() + clean_photo_url = str(order_photo_url or "").strip() + freight_missing = freight_amount is None or str(freight_amount).strip() == "" + must_have_freight = required if freight_required is None else freight_required + + if required and (not clean_waybill and not clean_photo_url) and freight_missing and freight_required is None: + raise HTTPException(status_code=400, detail="该出入库业务必须填写运单号或上传辅助照片,并填写运费") + if required and (not clean_waybill and not clean_photo_url): + raise HTTPException(status_code=400, detail="该出入库业务必须填写运单号或上传辅助照片") + if must_have_freight and freight_missing: + raise HTTPException(status_code=400, detail="该出入库业务必须填写运费") + if freight_missing: + return clean_waybill or None, None, clean_photo_url or None + + freight = to_decimal(freight_amount, "0.01") + if freight < 0: + raise HTTPException(status_code=400, detail="运费不能小于0") + return clean_waybill or None, freight, clean_photo_url or None diff --git a/backend/app/services/mock_data.py b/backend/app/services/mock_data.py new file mode 100644 index 0000000..c229e92 --- /dev/null +++ b/backend/app/services/mock_data.py @@ -0,0 +1,280 @@ +from __future__ import annotations + +from app.schemas.dashboard import AlertItem, BoardSection, DashboardPayload, FlowStep, OverviewMetric +from app.schemas.domain import CostInsight, InventoryLotSummary, MiniAppField, OperationCostLine, ProductSummary + + +def get_dashboard_payload() -> DashboardPayload: + return DashboardPayload( + company="宁波嘉恒智能科技有限公司", + overview=[ + OverviewMetric( + code="sales_open", + label="未交订单", + value="8", + unit="单", + note="其中 3 单未来 7 天交期", + ), + OverviewMetric( + code="mrp_gap", + label="待采购材料缺口", + value="59", + unit="吨", + highlight="warning", + note="SAP440 500*600*2.5 需按 2 吨倍数采购", + ), + OverviewMetric( + code="wip_orders", + label="在制工单", + value="6", + unit="张", + note="2 张存在工序卡滞", + ), + OverviewMetric( + code="scrap_alerts", + label="异常报废工序", + value="2", + unit="处", + highlight="danger", + note="冲压与焊接工序超阈值", + ), + OverviewMetric( + code="waiting_ship", + label="待发货成品", + value="3200", + unit="件", + note="本体产品优先发货", + ), + OverviewMetric( + code="maintenance_due", + label="设备待保养", + value="3", + unit="台", + highlight="warning", + note="含 1 台核心冲床", + ), + ], + alerts=[ + AlertItem( + level="danger", + title="冲压工序报废率 4.8%", + description="工单 MO-202604-003 的冲压工序高于允许报废率 2.0%,建议立即复核模具与板材批次。", + owner="车间主管", + action="检查模具并冻结异常批次", + ), + AlertItem( + level="warning", + title="SAP440 材料仅剩 8 吨可用", + description="按已排产工单计算,当前库存只能支撑 1.3 天,建议优先下达补料采购。", + owner="采购员", + action="生成补料采购单", + ), + AlertItem( + level="warning", + title="冲床 JH-CY-03 保养到期", + description="设备已连续运行 286 小时,超过设定保养上限 280 小时。", + owner="设备管理员", + action="创建保养工单", + ), + ], + flow_board=[ + BoardSection( + title="订单到采购", + items=[ + FlowStep( + step_code="sales", + title="订单 SO-202604-001", + status="done", + summary="产品 本体,下单数量 10000 件,客户交期 2026-04-28。", + quantity="10000 件", + ), + FlowStep( + step_code="mrp", + title="MRP 物料需求", + status="active", + summary="计算出 SAP440 钢板理论需求 59000kg,按采购倍数建议下单 60000kg。", + quantity="60 吨", + ), + FlowStep( + step_code="purchase", + title="采购到货", + status="queued", + summary="已到货 60000kg,待入库复核与批次挂账。", + quantity="60 吨", + ), + ], + ), + BoardSection( + title="领料到完工", + items=[ + FlowStep( + step_code="issue", + title="生产出库", + status="active", + summary="第一批已领料 22000kg,第二批计划明日发料。", + quantity="22 吨", + ), + FlowStep( + step_code="production", + title="6 道工序报工", + status="active", + summary="冲压工序存在异常报废,焊接工序节拍偏慢。", + quantity="在制 3800 件", + ), + FlowStep( + step_code="receipt", + title="成品待入库", + status="queued", + summary="首批完工 3200 件,待质检确认后转成品库。", + quantity="3200 件", + ), + ], + ), + ], + ) + + +def get_product_overview_summary() -> ProductSummary: + return ProductSummary( + product_code="FG-BT-001", + product_name="本体", + material_name="SAP440 500*600*2.5 钢板", + order_qty=10000, + unit_weight_kg=5.2, + gross_material_kg_per_piece=5.9, + planned_material_weight_kg=59000, + suggested_purchase_weight_kg=60000, + purchase_multiple_weight_kg=2000, + ) + + +def get_operation_costs() -> list[OperationCostLine]: + return [ + OperationCostLine( + seq_no=10, + operation_name="开平", + work_center="板材预处理区", + std_labor_cost=0.12, + std_machine_cost=0.18, + allowed_scrap_rate=0.5, + reported_good_qty=10000, + reported_scrap_qty=12, + abnormal=False, + ), + OperationCostLine( + seq_no=20, + operation_name="冲压", + work_center="冲压一区", + std_labor_cost=0.35, + std_machine_cost=0.42, + allowed_scrap_rate=2.0, + reported_good_qty=9580, + reported_scrap_qty=420, + abnormal=True, + ), + OperationCostLine( + seq_no=30, + operation_name="去毛刺", + work_center="后处理区", + std_labor_cost=0.08, + std_machine_cost=0.06, + allowed_scrap_rate=0.8, + reported_good_qty=9520, + reported_scrap_qty=25, + abnormal=False, + ), + OperationCostLine( + seq_no=40, + operation_name="折弯", + work_center="折弯一区", + std_labor_cost=0.22, + std_machine_cost=0.20, + allowed_scrap_rate=1.0, + reported_good_qty=9475, + reported_scrap_qty=18, + abnormal=False, + ), + OperationCostLine( + seq_no=50, + operation_name="焊接", + work_center="焊接二区", + std_labor_cost=0.48, + std_machine_cost=0.26, + allowed_scrap_rate=1.5, + reported_good_qty=9380, + reported_scrap_qty=85, + abnormal=True, + ), + OperationCostLine( + seq_no=60, + operation_name="检验包装", + work_center="成检包装区", + std_labor_cost=0.15, + std_machine_cost=0.04, + allowed_scrap_rate=0.2, + reported_good_qty=9320, + reported_scrap_qty=8, + abnormal=False, + ), + ] + + +def get_inventory_lots() -> list[InventoryLotSummary]: + return [ + InventoryLotSummary( + lot_no="RM-20260418-01", + item_name="SAP440 500*600*2.5 钢板", + warehouse="原材料仓", + location="A-01-03", + inbound_weight_kg=30000, + remaining_weight_kg=12000, + status="PARTIAL", + ), + InventoryLotSummary( + lot_no="RM-20260418-02", + item_name="SAP440 500*600*2.5 钢板", + warehouse="原材料仓", + location="A-01-04", + inbound_weight_kg=30000, + remaining_weight_kg=26000, + status="AVAILABLE", + ), + InventoryLotSummary( + lot_no="FG-20260420-01", + item_name="本体", + warehouse="成品仓", + location="FG-02-01", + inbound_weight_kg=16640, + remaining_weight_kg=16640, + status="WAIT_SHIP", + ), + ] + + +def get_mini_app_fields() -> list[MiniAppField]: + return [ + MiniAppField(field_name="report_no", label="报工单号", required=True, description="报工流水唯一编号"), + MiniAppField(field_name="work_order_no", label="工单号", required=True, description="与 ERP 工单主表关联"), + MiniAppField(field_name="operation_seq", label="工序序号", required=True, description="对应工艺路线工序"), + MiniAppField(field_name="employee_code", label="员工编码", required=True, description="报工人员账号"), + MiniAppField(field_name="equipment_code", label="设备编码", required=False, description="采集实际作业设备"), + MiniAppField(field_name="start_time", label="开始时间", required=True, description="工序开始加工时间"), + MiniAppField(field_name="end_time", label="结束时间", required=True, description="工序结束加工时间"), + MiniAppField(field_name="report_qty", label="报工数量", required=True, description="本次加工总数量"), + MiniAppField(field_name="good_qty", label="合格数量", required=True, description="用于流转到下道工序"), + MiniAppField(field_name="scrap_qty", label="报废数量", required=True, description="系统用于异常判定"), + MiniAppField(field_name="rework_qty", label="返工数量", required=False, description="返工件单独计成本"), + MiniAppField(field_name="scrap_reason", label="报废原因", required=False, description="用于工序和设备异常归因"), + MiniAppField(field_name="downtime_minutes", label="停机分钟数", required=False, description="用于设备稼动率分析"), + MiniAppField(field_name="remark", label="备注", required=False, description="现场异常补充说明"), + ] + + +def get_cost_insights() -> list[CostInsight]: + return [ + CostInsight(category="材料成本", amount=486000.0, ratio=72.4, note="含板材采购与运输"), + CostInsight(category="人工成本", amount=82100.0, ratio=12.2, note="按工序报工工时归集"), + CostInsight(category="设备成本", amount=46300.0, ratio=6.9, note="按设备开机工时分摊"), + CostInsight(category="制造费用", amount=37200.0, ratio=5.5, note="房租、水电、后勤、管理"), + CostInsight(category="异常损失", amount=20350.0, ratio=3.0, note="报废与返工"), + ] diff --git a/backend/app/services/operations.py b/backend/app/services/operations.py new file mode 100644 index 0000000..c1985cd --- /dev/null +++ b/backend/app/services/operations.py @@ -0,0 +1,4073 @@ +from __future__ import annotations + +from collections import defaultdict +from datetime import date, datetime, time, timedelta +from decimal import Decimal, ROUND_FLOOR +import re + +from fastapi import HTTPException +from sqlalchemy import Integer, Select, and_, bindparam, case, func, literal, or_, select, text +from sqlalchemy.orm import Session, aliased + +from app.models.document_archive import DocumentArchive +from app.models.master_data import Bom, BomItem, Item, Material, Product, StockBalance, Unit, Warehouse +from app.models.operations import ( + AccountingPeriod, + CompletionReceipt, + CompletionReceiptItem, + CostAllocation, + Delivery, + DeliveryItem, + Equipment, + InventoryTxn, + MaintenanceOrder, + MaintenancePlan, + OverheadEntry, + ProcessRoute, + ProcessRouteOperation, + PurchaseOrder, + PurchaseOrderItem, + PurchaseReceipt, + PurchaseReceiptItem, + PurchaseReturn, + PurchaseReturnItem, + ReturnDisposition, + ReturnItem, + ReturnOrder, + ScrapRecord, + StatementSnapshot, + StockLot, + Supplier, + WarehouseLocation, + WorkCenter, + WorkOrder, + WorkOrderMaterialIssue, + WorkOrderMaterial, + WorkOrderOperation, + OperationReport, +) +from app.models.org import Department, Employee, User +from app.models.planning import MaterialDemand +from app.models.sales import Customer, SalesOrder, SalesOrderItem +from app.schemas.operations import ( + CostAllocationRead, + ProductMaterialCostBoardRead, + ProductMaterialCostExpenseRead, + ProductMaterialCostRowRead, + ProductMaterialCostSummaryRead, +) +from app.services.document_archives import ( + ARCHIVE_STATUS_MISSING, + DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, + DOCUMENT_TYPE_WAREHOUSE_OPERATION, + FILE_FORMAT_PDF, +) +from app.services.sales_planning import code_token, decimalize, next_doc_no +from app.services.system_config import get_raw_material_lot_prefix, lock_raw_material_lot_prefix_config + + +def to_decimal(value: float | Decimal | int | None, places: str = "0.000001") -> Decimal: + raw = value if value is not None else 0 + return decimalize(raw, places) + + +def start_of_day(value: date) -> datetime: + return datetime.combine(value, time.min) + + +def end_of_day(value: date) -> datetime: + return datetime.combine(value, time.max) + + +def clamp_non_negative(value: Decimal, places: str = "0.000001") -> Decimal: + return max(Decimal("0"), value).quantize(Decimal(places)) + + +def is_raw_material_item(db: Session, item_id: int) -> bool: + item = db.get(Item, item_id) + return bool(item and item.item_type == "RAW_MATERIAL") + + +def is_weight_only_stock_context(db: Session, item_id: int, warehouse_id: int) -> bool: + _ = item_id + warehouse = db.get(Warehouse, warehouse_id) + warehouse_type = str(warehouse.warehouse_type if warehouse else "").upper() + return warehouse_type in {"RAW", "SCRAP"} + + +def _raw_lot_sequence_width(next_sequence: int) -> int: + return 4 if next_sequence <= 9999 else len(str(next_sequence)) + + +def build_inventory_lot_no(db: Session, material_item_id: int) -> str: + _ = material_item_id + lock_raw_material_lot_prefix_config(db) + prefix = get_raw_material_lot_prefix(db) + existing_lot_nos = db.scalars( + select(StockLot.lot_no).where(StockLot.lot_no.like(f"{prefix}%")) + ).all() + max_sequence = 0 + pattern = re.compile(rf"{re.escape(prefix)}(\d+)") + for lot_no in existing_lot_nos: + match = pattern.fullmatch(str(lot_no)) + if match: + max_sequence = max(max_sequence, int(match.group(1))) + sequence = max_sequence + 1 + candidate = f"{prefix}{sequence:0{_raw_lot_sequence_width(sequence)}d}" + while db.scalar(select(func.count(StockLot.id)).where(StockLot.lot_no == candidate)): + sequence += 1 + candidate = f"{prefix}{sequence:0{_raw_lot_sequence_width(sequence)}d}" + return candidate + + +def build_yearly_product_work_order_no(db: Session, product: Item, now: datetime | None = None) -> str: + current_time = now or datetime.now() + year_part = current_time.strftime("%Y") + existing_nos = db.scalars(select(WorkOrder.work_order_no).where(WorkOrder.work_order_no.like(f"{year_part}____-%"))).all() + used_sequences = [ + int(work_order_no[4:8]) + for work_order_no in existing_nos + if len(work_order_no) > 8 + and work_order_no[:4] == year_part + and work_order_no[4:8].isdigit() + and work_order_no[8] == "-" + ] + next_sequence = max(used_sequences, default=0) + 1 + product_name = " ".join(str(product.item_name or product.item_code or f"产品{product.id}").split()).strip("- ") or f"产品{product.id}" + max_product_name_length = max(80 - len(year_part) - 4 - 1, 1) + return f"{year_part}{next_sequence:04d}-{product_name[:max_product_name_length]}" + + +def build_raw_lot_product_work_order_no(*, biz_time: datetime, lot_no: str, product: Item) -> str: + date_part = biz_time.strftime("%Y%m%d") + normalized_product_name = " ".join(str(product.item_name or product.item_code or f"产品{product.id}").split()).strip("- ") + product_name = normalized_product_name or str(product.item_code or f"产品{product.id}") + prefix = f"{date_part}-{lot_no}-" + max_product_name_length = max(80 - len(prefix), 0) + return f"{prefix}{product_name[:max_product_name_length]}"[:80] + + +def first_positive_decimal(*values: Decimal) -> Decimal: + for value in values: + if value > 0: + return value + return Decimal("0") + + +FINANCE_EXPENSE_LAYOUT = [ + ("material_usage_value", "使用原材料价值:"), + ("material_loss_value", "不良率损耗:"), + ("operator_wage", "操作工人工资:(按照报工系统核实)"), + ("manager_wage", "管理人员工资:"), + ("finance_charge", "财务费用:"), + ("mould_maintenance", "模具维护成本:"), + ("outsource_process", "(生产)制造费用 -外协加工费"), + ("production_packaging", "(生产)制造费用-生产包装"), + ("equipment_depreciation", "设备折旧费"), + ("utility", "水电能耗:"), + ("office_supplies", "管理费用-办公用品:"), + ("factory_rent", "厂房租金:"), + ("temporary_wage", "临时工工资:"), + ("staff_welfare", "职工福利-餐费、饮品等"), + ("social_insurance", "社保费:"), + ("cash_misc", "现金支出的白条费用:"), + ("other", "其它:"), +] + +OVERHEAD_TYPE_ALIASES = { + "OPERATOR_WAGE": "operator_wage", + "DIRECT_LABOR": "operator_wage", + "LABOR": "operator_wage", + "MANAGER_WAGE": "manager_wage", + "MANAGER_SALARY": "manager_wage", + "MANAGEMENT_WAGE": "manager_wage", + "FINANCE_CHARGE": "finance_charge", + "FINANCE_FEE": "finance_charge", + "FINANCIAL_EXPENSE": "finance_charge", + "MOULD_MAINTENANCE": "mould_maintenance", + "MOLD_MAINTENANCE": "mould_maintenance", + "OUTSOURCE_PROCESS": "outsource_process", + "OUTSOURCE_FEE": "outsource_process", + "PRODUCTION_PACKAGING": "production_packaging", + "PACKAGING": "production_packaging", + "EQUIPMENT_DEPRECIATION": "equipment_depreciation", + "DEPRECIATION": "equipment_depreciation", + "UTILITY": "utility", + "WATER_ELECTRIC": "utility", + "OFFICE_SUPPLIES": "office_supplies", + "FACTORY_RENT": "factory_rent", + "RENT": "factory_rent", + "TEMPORARY_WAGE": "temporary_wage", + "TEMP_WAGE": "temporary_wage", + "STAFF_WELFARE": "staff_welfare", + "WELFARE": "staff_welfare", + "SOCIAL_INSURANCE": "social_insurance", + "INSURANCE": "social_insurance", + "CASH_MISC": "cash_misc", + "CASH_WHITE_STRIP": "cash_misc", + "OTHER": "other", +} + + +def _to_decimal_sum(value: float | Decimal | int | None, places: str = "0.000001") -> Decimal: + return to_decimal(value, places) + + +def _to_float(value: Decimal | float | int | None, places: str = "0.000001") -> float: + return float(to_decimal(value, places)) + + +def _normalize_overhead_type(value: str | None) -> str | None: + if not value: + return None + normalized = str(value).strip().upper() + return OVERHEAD_TYPE_ALIASES.get(normalized, "other") + + +def _join_distinct(parts: list[str]) -> str | None: + cleaned = [part for part in parts if part] + if not cleaned: + return None + result: list[str] = [] + seen: set[str] = set() + for part in cleaned: + if part in seen: + continue + seen.add(part) + result.append(part) + return " + ".join(result) + + +def _to_candidate_datetime(value: date | datetime | None) -> datetime | None: + if value is None: + return None + if isinstance(value, datetime): + return value + if isinstance(value, date): + return datetime.combine(value, time.min) + return None + + +def _extract_overhead_effective_date(description: str | None) -> date | None: + if not description: + return None + match = re.search(r"\[EXPENSE_DATE:(\d{4}-\d{2}-\d{2})\]", description) + if not match: + return None + try: + return date.fromisoformat(match.group(1)) + except ValueError: + return None + + +def build_supplier_code(db: Session | None = None) -> str: + date_part = date.today().strftime("%Y%m%d") + prefix = f"SUP-NB-{date_part}" + if db is None: + return f"{prefix}-{datetime.now().strftime('%H%M%S')}" + return next_doc_no(db, Supplier, "supplier_code", prefix) + + +def get_default_location(db: Session, warehouse_id: int) -> WarehouseLocation | None: + return db.scalar( + select(WarehouseLocation) + .where( + WarehouseLocation.warehouse_id == warehouse_id, + WarehouseLocation.status == "ACTIVE", + ) + .order_by(WarehouseLocation.is_default.desc(), WarehouseLocation.id) + ) + + +def get_location_or_default(db: Session, warehouse_id: int, location_id: int | None) -> WarehouseLocation | None: + if location_id: + location = db.get(WarehouseLocation, location_id) + if not location: + raise HTTPException(status_code=404, detail="库位不存在") + return location + return get_default_location(db, warehouse_id) + + +def get_default_bom(db: Session, product_item_id: int) -> Bom: + bom = db.scalar( + select(Bom).where( + Bom.product_item_id == product_item_id, + Bom.is_default == 1, + Bom.status == "ACTIVE", + ) + ) + if not bom: + raise HTTPException(status_code=400, detail="产品缺少默认 BOM") + return bom + + +def get_default_route(db: Session, product_item_id: int) -> ProcessRoute: + route = db.scalar( + select(ProcessRoute).where( + ProcessRoute.product_item_id == product_item_id, + ProcessRoute.is_default == 1, + ProcessRoute.status == "ACTIVE", + ) + ) + if not route: + raise HTTPException(status_code=400, detail="产品缺少默认工艺路线") + return route + + +def get_stock_balance_record( + db: Session, + item_id: int, + warehouse_id: int, + location_id: int | None, +) -> StockBalance | None: + stmt = select(StockBalance).where( + StockBalance.item_id == item_id, + StockBalance.warehouse_id == warehouse_id, + ) + if location_id is None: + stmt = stmt.where(StockBalance.location_id.is_(None)) + else: + stmt = stmt.where(StockBalance.location_id == location_id) + stmt = stmt.with_for_update() + return db.scalar(stmt) + + +def _assert_balance_delta_not_negative(current: Decimal, delta: Decimal, label: str) -> None: + if to_decimal(current) + to_decimal(delta) < 0: + raise HTTPException(status_code=400, detail=f"库存余额不足,不能执行出库扣减:{label}不足") + + +def upsert_stock_balance( + db: Session, + *, + item_id: int, + warehouse_id: int, + location_id: int | None, + qty_delta: Decimal, + weight_delta: Decimal, + available_qty_delta: Decimal | None = None, + available_weight_delta: Decimal | None = None, + allocated_qty_delta: Decimal | None = None, + allocated_weight_delta: Decimal | None = None, + unit_cost: Decimal | None = None, +) -> StockBalance: + weight_only_stock = is_weight_only_stock_context(db, item_id, warehouse_id) + if weight_only_stock: + qty_delta = Decimal("0") + available_qty_delta = Decimal("0") if available_qty_delta is not None else None + allocated_qty_delta = Decimal("0") if allocated_qty_delta is not None else None + + effective_available_qty_delta = available_qty_delta if available_qty_delta is not None else qty_delta + effective_available_weight_delta = available_weight_delta if available_weight_delta is not None else weight_delta + effective_allocated_qty_delta = allocated_qty_delta if allocated_qty_delta is not None else Decimal("0") + effective_allocated_weight_delta = allocated_weight_delta if allocated_weight_delta is not None else Decimal("0") + + balance = get_stock_balance_record(db, item_id, warehouse_id, location_id) + if balance is None: + _assert_balance_delta_not_negative(Decimal("0"), qty_delta, "现存数量") + _assert_balance_delta_not_negative(Decimal("0"), weight_delta, "现存重量") + _assert_balance_delta_not_negative(Decimal("0"), effective_available_qty_delta, "可用数量") + _assert_balance_delta_not_negative(Decimal("0"), effective_available_weight_delta, "可用重量") + _assert_balance_delta_not_negative(Decimal("0"), effective_allocated_qty_delta, "已分配数量") + _assert_balance_delta_not_negative(Decimal("0"), effective_allocated_weight_delta, "已分配重量") + else: + _assert_balance_delta_not_negative(to_decimal(balance.qty_on_hand), qty_delta, "现存数量") + _assert_balance_delta_not_negative(to_decimal(balance.weight_on_hand_kg), weight_delta, "现存重量") + _assert_balance_delta_not_negative(to_decimal(balance.qty_available), effective_available_qty_delta, "可用数量") + _assert_balance_delta_not_negative( + to_decimal(balance.weight_available_kg), + effective_available_weight_delta, + "可用重量", + ) + _assert_balance_delta_not_negative(to_decimal(balance.qty_allocated), effective_allocated_qty_delta, "已分配数量") + _assert_balance_delta_not_negative( + to_decimal(balance.weight_allocated_kg), + effective_allocated_weight_delta, + "已分配重量", + ) + + if balance is None: + balance = StockBalance( + item_id=item_id, + warehouse_id=warehouse_id, + location_id=location_id, + qty_on_hand=to_decimal(0), + weight_on_hand_kg=to_decimal(0), + qty_available=to_decimal(0), + weight_available_kg=to_decimal(0), + qty_allocated=to_decimal(0), + weight_allocated_kg=to_decimal(0), + avg_unit_cost=to_decimal(unit_cost or 0, "0.0001"), + ) + db.add(balance) + db.flush() + + previous_weight = to_decimal(balance.weight_on_hand_kg) + previous_cost = to_decimal(balance.avg_unit_cost, "0.0001") + incoming_weight = max(Decimal("0"), weight_delta) + incoming_cost = to_decimal(unit_cost or 0, "0.0001") + + balance.qty_on_hand = clamp_non_negative(to_decimal(balance.qty_on_hand) + qty_delta) + balance.weight_on_hand_kg = clamp_non_negative(to_decimal(balance.weight_on_hand_kg) + weight_delta) + balance.qty_available = clamp_non_negative(to_decimal(balance.qty_available) + effective_available_qty_delta) + balance.weight_available_kg = clamp_non_negative( + to_decimal(balance.weight_available_kg) + effective_available_weight_delta + ) + balance.qty_allocated = clamp_non_negative(to_decimal(balance.qty_allocated) + effective_allocated_qty_delta) + balance.weight_allocated_kg = clamp_non_negative( + to_decimal(balance.weight_allocated_kg) + effective_allocated_weight_delta + ) + if weight_only_stock: + balance.qty_on_hand = Decimal("0") + balance.qty_available = Decimal("0") + balance.qty_allocated = Decimal("0") + + if incoming_weight > 0: + total_weight = previous_weight + incoming_weight + if total_weight > 0: + weighted_cost = ((previous_weight * previous_cost) + (incoming_weight * incoming_cost)) / total_weight + balance.avg_unit_cost = to_decimal(weighted_cost, "0.0001") + elif unit_cost is not None and to_decimal(balance.avg_unit_cost, "0.0001") == 0: + balance.avg_unit_cost = incoming_cost + + db.add(balance) + return balance + + +def create_inventory_txn( + db: Session, + *, + txn_type: str, + item_id: int, + warehouse_id: int, + location_id: int | None, + lot_id: int | None, + qty_change: Decimal, + weight_change: Decimal, + unit_cost: Decimal, + source_doc_type: str, + source_doc_id: int, + source_line_id: int | None, + biz_time: datetime, + operator_user_id: int | None, + remark: str | None = None, + amount_basis: str = "QTY", + logistics_waybill_no: str | None = None, + logistics_freight_amount: Decimal | None = None, + logistics_photo_url: str | None = None, +) -> InventoryTxn: + if is_weight_only_stock_context(db, item_id, warehouse_id): + qty_change = Decimal("0") + if abs(weight_change) > 0: + amount_basis = "WEIGHT" + + base_value = abs(weight_change) if amount_basis == "WEIGHT" and abs(weight_change) > 0 else abs(qty_change) + amount = to_decimal(base_value * unit_cost, "0.01") + txn = InventoryTxn( + txn_no=next_doc_no( + db, + InventoryTxn, + "txn_no", + f"TXN-NB-{biz_time.strftime('%Y%m%d')}-{code_token(txn_type, 'BIZ', 10)}-{code_token(source_doc_type, 'SRC', 10)}", + ), + txn_type=txn_type, + item_id=item_id, + warehouse_id=warehouse_id, + location_id=location_id, + lot_id=lot_id, + qty_change=qty_change, + weight_change_kg=weight_change, + unit_cost=to_decimal(unit_cost, "0.0001"), + amount=amount, + source_doc_type=source_doc_type, + source_doc_id=source_doc_id, + source_line_id=source_line_id, + biz_time=biz_time, + operator_user_id=operator_user_id, + logistics_waybill_no=logistics_waybill_no, + logistics_freight_amount=logistics_freight_amount, + logistics_photo_url=logistics_photo_url, + remark=remark, + ) + db.add(txn) + db.flush() + return txn + + +def issue_item_from_lots( + db: Session, + *, + item_id: int, + required_qty: Decimal, + required_weight: Decimal, + enforce_qty: bool, + enforce_weight: bool, + source_doc_id: int, + source_line_id: int | None, + biz_time: datetime, + operator_user_id: int | None, + remark: str | None = None, +) -> tuple[Decimal, Decimal, Decimal]: + raw_material = is_raw_material_item(db, item_id) + if raw_material: + enforce_qty = False + enforce_weight = required_weight > 0 + + remaining_weight = required_weight if enforce_weight else Decimal("0") + remaining_qty = required_qty if enforce_qty else Decimal("0") + total_qty = Decimal("0") + total_weight = Decimal("0") + total_amount = Decimal("0") + + lot_filters = [ + StockLot.item_id == item_id, + StockLot.quality_status == "PASS", + StockLot.status == "AVAILABLE", + StockLot.remaining_weight_kg > 0 if raw_material else or_(StockLot.remaining_qty > 0, StockLot.remaining_weight_kg > 0), + ] + if raw_material: + lot_filters.append(StockLot.warehouse_id.in_(select(Warehouse.id).where(Warehouse.warehouse_type == "RAW"))) + + lots = db.scalars( + select(StockLot) + .where(*lot_filters) + .order_by(StockLot.created_at, StockLot.id) + ).all() + + for lot in lots: + available_qty = Decimal("0") if raw_material else clamp_non_negative(to_decimal(lot.remaining_qty) - to_decimal(lot.locked_qty)) + available_weight = clamp_non_negative( + to_decimal(lot.remaining_weight_kg) - to_decimal(lot.locked_weight_kg) + ) + if (raw_material and available_weight <= 0) or (not raw_material and available_qty <= 0 and available_weight <= 0): + continue + + if raw_material: + take_weight = min(remaining_weight, available_weight) + take_qty = Decimal("0") + elif remaining_weight > 0 and available_weight > 0: + take_weight = min(remaining_weight, available_weight) + ratio = take_weight / available_weight if available_weight > 0 else Decimal("0") + take_qty = available_qty * ratio if available_qty > 0 else Decimal("0") + else: + take_qty = min(remaining_qty, available_qty) + ratio = take_qty / available_qty if available_qty > 0 else Decimal("0") + take_weight = available_weight * ratio if available_weight > 0 else Decimal("0") + + take_qty = clamp_non_negative(take_qty) + take_weight = clamp_non_negative(take_weight) + if take_qty <= 0 and take_weight <= 0: + continue + + lot.remaining_qty = Decimal("0") if raw_material else clamp_non_negative(to_decimal(lot.remaining_qty) - take_qty) + lot.remaining_weight_kg = clamp_non_negative(to_decimal(lot.remaining_weight_kg) - take_weight) + if raw_material: + lot.locked_qty = Decimal("0") + if (raw_material and lot.remaining_weight_kg <= 0) or (not raw_material and lot.remaining_qty <= 0 and lot.remaining_weight_kg <= 0): + lot.status = "DEPLETED" + db.add(lot) + + upsert_stock_balance( + db, + item_id=item_id, + warehouse_id=lot.warehouse_id, + location_id=lot.location_id, + qty_delta=-take_qty, + weight_delta=-take_weight, + available_qty_delta=-take_qty, + available_weight_delta=-take_weight, + unit_cost=to_decimal(lot.unit_cost, "0.0001"), + ) + txn = create_inventory_txn( + db, + txn_type="MATERIAL_ISSUE", + item_id=item_id, + warehouse_id=lot.warehouse_id, + location_id=lot.location_id, + lot_id=lot.id, + qty_change=-take_qty, + weight_change=-take_weight, + unit_cost=to_decimal(lot.unit_cost, "0.0001"), + source_doc_type="WORK_ORDER", + source_doc_id=source_doc_id, + source_line_id=source_line_id, + biz_time=biz_time, + operator_user_id=operator_user_id, + remark=remark, + amount_basis="WEIGHT" if take_weight > 0 else "QTY", + ) + material_sub_batch_no = lot.lot_no + txn.remark = f"{remark or '生产领料'};库存批次号 {material_sub_batch_no}" + db.add(txn) + if source_line_id: + db.add( + WorkOrderMaterialIssue( + work_order_material_id=source_line_id, + work_order_id=source_doc_id, + material_item_id=item_id, + source_lot_id=lot.id, + material_sub_batch_no=material_sub_batch_no, + issued_qty=take_qty, + issued_weight_kg=take_weight, + unit_cost=to_decimal(lot.unit_cost, "0.0001"), + amount=to_decimal(txn.amount, "0.01"), + issue_time=biz_time, + operator_user_id=operator_user_id, + remark=remark, + ) + ) + + total_qty += take_qty + total_weight += take_weight + total_amount += to_decimal(txn.amount, "0.01") + remaining_qty = clamp_non_negative(remaining_qty - take_qty) + remaining_weight = clamp_non_negative(remaining_weight - take_weight) + + if remaining_qty <= 0 and remaining_weight <= 0: + break + + if (enforce_qty and remaining_qty > 0) or (enforce_weight and remaining_weight > 0): + raise HTTPException(status_code=400, detail="原材料可用库存不足,无法自动领料") + + return total_qty, total_weight, total_amount + + +def issue_selected_stock_lots( + db: Session, + *, + work_order_id: int, + work_order_material_id: int, + material_item_id: int, + selected_lots: list[dict], + operator_user_id: int | None, +) -> list[WorkOrderMaterialIssue]: + if not selected_lots: + raise HTTPException(status_code=400, detail="请选择至少一个库存批次") + + source_lot_ids = [int(row.get("source_lot_id") or 0) for row in selected_lots] + if len(source_lot_ids) != len(set(source_lot_ids)): + raise HTTPException(status_code=400, detail="库存批次重复选择") + + issues: list[WorkOrderMaterialIssue] = [] + biz_time = datetime.now() + for row in selected_lots: + source_lot_id = int(row.get("source_lot_id") or 0) + issued_weight = to_decimal(row.get("issued_weight_kg")) + if issued_weight <= 0: + raise HTTPException(status_code=400, detail="出库重量必须大于0") + + lot = db.scalar(select(StockLot).where(StockLot.id == source_lot_id).with_for_update()) + if not lot or int(lot.item_id) != int(material_item_id): + raise HTTPException(status_code=400, detail="库存批次不存在或物料不匹配") + warehouse = db.get(Warehouse, lot.warehouse_id) + if not warehouse or warehouse.warehouse_type != "RAW": + raise HTTPException(status_code=400, detail="库存批次不属于原材料库") + + usable_weight = clamp_non_negative(to_decimal(lot.remaining_weight_kg) - to_decimal(lot.locked_weight_kg)) + if lot.quality_status != "PASS" or lot.status != "AVAILABLE" or usable_weight <= 0: + raise HTTPException(status_code=400, detail=f"库存批次 {lot.lot_no} 不可用") + if issued_weight > usable_weight: + raise HTTPException(status_code=400, detail=f"库存批次 {lot.lot_no} 剩余重量不足") + + lot.remaining_qty = Decimal("0") + lot.remaining_weight_kg = clamp_non_negative(to_decimal(lot.remaining_weight_kg) - issued_weight) + if to_decimal(lot.remaining_weight_kg) <= 0: + lot.status = "DEPLETED" + db.add(lot) + + unit_cost = to_decimal(lot.unit_cost, "0.0001") + upsert_stock_balance( + db, + item_id=material_item_id, + warehouse_id=lot.warehouse_id, + location_id=lot.location_id, + qty_delta=Decimal("0"), + weight_delta=-issued_weight, + available_qty_delta=Decimal("0"), + available_weight_delta=-issued_weight, + unit_cost=unit_cost, + ) + txn = create_inventory_txn( + db, + txn_type="MATERIAL_ISSUE", + item_id=material_item_id, + warehouse_id=lot.warehouse_id, + location_id=lot.location_id, + lot_id=lot.id, + qty_change=Decimal("0"), + weight_change=-issued_weight, + unit_cost=unit_cost, + source_doc_type="WORK_ORDER", + source_doc_id=work_order_id, + source_line_id=work_order_material_id, + biz_time=biz_time, + operator_user_id=operator_user_id, + remark=f"生产出库:{lot.lot_no}", + amount_basis="WEIGHT", + ) + db.add(txn) + + issue = WorkOrderMaterialIssue( + work_order_material_id=work_order_material_id, + work_order_id=work_order_id, + material_item_id=material_item_id, + source_lot_id=lot.id, + material_sub_batch_no=lot.lot_no, + issued_qty=Decimal("0"), + issued_weight_kg=issued_weight, + unit_cost=unit_cost, + amount=to_decimal(issued_weight * unit_cost, "0.01"), + issue_time=biz_time, + operator_user_id=operator_user_id, + remark=f"生产出库:{lot.lot_no}", + ) + db.add(issue) + issues.append(issue) + + db.flush() + return issues + + +def sync_purchase_order_item_status(item: PurchaseOrderItem) -> None: + ordered_qty = to_decimal(item.order_qty) + received_qty = to_decimal(item.received_qty) + ordered_weight = to_decimal(item.order_weight_kg) + received_weight = to_decimal(item.received_weight_kg) + if ordered_qty > 0: + if received_qty >= ordered_qty: + item.status = "CLOSED" + elif received_qty > 0: + item.status = "PARTIAL" + else: + item.status = "OPEN" + elif ordered_weight > 0 and received_weight >= ordered_weight: + item.status = "CLOSED" + elif received_weight > 0: + item.status = "PARTIAL" + else: + item.status = "OPEN" + + +def recalc_purchase_order_item_received_weight(db: Session, purchase_order_item_id: int) -> PurchaseOrderItem | None: + item = db.get(PurchaseOrderItem, purchase_order_item_id) + if not item: + return None + accepted_qty = db.scalar( + select(func.coalesce(func.sum(PurchaseReceiptItem.accepted_qty), 0)) + .where( + PurchaseReceiptItem.purchase_order_item_id == purchase_order_item_id, + PurchaseReceiptItem.status == "PASSED", + ) + ) or Decimal("0") + accepted_weight = db.scalar( + select(func.coalesce(func.sum(PurchaseReceiptItem.accepted_weight_kg), 0)) + .where( + PurchaseReceiptItem.purchase_order_item_id == purchase_order_item_id, + PurchaseReceiptItem.status == "PASSED", + ) + ) or Decimal("0") + item.received_qty = to_decimal(accepted_qty) + item.received_weight_kg = to_decimal(accepted_weight) + sync_purchase_order_item_status(item) + db.add(item) + return item + + +def sync_purchase_order_status(db: Session, purchase_order_id: int) -> None: + order = db.get(PurchaseOrder, purchase_order_id) + if not order: + return + if order.status == "LOCKED": + db.add(order) + return + items = db.scalars( + select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == purchase_order_id) + ).all() + pending_count = db.scalar( + select(func.count(PurchaseReceiptItem.id)) + .join(PurchaseReceipt, PurchaseReceipt.id == PurchaseReceiptItem.receipt_id) + .where(PurchaseReceipt.purchase_order_id == purchase_order_id, PurchaseReceiptItem.status == "PENDING_QC") + ) or 0 + rejected_count = db.scalar( + select(func.count(PurchaseReceiptItem.id)) + .join(PurchaseReceipt, PurchaseReceipt.id == PurchaseReceiptItem.receipt_id) + .where(PurchaseReceipt.purchase_order_id == purchase_order_id, PurchaseReceiptItem.status == "REJECTED") + ) or 0 + if pending_count > 0: + order.status = "PENDING_QC" + elif items and all(item.status == "CLOSED" for item in items): + order.status = "RECEIVED" + elif any(item.status in {"PARTIAL", "CLOSED"} for item in items): + order.status = "PARTIAL" + elif rejected_count > 0: + order.status = "REJECTED" + elif order.status in {"LOCKED", "PENDING_QC", "PARTIAL", "REJECTED", "RECEIVED"}: + order.status = "LOCKED" + else: + order.status = "OPEN" + db.add(order) + + +def sync_sales_order_status(db: Session, sales_order_id: int) -> None: + if not sales_order_id: + return + order = db.get(SalesOrder, sales_order_id) + if not order: + return + items = db.scalars(select(SalesOrderItem).where(SalesOrderItem.sales_order_id == sales_order_id)).all() + if items and all(to_decimal(item.delivered_qty) >= to_decimal(item.order_qty) for item in items): + order.status = "CLOSED" + elif any(to_decimal(item.delivered_qty) > 0 for item in items): + order.status = "PARTIAL" + else: + order.status = "OPEN" + db.add(order) + + +def sync_return_order_status(db: Session, return_order_id: int) -> None: + order = db.get(ReturnOrder, return_order_id) + if not order: + return + items = db.scalars(select(ReturnItem).where(ReturnItem.return_order_id == return_order_id)).all() + if items and all(item.disposition_status == "CLOSED" for item in items): + order.status = "CLOSED" + elif any(item.disposition_status != "PENDING" for item in items): + order.status = "PARTIAL" + else: + order.status = "CREATED" + db.add(order) + + +def sync_work_order_status(db: Session, work_order_id: int) -> None: + work_order = db.get(WorkOrder, work_order_id) + if not work_order: + return + if str(work_order.status or "").upper() == "SETTLED": + return + planned_qty = to_decimal(work_order.planned_qty) + finished_qty = to_decimal(work_order.finished_qty) + operation_count = db.scalar( + select(func.count(WorkOrderOperation.id)).where(WorkOrderOperation.work_order_id == work_order_id) + ) or 0 + done_count = db.scalar( + select(func.count(WorkOrderOperation.id)).where( + WorkOrderOperation.work_order_id == work_order_id, + WorkOrderOperation.status == "DONE", + ) + ) or 0 + if finished_qty >= planned_qty and planned_qty > 0: + work_order.status = "COMPLETED" + work_order.actual_end_time = work_order.actual_end_time or datetime.now() + elif done_count == operation_count and operation_count > 0: + work_order.status = "READY_TO_RECEIPT" + elif work_order.actual_start_time is not None or done_count > 0: + work_order.status = "IN_PROGRESS" + elif to_decimal(work_order.released_qty) > 0: + work_order.status = "RELEASED" + else: + work_order.status = "CREATED" + db.add(work_order) + + +def calculate_work_order_reference_qty(db: Session, work_order: WorkOrder) -> Decimal | None: + material_rows = db.execute( + select( + WorkOrderMaterial.id.label("work_order_material_id"), + WorkOrderMaterial.issued_weight_kg.label("issued_weight_kg"), + WorkOrderMaterial.required_weight_kg.label("required_weight_kg"), + BomItem.gross_weight_per_piece_kg.label("gross_weight_per_piece_kg"), + Item.unit_weight_kg.label("product_unit_weight_kg"), + ) + .join(BomItem, BomItem.id == WorkOrderMaterial.bom_item_id) + .join(WorkOrder, WorkOrder.id == WorkOrderMaterial.work_order_id) + .join(Item, Item.id == WorkOrder.product_item_id) + .where(WorkOrderMaterial.work_order_id == work_order.id) + .order_by(BomItem.seq_no, WorkOrderMaterial.id) + ).mappings().all() + material_row = next((row for row in material_rows if to_decimal(row["issued_weight_kg"]) > 0), None) + if not material_row and material_rows: + material_row = material_rows[0] + if not material_row: + return None + + issue_weight = to_decimal(material_row["issued_weight_kg"]) + if issue_weight <= 0: + issue_weight = to_decimal(material_row["required_weight_kg"]) + gross_weight = to_decimal(material_row["gross_weight_per_piece_kg"]) + if gross_weight <= 0: + gross_weight = to_decimal(material_row["product_unit_weight_kg"]) + if issue_weight <= 0 or gross_weight <= 0: + return None + return (issue_weight / gross_weight).to_integral_value(rounding=ROUND_FLOOR) + + +def sync_work_order_reference_qty(db: Session, work_order_id: int) -> bool: + work_order = db.get(WorkOrder, work_order_id) + if not work_order: + return False + reference_qty = calculate_work_order_reference_qty(db, work_order) + if reference_qty is None or reference_qty <= 0: + return False + + current_qty = to_decimal(work_order.planned_qty) + changed = current_qty != reference_qty or to_decimal(work_order.released_qty) != reference_qty + if changed: + work_order.planned_qty = reference_qty + work_order.released_qty = reference_qty + db.add(work_order) + + operations = db.scalars(select(WorkOrderOperation).where(WorkOrderOperation.work_order_id == work_order_id)).all() + for operation in operations: + if to_decimal(operation.planned_qty) != reference_qty: + operation.planned_qty = reference_qty + changed = True + report_qty = to_decimal(operation.report_qty) + next_status = "DONE" if report_qty >= reference_qty else "IN_PROGRESS" if report_qty > 0 else "PENDING" + if operation.status != next_status: + operation.status = next_status + changed = True + db.add(operation) + + material_rows = db.execute( + select(WorkOrderMaterial, BomItem) + .join(BomItem, BomItem.id == WorkOrderMaterial.bom_item_id) + .where(WorkOrderMaterial.work_order_id == work_order_id) + .order_by(BomItem.seq_no, WorkOrderMaterial.id) + ).all() + for material_row, bom_item in material_rows: + issued_weight = to_decimal(material_row.issued_weight_kg) + expected_required_weight = issued_weight if issued_weight > 0 else reference_qty * to_decimal(bom_item.gross_weight_per_piece_kg) + expected_required_weight = to_decimal(expected_required_weight) + if to_decimal(material_row.required_weight_kg) != expected_required_weight: + material_row.required_weight_kg = expected_required_weight + changed = True + db.add(material_row) + + if changed: + sync_work_order_status(db, work_order_id) + return changed + + +def sync_recent_work_order_reference_qty(db: Session, limit: int = 500) -> bool: + work_order_ids = db.scalars(select(WorkOrder.id).order_by(WorkOrder.id.desc()).limit(limit)).all() + changed = False + for work_order_id in work_order_ids: + changed = sync_work_order_reference_qty(db, work_order_id) or changed + return changed + + +def calculate_work_order_cost_summary(db: Session, work_order_id: int) -> dict[str, Decimal]: + work_order = db.get(WorkOrder, work_order_id) + if not work_order: + raise HTTPException(status_code=404, detail="工单不存在") + + material_amount = to_decimal( + db.scalar( + select(func.coalesce(func.sum(InventoryTxn.amount), 0)).where( + InventoryTxn.txn_type == "MATERIAL_ISSUE", + InventoryTxn.source_doc_type == "WORK_ORDER", + InventoryTxn.source_doc_id == work_order_id, + ) + ), + "0.01", + ) + + active_scrap_record_ids = ( + select(func.max(ScrapRecord.id).label("scrap_record_id")) + .where(ScrapRecord.status != "OVERRIDDEN") + .group_by(ScrapRecord.report_id) + .subquery() + ) + + operations = db.scalars( + select(WorkOrderOperation).where(WorkOrderOperation.work_order_id == work_order_id) + ).all() + labor_amount = Decimal("0") + machine_amount = Decimal("0") + for operation in operations: + report_qty = to_decimal(operation.report_qty) + labor_amount += report_qty * to_decimal(operation.std_labor_cost, "0.0001") + machine_amount += report_qty * to_decimal(operation.std_machine_cost, "0.0001") + + rework_labor_amount = to_decimal( + db.scalar( + select(func.coalesce(func.sum(ScrapRecord.scrap_qty * WorkOrderOperation.std_labor_cost), 0)) + .join(WorkOrderOperation, WorkOrderOperation.id == ScrapRecord.work_order_operation_id) + .where(ScrapRecord.id.in_(select(active_scrap_record_ids.c.scrap_record_id))) + .where( + ScrapRecord.work_order_id == work_order_id, + ScrapRecord.disposal_type == "REWORK", + ScrapRecord.status == "CONFIRMED", + ) + ), + "0.01", + ) + labor_amount += rework_labor_amount + + abnormal_loss_amount = to_decimal( + db.scalar( + select(func.coalesce(func.sum(ScrapRecord.scrap_cost_amount), 0)).where( + ScrapRecord.id.in_(select(active_scrap_record_ids.c.scrap_record_id)), + ScrapRecord.work_order_id == work_order_id, + ScrapRecord.status == "CONFIRMED", + ) + ), + "0.01", + ) + abnormal_loss_amount += to_decimal( + db.scalar( + select(func.coalesce(func.sum(OperationReport.extra_cost_amount), 0)).where( + OperationReport.work_order_id == work_order_id, + OperationReport.is_abnormal == 1, + ) + ), + "0.01", + ) + total_cost_amount = material_amount + labor_amount + machine_amount + abnormal_loss_amount + finished_qty = to_decimal(work_order.finished_qty) + base_qty = finished_qty if finished_qty > 0 else to_decimal(work_order.planned_qty) + unit_cost = (total_cost_amount / base_qty) if base_qty > 0 else Decimal("0") + return { + "material_amount": to_decimal(material_amount, "0.01"), + "labor_amount": to_decimal(labor_amount, "0.01"), + "machine_amount": to_decimal(machine_amount, "0.01"), + "abnormal_loss_amount": to_decimal(abnormal_loss_amount, "0.01"), + "total_cost_amount": to_decimal(total_cost_amount, "0.01"), + "unit_cost": to_decimal(unit_cost, "0.0001"), + } + + +def create_work_order_materials_and_operations( + db: Session, + *, + work_order: WorkOrder, + bom: Bom, + route: ProcessRoute, + auto_issue_materials: bool, + operator_user_id: int | None, + issue_weight_override: Decimal | None = None, +) -> None: + bom_items = db.scalars(select(BomItem).where(BomItem.bom_id == bom.id).order_by(BomItem.seq_no)).all() + planned_qty = to_decimal(work_order.planned_qty) + for index, bom_item in enumerate(bom_items): + required_qty = Decimal("0") + required_weight = planned_qty * to_decimal(bom_item.gross_weight_per_piece_kg) + if index == 0 and issue_weight_override is not None and issue_weight_override > 0: + required_weight = to_decimal(issue_weight_override) + enforce_qty = False + enforce_weight = required_weight > 0 + material_row = WorkOrderMaterial( + work_order_id=work_order.id, + bom_item_id=bom_item.id, + material_item_id=bom_item.material_item_id, + required_qty=required_qty, + required_weight_kg=required_weight, + issued_qty=to_decimal(0), + issued_weight_kg=to_decimal(0), + returned_qty=to_decimal(0), + returned_weight_kg=to_decimal(0), + status="OPEN", + remark=None, + ) + db.add(material_row) + db.flush() + + if auto_issue_materials and required_weight > 0: + issued_qty, issued_weight, _ = issue_item_from_lots( + db, + item_id=bom_item.material_item_id, + required_qty=required_qty, + required_weight=required_weight, + enforce_qty=enforce_qty, + enforce_weight=enforce_weight, + source_doc_id=work_order.id, + source_line_id=material_row.id, + biz_time=work_order.planned_start_time or datetime.now(), + operator_user_id=operator_user_id, + remark=f"工单 {work_order.work_order_no} 自动领料", + ) + material_row.issued_qty = issued_qty + material_row.issued_weight_kg = issued_weight + material_row.status = "ISSUED" + db.add(material_row) + + route_ops = db.scalars( + select(ProcessRouteOperation) + .where(ProcessRouteOperation.route_id == route.id, ProcessRouteOperation.status == "ACTIVE") + .order_by(ProcessRouteOperation.seq_no) + ).all() + for route_op in route_ops: + db.add( + WorkOrderOperation( + work_order_id=work_order.id, + route_operation_id=route_op.id, + seq_no=route_op.seq_no, + operation_name=route_op.operation_name, + work_center_id=route_op.work_center_id, + planned_qty=planned_qty, + report_qty=to_decimal(0), + good_qty=to_decimal(0), + scrap_qty=to_decimal(0), + rework_qty=to_decimal(0), + std_labor_cost=to_decimal(route_op.std_labor_cost, "0.0001"), + std_machine_cost=to_decimal(route_op.std_machine_cost, "0.0001"), + std_outsource_cost=to_decimal(route_op.std_outsource_cost, "0.0001"), + allowed_scrap_rate=to_decimal(route_op.allowed_scrap_rate, "0.0001"), + is_abnormal=0, + status="PENDING", + remark=None, + ) + ) + + +def generate_cost_allocations(db: Session, period_id: int) -> list[CostAllocationRead]: + period = db.get(AccountingPeriod, period_id) + if not period: + raise HTTPException(status_code=404, detail="财务期间不存在") + + period_start = start_of_day(period.start_date) + period_end = end_of_day(period.end_date) + total_overhead = to_decimal( + db.scalar(select(func.coalesce(func.sum(OverheadEntry.amount), 0)).where(OverheadEntry.period_id == period_id)), + "0.01", + ) + + db.query(CostAllocation).filter(CostAllocation.period_id == period_id).delete() + db.flush() + + work_orders = db.scalars( + select(WorkOrder).where( + or_( + and_(WorkOrder.actual_end_time.is_not(None), WorkOrder.actual_end_time.between(period_start, period_end)), + WorkOrder.updated_at.between(period_start, period_end), + ) + ) + ).all() + if not work_orders: + return [] + + basis_map: dict[int, Decimal] = {} + total_basis = Decimal("0") + for work_order in work_orders: + basis = to_decimal(work_order.finished_qty) + if basis <= 0: + basis = to_decimal(work_order.released_qty) + if basis <= 0: + basis = to_decimal(work_order.planned_qty) + basis_map[work_order.id] = basis + total_basis += basis + + rows: list[CostAllocation] = [] + for work_order in work_orders: + product = db.get(Item, work_order.product_item_id) + if not product: + continue + basis = basis_map[work_order.id] + cost_summary = calculate_work_order_cost_summary(db, work_order.id) + overhead_amount = (total_overhead * basis / total_basis) if total_basis > 0 else Decimal("0") + total_cost = cost_summary["total_cost_amount"] + overhead_amount + base_qty = to_decimal(work_order.finished_qty) + if base_qty <= 0: + base_qty = to_decimal(work_order.planned_qty) + allocation = CostAllocation( + period_id=period_id, + work_order_id=work_order.id, + product_item_id=work_order.product_item_id, + allocation_basis_value=basis, + overhead_amount=to_decimal(overhead_amount, "0.01"), + material_amount=cost_summary["material_amount"], + labor_amount=cost_summary["labor_amount"], + machine_amount=cost_summary["machine_amount"], + abnormal_loss_amount=cost_summary["abnormal_loss_amount"], + total_cost_amount=to_decimal(total_cost, "0.01"), + unit_cost=to_decimal((total_cost / base_qty) if base_qty > 0 else 0, "0.0001"), + ) + db.add(allocation) + rows.append(allocation) + + db.flush() + return get_cost_allocation_rows(db, period_id=period_id, limit=500) + + +def build_statement_summary(db: Session, period_id: int, report_date: date) -> dict: + overhead_total = float( + db.scalar(select(func.coalesce(func.sum(OverheadEntry.amount), 0)).where(OverheadEntry.period_id == period_id)) or 0 + ) + allocation_total = float( + db.scalar(select(func.coalesce(func.sum(CostAllocation.total_cost_amount), 0)).where(CostAllocation.period_id == period_id)) + or 0 + ) + statement_rows = db.execute( + select( + Item.item_type.label("item_type"), + func.count(StockBalance.id).label("line_count"), + func.coalesce(func.sum(StockBalance.qty_on_hand), 0).label("qty_on_hand"), + func.coalesce(func.sum(StockBalance.weight_on_hand_kg), 0).label("weight_on_hand_kg"), + func.coalesce( + func.sum( + case( + (Item.item_type == "RAW_MATERIAL", StockBalance.avg_unit_cost * StockBalance.weight_on_hand_kg), + else_=StockBalance.avg_unit_cost * StockBalance.qty_on_hand, + ) + ), + 0, + ).label("stock_amount"), + ) + .join(Item, Item.id == StockBalance.item_id) + .group_by(Item.item_type) + ).mappings() + inventory_summary = [dict(row) for row in statement_rows] + return { + "report_date": report_date.isoformat(), + "overhead_total": overhead_total, + "allocation_total": allocation_total, + "inventory_summary": inventory_summary, + } + + +def _build_finance_expense_rows(expense_amounts: dict[str, Decimal]) -> list[ProductMaterialCostExpenseRead]: + return [ + ProductMaterialCostExpenseRead( + key=key, + label=label, + amount=_to_float(expense_amounts.get(key, Decimal("0")), "0.01"), + ) + for key, label in FINANCE_EXPENSE_LAYOUT + ] + + +def build_product_material_cost_board( + db: Session, + *, + date_from: date, + date_to: date, +) -> ProductMaterialCostBoardRead: + if date_to < date_from: + raise HTTPException(status_code=400, detail="结束日期不能早于开始日期") + + start_dt = start_of_day(date_from) + end_dt = end_of_day(date_to) + + base_rows = db.execute( + text( + """ + select + wo.id as work_order_id, + wo.work_order_no, + wo.created_at, + wo.updated_at, + wo.actual_end_time, + wo.planned_qty, + wo.finished_qty, + wo.scrap_qty, + wo.status, + wo.source_sales_order_item_id, + prod_item.id as product_item_id, + prod_item.item_code as product_code, + prod_item.item_name as product_name, + prod_item.unit_weight_kg as product_unit_weight_kg, + so_item.order_qty as order_qty, + so_item.unit_price as unit_price, + so_item.customer_part_no as customer_part_no, + so.order_no as source_order_no, + so.order_date, + customer.customer_name + from pp_work_order wo + join md_item prod_item on prod_item.id = wo.product_item_id + left join so_sales_order_item so_item on so_item.id = wo.source_sales_order_item_id + left join so_sales_order so on so.id = so_item.sales_order_id + left join md_customer customer on customer.id = so.customer_id + order by wo.id desc + """ + ) + ).mappings().all() + + work_order_ids = [int(row["work_order_id"]) for row in base_rows] + if not work_order_ids: + empty_expenses = _build_finance_expense_rows({key: Decimal("0") for key, _ in FINANCE_EXPENSE_LAYOUT}) + return ProductMaterialCostBoardRead( + title="冲压产品材料成本核算表", + date_from=date_from, + date_to=date_to, + generated_at=datetime.now(), + rows=[], + expense_rows=empty_expenses, + summary=ProductMaterialCostSummaryRead( + row_count=0, + sales_total=0, + gross_material_total=0, + scrap_revenue_total=0, + material_cost_total=0, + material_loss_total=0, + labor_total=0, + outsource_total=0, + rework_total=0, + scrap_total=0, + overhead_total=0, + direct_cost_total=0, + profit_amount=0, + cost_ratio=0, + ), + ) + + material_stmt = text( + """ + select + wom.work_order_id, + wom.material_item_id, + wom.required_weight_kg, + wom.issued_weight_kg, + bi.gross_weight_per_piece_kg, + bi.net_weight_per_piece_kg, + bi.loss_rate, + material_item.item_code as material_code, + material_item.item_name as material_name, + material_item.material_grade as material_grade, + material_item.scrap_sale_price as material_scrap_sale_price + from pp_work_order_material wom + join md_bom_item bi on bi.id = wom.bom_item_id + join md_item material_item on material_item.id = wom.material_item_id + where wom.work_order_id in :work_order_ids + order by wom.work_order_id, wom.id + """ + ).bindparams(bindparam("work_order_ids", expanding=True)) + material_rows = db.execute(material_stmt, {"work_order_ids": work_order_ids}).mappings().all() + + materials_by_work_order: dict[int, list[dict]] = defaultdict(list) + material_item_ids: set[int] = set() + for row in material_rows: + payload = dict(row) + materials_by_work_order[int(payload["work_order_id"])].append(payload) + material_item_ids.add(int(payload["material_item_id"])) + + report_stmt = text( + """ + select + r.work_order_id, + coalesce(sum(r.report_qty), 0) as report_qty, + coalesce(sum(r.good_qty), 0) as good_qty, + coalesce(sum(r.scrap_qty), 0) as scrap_qty, + coalesce(sum(r.rework_qty), 0) as rework_qty, + coalesce(sum(r.extra_cost_amount), 0) as extra_cost_amount, + max(r.end_time) as last_report_time + from pp_operation_report r + where r.work_order_id in :work_order_ids + and r.end_time <= :end_dt + group by r.work_order_id + """ + ).bindparams(bindparam("work_order_ids", expanding=True)) + report_rows = db.execute(report_stmt, {"work_order_ids": work_order_ids, "end_dt": end_dt}).mappings().all() + report_map = {int(row["work_order_id"]): dict(row) for row in report_rows} + + final_report_stmt = text( + """ + select + final_operation.work_order_id, + coalesce(sum(r.report_qty), 0) as report_qty, + coalesce(sum(r.good_qty), 0) as good_qty, + coalesce(sum(r.scrap_qty), 0) as scrap_qty, + coalesce(sum(r.rework_qty), 0) as rework_qty, + max(r.end_time) as last_report_time + from ( + select + woo.work_order_id, + woo.id as work_order_operation_id + from pp_work_order_operation woo + join ( + select + work_order_id, + max(seq_no) as max_seq_no + from pp_work_order_operation + where work_order_id in :work_order_ids + group by work_order_id + ) final_seq + on final_seq.work_order_id = woo.work_order_id + and final_seq.max_seq_no = woo.seq_no + ) final_operation + left join pp_operation_report r + on r.work_order_operation_id = final_operation.work_order_operation_id + and r.end_time <= :end_dt + group by final_operation.work_order_id + """ + ).bindparams(bindparam("work_order_ids", expanding=True)) + final_report_rows = db.execute( + final_report_stmt, + {"work_order_ids": work_order_ids, "end_dt": end_dt}, + ).mappings().all() + final_report_map = {int(row["work_order_id"]): dict(row) for row in final_report_rows} + + operation_cost_stmt = text( + """ + select + r.work_order_id, + coalesce(sum(r.report_qty * woo.std_labor_cost), 0) as labor_amount, + coalesce(sum(r.report_qty * woo.std_outsource_cost), 0) as outsource_amount + from pp_operation_report r + join pp_work_order_operation woo on woo.id = r.work_order_operation_id + where r.work_order_id in :work_order_ids + and r.end_time <= :end_dt + group by r.work_order_id + """ + ).bindparams(bindparam("work_order_ids", expanding=True)) + operation_cost_rows = db.execute( + operation_cost_stmt, {"work_order_ids": work_order_ids, "end_dt": end_dt} + ).mappings().all() + operation_cost_map = {int(row["work_order_id"]): dict(row) for row in operation_cost_rows} + + receipt_stmt = text( + """ + select + cr.work_order_id, + coalesce(sum(cri.receipt_qty), 0) as receipt_qty, + max(cr.receipt_time) as last_receipt_time, + group_concat(distinct cr.receipt_no separator ', ') as receipt_nos + from pp_completion_receipt cr + join pp_completion_receipt_item cri on cri.completion_receipt_id = cr.id + where cr.work_order_id in :work_order_ids + and cr.receipt_time <= :end_dt + group by cr.work_order_id + """ + ).bindparams(bindparam("work_order_ids", expanding=True)) + receipt_rows = db.execute(receipt_stmt, {"work_order_ids": work_order_ids, "end_dt": end_dt}).mappings().all() + receipt_map = {int(row["work_order_id"]): dict(row) for row in receipt_rows} + + delivery_stmt = text( + """ + select + cr.work_order_id, + coalesce(sum(di.delivery_qty), 0) as delivery_qty, + max(d.delivery_date) as last_delivery_date, + group_concat(distinct d.delivery_no separator ', ') as delivery_nos + from so_delivery_item di + join so_delivery d on d.id = di.delivery_id + join wh_stock_lot lot on lot.id = di.lot_id and lot.source_doc_type = 'FG_RECEIPT' + join pp_completion_receipt_item cri on cri.id = lot.source_line_id + join pp_completion_receipt cr on cr.id = cri.completion_receipt_id and lot.source_doc_id = cr.id + where cr.work_order_id in :work_order_ids + and d.delivery_date <= :end_dt + group by cr.work_order_id + """ + ).bindparams(bindparam("work_order_ids", expanding=True)) + delivery_rows = db.execute(delivery_stmt, {"work_order_ids": work_order_ids, "end_dt": end_dt}).mappings().all() + delivery_map = {int(row["work_order_id"]): dict(row) for row in delivery_rows} + + scrap_stmt = text( + """ + select + sr.work_order_id, + coalesce(sum(case when sr.disposal_type = 'SCRAP' then sr.scrap_qty else 0 end), 0) as scrap_qty, + coalesce(sum(case when sr.disposal_type = 'SCRAP' then sr.scrap_weight_kg else 0 end), 0) as scrap_weight_kg, + coalesce(sum(case when sr.disposal_type = 'SCRAP' then sr.saleable_scrap_weight_kg else 0 end), 0) as saleable_scrap_weight_kg, + coalesce(sum(case when sr.disposal_type = 'SCRAP' then sr.scrap_cost_amount else 0 end), 0) as scrap_cost_amount, + coalesce(sum(case when sr.disposal_type = 'REWORK' then sr.scrap_qty else 0 end), 0) as rework_qty, + coalesce(sum(case when sr.disposal_type = 'REWORK' then sr.scrap_cost_amount else 0 end), 0) as rework_cost_amount, + max(sr.created_at) as last_scrap_time + from pp_scrap_record sr + join ( + select max(id) as id + from pp_scrap_record + where status <> 'OVERRIDDEN' + and created_at <= :end_dt + group by report_id + ) active_sr on active_sr.id = sr.id + where sr.work_order_id in :work_order_ids + and sr.status <> 'OVERRIDDEN' + and sr.created_at <= :end_dt + group by sr.work_order_id + """ + ).bindparams(bindparam("work_order_ids", expanding=True)) + scrap_rows = db.execute(scrap_stmt, {"work_order_ids": work_order_ids, "end_dt": end_dt}).mappings().all() + scrap_map = {int(row["work_order_id"]): dict(row) for row in scrap_rows} + + return_disposition_stmt = text( + """ + select + mapped.work_order_id, + coalesce(sum(case when mapped.disposition_type = 'REWORK' then mapped.return_qty else 0 end), 0) as return_rework_qty, + coalesce(sum(case when mapped.disposition_type = 'REWORK' then mapped.extra_cost_amount else 0 end), 0) as return_rework_cost_amount, + coalesce(sum(case when mapped.disposition_type = 'SCRAP' then mapped.return_qty else 0 end), 0) as return_scrap_qty, + coalesce(sum(case when mapped.disposition_type = 'SCRAP' then mapped.scrap_weight_kg else 0 end), 0) as return_scrap_weight_kg, + coalesce(sum(case when mapped.disposition_type = 'SCRAP' then mapped.scrap_sale_amount else 0 end), 0) as return_scrap_sale_amount, + max(mapped.closed_at) as last_return_disposition_time + from ( + select + coalesce(cr.work_order_id, source_wo.id) as work_order_id, + rd.disposition_type, + ri.return_qty, + rd.extra_cost_amount, + rd.scrap_weight_kg, + rd.scrap_sale_amount, + rd.closed_at + from rt_return_disposition rd + join rt_return_item ri on ri.id = rd.return_item_id + left join wh_stock_lot source_lot + on source_lot.id = ri.source_lot_id + and source_lot.source_doc_type = 'FG_RECEIPT' + left join pp_completion_receipt_item cri on cri.id = source_lot.source_line_id + left join pp_completion_receipt cr on cr.id = cri.completion_receipt_id + left join pp_work_order source_wo on source_wo.source_sales_order_item_id = ri.sales_order_item_id + where rd.status = 'CLOSED' + and rd.closed_at <= :end_dt + ) mapped + where mapped.work_order_id in :work_order_ids + group by mapped.work_order_id + """ + ).bindparams(bindparam("work_order_ids", expanding=True)) + return_disposition_rows = db.execute( + return_disposition_stmt, + {"work_order_ids": work_order_ids, "end_dt": end_dt}, + ).mappings().all() + return_disposition_map = {int(row["work_order_id"]): dict(row) for row in return_disposition_rows} + + issue_stmt = text( + """ + select + txn.source_doc_id as work_order_id, + txn.item_id as material_item_id, + coalesce(sum(abs(txn.weight_change_kg)), 0) as issued_weight_kg, + coalesce(sum(abs(txn.amount)), 0) as issued_amount + from wh_inventory_txn txn + where txn.source_doc_type = 'WORK_ORDER' + and txn.txn_type = 'MATERIAL_ISSUE' + and txn.source_doc_id in :work_order_ids + and txn.biz_time <= :end_dt + group by txn.source_doc_id, txn.item_id + """ + ).bindparams(bindparam("work_order_ids", expanding=True)) + issue_rows = db.execute(issue_stmt, {"work_order_ids": work_order_ids, "end_dt": end_dt}).mappings().all() + issue_map: dict[tuple[int, int], dict] = {} + for row in issue_rows: + issue_map[(int(row["work_order_id"]), int(row["material_item_id"]))] = dict(row) + + material_stock_map: dict[int, dict] = {} + balance_cost_map: dict[int, Decimal] = {} + if material_item_ids: + material_item_list = sorted(material_item_ids) + + material_stat_stmt = text( + """ + select + txn.item_id, + coalesce(sum(case when txn.txn_type = 'PURCHASE_IN' then abs(txn.weight_change_kg) else 0 end), 0) as purchased_weight_kg, + coalesce(sum(case when txn.txn_type = 'PURCHASE_IN' then abs(txn.amount) else 0 end), 0) as purchased_amount, + coalesce(sum(case when txn.txn_type = 'MATERIAL_ISSUE' then abs(txn.weight_change_kg) else 0 end), 0) as issued_weight_kg + from wh_inventory_txn txn + where txn.item_id in :material_item_ids + and txn.txn_type in ('PURCHASE_IN', 'MATERIAL_ISSUE') + and txn.biz_time <= :end_dt + group by txn.item_id + """ + ).bindparams(bindparam("material_item_ids", expanding=True)) + material_stat_rows = db.execute( + material_stat_stmt, + {"material_item_ids": material_item_list, "end_dt": end_dt}, + ).mappings().all() + material_stock_map = {int(row["item_id"]): dict(row) for row in material_stat_rows} + + balance_cost_stmt = text( + """ + select + sb.item_id, + case + when sum(sb.weight_on_hand_kg) > 0 then sum(sb.avg_unit_cost * sb.weight_on_hand_kg) / sum(sb.weight_on_hand_kg) + else avg(sb.avg_unit_cost) + end as avg_unit_cost + from wh_stock_balance sb + where sb.item_id in :material_item_ids + group by sb.item_id + """ + ).bindparams(bindparam("material_item_ids", expanding=True)) + for row in db.execute(balance_cost_stmt, {"material_item_ids": material_item_list}).mappings().all(): + balance_cost_map[int(row["item_id"])] = to_decimal(row["avg_unit_cost"], "0.0001") + + overhead_amounts: dict[str, Decimal] = defaultdict(lambda: Decimal("0")) + overhead_rows = db.execute( + text( + """ + select + oe.overhead_type, + oe.amount, + oe.description, + ap.start_date, + ap.end_date + from fi_overhead_entry oe + join fi_accounting_period ap on ap.id = oe.period_id + where ap.end_date >= :date_from + and ap.start_date <= :date_to + """ + ), + {"date_from": date_from, "date_to": date_to}, + ).mappings().all() + for row in overhead_rows: + normalized_key = _normalize_overhead_type(row["overhead_type"]) or "other" + effective_date = _extract_overhead_effective_date(row.get("description")) + if effective_date: + if date_from <= effective_date <= date_to: + overhead_amounts[normalized_key] += to_decimal(row["amount"], "0.01") + continue + + period_start = row["start_date"] + period_end = row["end_date"] + if not period_start or not period_end: + continue + overlap_start = max(period_start, date_from) + overlap_end = min(period_end, date_to) + if overlap_start > overlap_end: + continue + total_days = max((period_end - period_start).days + 1, 1) + overlap_days = (overlap_end - overlap_start).days + 1 + prorated_amount = to_decimal(row["amount"], "0.01") * Decimal(overlap_days) / Decimal(total_days) + overhead_amounts[normalized_key] += to_decimal(prorated_amount, "0.01") + + maintenance_cost_rows = db.execute( + text( + """ + select + maintenance_type, + coalesce(sum(cost_amount), 0) as cost_amount + from em_maintenance_order + where status = 'COMPLETED' + and coalesce(end_time, start_time, created_at) >= :start_dt + and coalesce(end_time, start_time, created_at) <= :end_dt + group by maintenance_type + """ + ), + {"start_dt": start_dt, "end_dt": end_dt}, + ).mappings().all() + for row in maintenance_cost_rows: + maintenance_type = str(row.get("maintenance_type") or "").upper() + target_key = "mould_maintenance" if any(token in maintenance_type for token in ("MOULD", "MOLD", "模具")) else "other" + overhead_amounts[target_key] += to_decimal(row.get("cost_amount"), "0.01") + + board_rows: list[ProductMaterialCostRowRead] = [] + row_payloads: list[dict] = [] + + for base_row in base_rows: + work_order_id = int(base_row["work_order_id"]) + report_summary = report_map.get(work_order_id, {}) + final_report_summary = final_report_map.get(work_order_id, {}) + receipt_summary = receipt_map.get(work_order_id, {}) + delivery_summary = delivery_map.get(work_order_id, {}) + scrap_summary = scrap_map.get(work_order_id, {}) + return_disposition_summary = return_disposition_map.get(work_order_id, {}) + operation_cost_summary = operation_cost_map.get(work_order_id, {}) + + relevant_dates: list[datetime] = [] + if base_row.get("order_date") and date_from <= base_row["order_date"] <= date_to: + relevant_dates.append(datetime.combine(base_row["order_date"], time.min)) + for candidate in ( + report_summary.get("last_report_time"), + receipt_summary.get("last_receipt_time"), + delivery_summary.get("last_delivery_date"), + return_disposition_summary.get("last_return_disposition_time"), + ): + candidate_dt = _to_candidate_datetime(candidate) + if candidate_dt and start_dt <= candidate_dt <= end_dt: + relevant_dates.append(candidate_dt) + if base_row.get("created_at") and start_dt <= base_row["created_at"] <= end_dt: + relevant_dates.append(base_row["created_at"]) + if base_row.get("updated_at") and start_dt <= base_row["updated_at"] <= end_dt: + relevant_dates.append(base_row["updated_at"]) + + if not relevant_dates: + continue + + work_order_material_rows = materials_by_work_order.get(work_order_id, []) + material_codes: list[str] = [] + material_names: list[str] = [] + material_grades: list[str] = [] + gross_weight_kg = Decimal("0") + net_weight_kg = Decimal("0") + loss_rate = Decimal("0") + purchase_material_weight = Decimal("0") + used_material_weight = Decimal("0") + material_inventory_weight = Decimal("0") + material_inventory_value = Decimal("0") + material_price_weight_total = Decimal("0") + weighted_material_cost_sum = Decimal("0") + scrap_price_weight_total = Decimal("0") + weighted_scrap_sale_price_sum = Decimal("0") + + for material_row in work_order_material_rows: + material_item_id = int(material_row["material_item_id"]) + material_codes.append(material_row.get("material_code") or "") + material_names.append(material_row.get("material_name") or "") + material_grades.append(material_row.get("material_grade") or "") + gross_weight_kg += to_decimal(material_row.get("gross_weight_per_piece_kg")) + net_weight_kg += to_decimal(material_row.get("net_weight_per_piece_kg")) + loss_rate = max(loss_rate, to_decimal(material_row.get("loss_rate"), "0.0001")) + + issue_detail = issue_map.get((work_order_id, material_item_id), {}) + item_issue_weight = to_decimal( + issue_detail.get("issued_weight_kg") + if issue_detail.get("issued_weight_kg") is not None + else material_row.get("issued_weight_kg"), + "0.000001", + ) + item_issue_amount = to_decimal(issue_detail.get("issued_amount"), "0.01") + material_stats = material_stock_map.get(material_item_id, {}) + purchased_weight_total = to_decimal(material_stats.get("purchased_weight_kg"), "0.000001") + purchased_amount_total = to_decimal(material_stats.get("purchased_amount"), "0.01") + issued_weight_total = to_decimal(material_stats.get("issued_weight_kg"), "0.000001") + remaining_weight_total = max(Decimal("0"), purchased_weight_total - issued_weight_total) + + if item_issue_weight > 0 and item_issue_amount > 0: + material_unit_price = item_issue_amount / item_issue_weight + elif purchased_weight_total > 0 and purchased_amount_total > 0: + material_unit_price = purchased_amount_total / purchased_weight_total + else: + material_unit_price = balance_cost_map.get(material_item_id, Decimal("0")) + + if issued_weight_total > 0 and item_issue_weight > 0: + allocated_remaining_weight = remaining_weight_total * item_issue_weight / issued_weight_total + elif item_issue_weight > 0: + allocated_remaining_weight = remaining_weight_total + else: + allocated_remaining_weight = Decimal("0") + + purchase_material_weight += item_issue_weight + allocated_remaining_weight + used_material_weight += item_issue_weight + material_inventory_weight += allocated_remaining_weight + material_inventory_value += allocated_remaining_weight * material_unit_price + material_price_weight_total += item_issue_weight + weighted_material_cost_sum += material_unit_price * item_issue_weight + scrap_sale_price = to_decimal(material_row.get("material_scrap_sale_price"), "0.0001") + scrap_price_basis = item_issue_weight if item_issue_weight > 0 else to_decimal( + material_row.get("required_weight_kg"), "0.000001" + ) + if scrap_price_basis > 0: + scrap_price_weight_total += scrap_price_basis + weighted_scrap_sale_price_sum += scrap_sale_price * scrap_price_basis + + if gross_weight_kg <= 0 and base_row.get("product_unit_weight_kg") is not None: + gross_weight_kg = to_decimal(base_row["product_unit_weight_kg"]) + if net_weight_kg <= 0: + net_weight_kg = to_decimal(base_row.get("product_unit_weight_kg")) + + actual_end_time = _to_candidate_datetime(base_row.get("actual_end_time")) + completed_before_cutoff = bool(actual_end_time and actual_end_time <= end_dt) + disposed_scrap_qty = to_decimal(scrap_summary.get("scrap_qty"), "0.000001") + work_order_scrap_qty = to_decimal(base_row.get("scrap_qty"), "0.000001") + final_report_scrap_qty = to_decimal(final_report_summary.get("scrap_qty"), "0.000001") + production_scrap_qty = first_positive_decimal( + disposed_scrap_qty, + final_report_scrap_qty, + work_order_scrap_qty if completed_before_cutoff else Decimal("0"), + ) + return_scrap_qty = to_decimal(return_disposition_summary.get("return_scrap_qty"), "0.000001") + actual_scrap_qty = production_scrap_qty + return_scrap_qty + receipt_qty = to_decimal(receipt_summary.get("receipt_qty"), "0.000001") + final_report_good_qty = to_decimal(final_report_summary.get("good_qty"), "0.000001") + effective_rework_qty = to_decimal(scrap_summary.get("rework_qty"), "0.000001") + finished_qty = to_decimal(base_row.get("finished_qty"), "0.000001") + actual_production_qty = first_positive_decimal( + receipt_qty, + final_report_good_qty + effective_rework_qty, + finished_qty if completed_before_cutoff else Decimal("0"), + ) + shipped_qty = to_decimal(delivery_summary.get("delivery_qty"), "0.000001") + inventory_qty = max(Decimal("0"), actual_production_qty - shipped_qty) + + order_qty = to_decimal(base_row.get("order_qty"), "0.000001") + if order_qty <= 0: + order_qty = to_decimal(base_row.get("planned_qty"), "0.000001") + + untaxed_unit_price = to_decimal(base_row.get("unit_price"), "0.0001") + material_unit_price_kg = ( + weighted_material_cost_sum / material_price_weight_total + if material_price_weight_total > 0 + else balance_cost_map.get( + int(work_order_material_rows[0]["material_item_id"]) if work_order_material_rows else 0, + Decimal("0"), + ) + ) + + scrap_unit_price_kg = ( + weighted_scrap_sale_price_sum / scrap_price_weight_total + if scrap_price_weight_total > 0 + else to_decimal(work_order_material_rows[0].get("material_scrap_sale_price"), "0.0001") + if work_order_material_rows + else Decimal("0") + ) + gross_material_cost_per_piece = material_unit_price_kg * gross_weight_kg + gross_material_total_cost = actual_production_qty * gross_material_cost_per_piece + scrap_weight_kg = max(Decimal("0"), gross_weight_kg - net_weight_kg) + scrap_revenue_per_piece = scrap_weight_kg * scrap_unit_price_kg + return_scrap_sale_amount = to_decimal(return_disposition_summary.get("return_scrap_sale_amount"), "0.01") + scrap_total_revenue = actual_production_qty * scrap_revenue_per_piece + return_scrap_sale_amount + material_cost_per_piece = gross_material_cost_per_piece - scrap_revenue_per_piece + material_total_cost = actual_production_qty * material_cost_per_piece + loss_total_cost = material_total_cost * loss_rate + + labor_total_cost = to_decimal(operation_cost_summary.get("labor_amount"), "0.01") + outsource_total_cost = to_decimal(operation_cost_summary.get("outsource_amount"), "0.01") + labor_cost_per_piece = (labor_total_cost / actual_production_qty) if actual_production_qty > 0 else Decimal("0") + outsource_cost_per_piece = ( + outsource_total_cost / actual_production_qty if actual_production_qty > 0 else Decimal("0") + ) + + product_cost_per_piece = material_cost_per_piece * (Decimal("1") + loss_rate) + labor_cost_per_piece + outsource_cost_per_piece + + production_rework_qty = first_positive_decimal( + to_decimal(scrap_summary.get("rework_qty"), "0.000001"), + to_decimal(final_report_summary.get("rework_qty"), "0.000001"), + to_decimal(report_summary.get("rework_qty"), "0.000001"), + ) + return_rework_qty = to_decimal(return_disposition_summary.get("return_rework_qty"), "0.000001") + rework_qty = production_rework_qty + return_rework_qty + production_rework_cost_total = to_decimal(scrap_summary.get("rework_cost_amount"), "0.01") + if production_rework_cost_total <= 0 and production_rework_qty > 0: + production_rework_cost_total = production_rework_qty * (labor_cost_per_piece + outsource_cost_per_piece) + return_rework_cost_total = to_decimal(return_disposition_summary.get("return_rework_cost_amount"), "0.01") + rework_cost_total = production_rework_cost_total + return_rework_cost_total + rework_cost_per_piece = (rework_cost_total / rework_qty) if rework_qty > 0 else Decimal("0") + + scrap_residual_value_per_piece = net_weight_kg * scrap_unit_price_kg + production_scrap_total_cost = max(Decimal("0"), product_cost_per_piece - scrap_residual_value_per_piece) * production_scrap_qty + return_scrap_total_cost = max(Decimal("0"), product_cost_per_piece * return_scrap_qty - return_scrap_sale_amount) + scrap_total_cost = production_scrap_total_cost + return_scrap_total_cost + batch_adjustment_avg_cost = ( + (rework_cost_total + scrap_total_cost) / actual_production_qty if actual_production_qty > 0 else Decimal("0") + ) + product_cost_with_adjustment_per_piece = product_cost_per_piece + batch_adjustment_avg_cost + product_total_cost = product_cost_with_adjustment_per_piece * actual_production_qty + + material_inventory_producible_qty = ( + material_inventory_weight / gross_weight_kg if gross_weight_kg > 0 else Decimal("0") + ) + inventory_value = inventory_qty * untaxed_unit_price + untaxed_total_amount = actual_production_qty * untaxed_unit_price + + if material_unit_price_kg <= 0 and outsource_total_cost > 0: + supply_mode = "外协发加工" + elif material_unit_price_kg <= 0: + supply_mode = "客供料" + else: + supply_mode = "自家料" + + biz_date = max(relevant_dates).date() if relevant_dates else None + delivery_note_no = ( + delivery_summary.get("delivery_nos") + or receipt_summary.get("receipt_nos") + or base_row.get("source_order_no") + or base_row["work_order_no"] + ) + + row_payloads.append( + { + "work_order_id": work_order_id, + "work_order_no": base_row["work_order_no"], + "biz_date": biz_date, + "product_code": base_row.get("customer_part_no") or base_row["product_code"], + "customer_name": base_row.get("customer_name"), + "product_name": base_row["product_name"], + "supply_mode": supply_mode, + "delivery_note_no": delivery_note_no, + "order_qty": order_qty, + "actual_production_qty": actual_production_qty, + "shipped_qty": shipped_qty, + "inventory_qty": inventory_qty, + "inventory_value": inventory_value, + "untaxed_unit_price": untaxed_unit_price, + "untaxed_total_amount": untaxed_total_amount, + "purchase_material_weight_kg": purchase_material_weight, + "used_material_weight_kg": used_material_weight, + "material_inventory_weight_kg": material_inventory_weight, + "material_inventory_producible_qty": material_inventory_producible_qty, + "material_inventory_value": material_inventory_value, + "material_grade": _join_distinct(material_grades), + "material_unit_price_kg": material_unit_price_kg, + "gross_weight_g": gross_weight_kg * Decimal("1000"), + "gross_material_cost_per_piece": gross_material_cost_per_piece, + "gross_material_total_cost": gross_material_total_cost, + "net_weight_g": net_weight_kg * Decimal("1000"), + "scrap_weight_g": scrap_weight_kg * Decimal("1000"), + "scrap_unit_price_kg": scrap_unit_price_kg, + "scrap_revenue_per_piece": scrap_revenue_per_piece, + "scrap_total_revenue": scrap_total_revenue, + "material_cost_per_piece": material_cost_per_piece, + "material_total_cost": material_total_cost, + "loss_rate": loss_rate, + "loss_total_cost": loss_total_cost, + "labor_cost_per_piece": labor_cost_per_piece, + "labor_total_cost": labor_total_cost, + "outsource_cost_per_piece": outsource_cost_per_piece, + "outsource_total_cost": outsource_total_cost, + "product_cost_per_piece": product_cost_per_piece, + "product_cost_with_adjustment_per_piece": product_cost_with_adjustment_per_piece, + "product_total_cost": product_total_cost, + "rework_qty": rework_qty, + "rework_cost_per_piece": rework_cost_per_piece, + "rework_total_cost": rework_cost_total, + "scrap_qty": actual_scrap_qty, + "scrap_residual_value_per_piece": scrap_residual_value_per_piece, + "scrap_total_cost": scrap_total_cost, + "batch_adjustment_avg_cost": batch_adjustment_avg_cost, + } + ) + + row_payloads.sort( + key=lambda row: ( + row["biz_date"] or date.min, + row["work_order_no"], + ), + reverse=True, + ) + + for index, payload in enumerate(row_payloads, start=1): + board_rows.append( + ProductMaterialCostRowRead( + work_order_id=payload["work_order_id"], + work_order_no=payload["work_order_no"], + row_no=index, + biz_date=payload["biz_date"], + product_code=payload["product_code"], + customer_name=payload["customer_name"], + product_name=payload["product_name"], + supply_mode=payload["supply_mode"], + delivery_note_no=payload["delivery_note_no"], + order_qty=_to_float(payload["order_qty"], "0.000001"), + actual_production_qty=_to_float(payload["actual_production_qty"], "0.000001"), + shipped_qty=_to_float(payload["shipped_qty"], "0.000001"), + inventory_qty=_to_float(payload["inventory_qty"], "0.000001"), + inventory_value=_to_float(payload["inventory_value"], "0.01"), + untaxed_unit_price=_to_float(payload["untaxed_unit_price"], "0.0001"), + untaxed_total_amount=_to_float(payload["untaxed_total_amount"], "0.01"), + purchase_material_weight_kg=_to_float(payload["purchase_material_weight_kg"], "0.000001"), + used_material_weight_kg=_to_float(payload["used_material_weight_kg"], "0.000001"), + material_inventory_weight_kg=_to_float(payload["material_inventory_weight_kg"], "0.000001"), + material_inventory_producible_qty=_to_float(payload["material_inventory_producible_qty"], "0.000001"), + material_inventory_value=_to_float(payload["material_inventory_value"], "0.01"), + material_grade=payload["material_grade"], + material_unit_price_kg=_to_float(payload["material_unit_price_kg"], "0.0001"), + gross_weight_g=_to_float(payload["gross_weight_g"], "0.001"), + gross_material_cost_per_piece=_to_float(payload["gross_material_cost_per_piece"], "0.0001"), + gross_material_total_cost=_to_float(payload["gross_material_total_cost"], "0.01"), + net_weight_g=_to_float(payload["net_weight_g"], "0.001"), + scrap_weight_g=_to_float(payload["scrap_weight_g"], "0.001"), + scrap_unit_price_kg=_to_float(payload["scrap_unit_price_kg"], "0.0001"), + scrap_revenue_per_piece=_to_float(payload["scrap_revenue_per_piece"], "0.0001"), + scrap_total_revenue=_to_float(payload["scrap_total_revenue"], "0.01"), + material_cost_per_piece=_to_float(payload["material_cost_per_piece"], "0.0001"), + material_total_cost=_to_float(payload["material_total_cost"], "0.01"), + loss_rate=_to_float(payload["loss_rate"], "0.0001"), + loss_total_cost=_to_float(payload["loss_total_cost"], "0.01"), + labor_cost_per_piece=_to_float(payload["labor_cost_per_piece"], "0.0001"), + labor_total_cost=_to_float(payload["labor_total_cost"], "0.01"), + outsource_cost_per_piece=_to_float(payload["outsource_cost_per_piece"], "0.0001"), + outsource_total_cost=_to_float(payload["outsource_total_cost"], "0.01"), + product_cost_per_piece=_to_float(payload["product_cost_per_piece"], "0.0001"), + product_cost_with_adjustment_per_piece=_to_float( + payload["product_cost_with_adjustment_per_piece"], "0.0001" + ), + product_total_cost=_to_float(payload["product_total_cost"], "0.01"), + rework_qty=_to_float(payload["rework_qty"], "0.000001"), + rework_cost_per_piece=_to_float(payload["rework_cost_per_piece"], "0.0001"), + rework_total_cost=_to_float(payload["rework_total_cost"], "0.01"), + scrap_qty=_to_float(payload["scrap_qty"], "0.000001"), + scrap_residual_value_per_piece=_to_float(payload["scrap_residual_value_per_piece"], "0.0001"), + scrap_total_cost=_to_float(payload["scrap_total_cost"], "0.01"), + batch_adjustment_avg_cost=_to_float(payload["batch_adjustment_avg_cost"], "0.0001"), + ) + ) + + sales_total = sum(to_decimal(row.untaxed_total_amount, "0.01") for row in board_rows) + gross_material_total = sum(to_decimal(row.gross_material_total_cost, "0.01") for row in board_rows) + scrap_revenue_total = sum(to_decimal(row.scrap_total_revenue, "0.01") for row in board_rows) + material_cost_total = sum(to_decimal(row.material_total_cost, "0.01") for row in board_rows) + material_loss_total = sum(to_decimal(row.loss_total_cost, "0.01") for row in board_rows) + labor_total = sum(to_decimal(row.labor_total_cost, "0.01") for row in board_rows) + outsource_total = sum(to_decimal(row.outsource_total_cost, "0.01") for row in board_rows) + rework_total = sum(to_decimal(row.rework_total_cost, "0.01") for row in board_rows) + scrap_total = sum(to_decimal(row.scrap_total_cost, "0.01") for row in board_rows) + direct_cost_total = sum(to_decimal(row.product_total_cost, "0.01") for row in board_rows) + + expense_amounts: dict[str, Decimal] = defaultdict(lambda: Decimal("0")) + expense_amounts["material_usage_value"] = material_cost_total + expense_amounts["material_loss_value"] = material_loss_total + expense_amounts["operator_wage"] = labor_total if labor_total > 0 else overhead_amounts.get("operator_wage", Decimal("0")) + expense_amounts["manager_wage"] = overhead_amounts.get("manager_wage", Decimal("0")) + expense_amounts["finance_charge"] = overhead_amounts.get("finance_charge", Decimal("0")) + expense_amounts["mould_maintenance"] = overhead_amounts.get("mould_maintenance", Decimal("0")) + expense_amounts["outsource_process"] = ( + outsource_total if outsource_total > 0 else overhead_amounts.get("outsource_process", Decimal("0")) + ) + expense_amounts["production_packaging"] = overhead_amounts.get("production_packaging", Decimal("0")) + expense_amounts["equipment_depreciation"] = overhead_amounts.get("equipment_depreciation", Decimal("0")) + expense_amounts["utility"] = overhead_amounts.get("utility", Decimal("0")) + expense_amounts["office_supplies"] = overhead_amounts.get("office_supplies", Decimal("0")) + expense_amounts["factory_rent"] = overhead_amounts.get("factory_rent", Decimal("0")) + expense_amounts["temporary_wage"] = overhead_amounts.get("temporary_wage", Decimal("0")) + expense_amounts["staff_welfare"] = overhead_amounts.get("staff_welfare", Decimal("0")) + expense_amounts["social_insurance"] = overhead_amounts.get("social_insurance", Decimal("0")) + expense_amounts["cash_misc"] = overhead_amounts.get("cash_misc", Decimal("0")) + expense_amounts["other"] = overhead_amounts.get("other", Decimal("0")) + rework_total + scrap_total + + profit_amount = sales_total - sum(expense_amounts.values()) + overhead_total = sum(overhead_amounts.values()) + cost_ratio = (sales_total - profit_amount) / sales_total if sales_total > 0 else Decimal("0") + + return ProductMaterialCostBoardRead( + title="冲压产品材料成本核算表", + date_from=date_from, + date_to=date_to, + generated_at=datetime.now(), + rows=board_rows, + expense_rows=_build_finance_expense_rows(expense_amounts), + summary=ProductMaterialCostSummaryRead( + row_count=len(board_rows), + sales_total=_to_float(sales_total, "0.01"), + gross_material_total=_to_float(gross_material_total, "0.01"), + scrap_revenue_total=_to_float(scrap_revenue_total, "0.01"), + material_cost_total=_to_float(material_cost_total, "0.01"), + material_loss_total=_to_float(material_loss_total, "0.01"), + labor_total=_to_float(labor_total, "0.01"), + outsource_total=_to_float(outsource_total, "0.01"), + rework_total=_to_float(rework_total, "0.01"), + scrap_total=_to_float(scrap_total, "0.01"), + overhead_total=_to_float(overhead_total, "0.01"), + direct_cost_total=_to_float(direct_cost_total, "0.01"), + profit_amount=_to_float(profit_amount, "0.01"), + cost_ratio=_to_float(cost_ratio, "0.0001"), + ), + ) + + +def get_cost_allocation_rows(db: Session, period_id: int | None = None, limit: int = 100) -> list[CostAllocationRead]: + stmt = ( + select( + CostAllocation.id.label("cost_allocation_id"), + CostAllocation.period_id.label("period_id"), + AccountingPeriod.period_code.label("period_code"), + CostAllocation.work_order_id.label("work_order_id"), + WorkOrder.work_order_no.label("work_order_no"), + CostAllocation.product_item_id.label("product_item_id"), + Item.item_code.label("product_code"), + Item.item_name.label("product_name"), + CostAllocation.allocation_basis_value.label("allocation_basis_value"), + CostAllocation.overhead_amount.label("overhead_amount"), + CostAllocation.material_amount.label("material_amount"), + CostAllocation.labor_amount.label("labor_amount"), + CostAllocation.machine_amount.label("machine_amount"), + CostAllocation.abnormal_loss_amount.label("abnormal_loss_amount"), + CostAllocation.total_cost_amount.label("total_cost_amount"), + CostAllocation.unit_cost.label("unit_cost"), + ) + .join(AccountingPeriod, AccountingPeriod.id == CostAllocation.period_id) + .join(WorkOrder, WorkOrder.id == CostAllocation.work_order_id) + .join(Item, Item.id == CostAllocation.product_item_id) + .order_by(CostAllocation.id.desc()) + .limit(limit) + ) + if period_id: + stmt = stmt.where(CostAllocation.period_id == period_id) + rows = db.execute(stmt).mappings().all() + return [CostAllocationRead.model_validate(dict(row)) for row in rows] + + +def create_rework_work_order( + db: Session, + *, + product_item_id: int, + planned_qty: Decimal, + remark: str | None, +) -> WorkOrder: + route = get_default_route(db, product_item_id) + bom = get_default_bom(db, product_item_id) + product = db.get(Item, product_item_id) + if not product: + raise HTTPException(status_code=404, detail="返工工单产品不存在") + work_order = WorkOrder( + work_order_no=build_yearly_product_work_order_no(db, product), + work_order_type="REWORK", + source_sales_order_item_id=None, + product_item_id=product_item_id, + route_id=route.id, + bom_id=bom.id, + planned_qty=planned_qty, + released_qty=planned_qty, + finished_qty=to_decimal(0), + scrap_qty=to_decimal(0), + planned_start_time=datetime.now(), + planned_end_time=None, + actual_start_time=None, + actual_end_time=None, + priority_level=2, + status="RELEASED", + remark=remark, + ) + db.add(work_order) + db.flush() + create_work_order_materials_and_operations( + db, + work_order=work_order, + bom=bom, + route=route, + auto_issue_materials=False, + operator_user_id=None, + ) + return work_order + + +def get_purchase_order_query(limit: int = 50) -> Select: + receipt_item_subquery = ( + select( + PurchaseReceiptItem.purchase_order_item_id.label("purchase_order_item_id"), + func.coalesce(func.sum(case((PurchaseReceiptItem.status == "PASSED", PurchaseReceiptItem.accepted_qty), else_=0)), 0).label("accepted_qty"), + func.coalesce(func.sum(case((PurchaseReceiptItem.status == "PASSED", PurchaseReceiptItem.accepted_weight_kg), else_=0)), 0).label("accepted_weight_kg"), + func.coalesce(func.sum(case((PurchaseReceiptItem.status == "PENDING_QC", 1), else_=0)), 0).label("pending_count"), + func.coalesce(func.sum(case((PurchaseReceiptItem.status == "PASSED", 1), else_=0)), 0).label("passed_count"), + func.coalesce(func.sum(case((PurchaseReceiptItem.status == "REJECTED", 1), else_=0)), 0).label("rejected_count"), + ) + .group_by(PurchaseReceiptItem.purchase_order_item_id) + .subquery() + ) + return_item_subquery = ( + select( + PurchaseReturnItem.purchase_order_item_id.label("purchase_order_item_id"), + func.coalesce(func.sum(PurchaseReturnItem.return_weight_kg), 0).label("returned_weight_kg"), + ) + .join(PurchaseReturn, PurchaseReturn.id == PurchaseReturnItem.purchase_return_id) + .where(PurchaseReturn.status != "CANCELED", PurchaseReturnItem.status != "CANCELED") + .group_by(PurchaseReturnItem.purchase_order_item_id) + .subquery() + ) + line_subquery = ( + select( + PurchaseOrderItem.purchase_order_id.label("purchase_order_id"), + func.count(PurchaseOrderItem.id).label("line_count"), + func.coalesce(func.sum(PurchaseOrderItem.order_qty), 0).label("total_order_qty"), + func.coalesce(func.sum(PurchaseOrderItem.order_weight_kg), 0).label("total_order_weight_kg"), + func.coalesce(func.sum(receipt_item_subquery.c.accepted_qty), 0).label("total_received_qty"), + func.coalesce(func.sum(receipt_item_subquery.c.accepted_weight_kg), 0).label("total_received_weight_kg"), + func.coalesce(func.sum(return_item_subquery.c.returned_weight_kg), 0).label("total_return_weight_kg"), + func.coalesce(func.sum(receipt_item_subquery.c.pending_count), 0).label("pending_qc_count"), + func.coalesce(func.sum(receipt_item_subquery.c.passed_count), 0).label("passed_count"), + func.coalesce(func.sum(receipt_item_subquery.c.rejected_count), 0).label("rejected_count"), + func.count(func.distinct(SalesOrder.id)).label("source_order_count"), + literal(None).label("source_order_nos"), + ) + .outerjoin(receipt_item_subquery, receipt_item_subquery.c.purchase_order_item_id == PurchaseOrderItem.id) + .outerjoin(return_item_subquery, return_item_subquery.c.purchase_order_item_id == PurchaseOrderItem.id) + .outerjoin(MaterialDemand, MaterialDemand.id == PurchaseOrderItem.source_demand_id) + .outerjoin(SalesOrderItem, SalesOrderItem.id == MaterialDemand.sales_order_item_id) + .outerjoin(SalesOrder, SalesOrder.id == SalesOrderItem.sales_order_id) + .group_by(PurchaseOrderItem.purchase_order_id) + .subquery() + ) + latest_archive_version_subquery = ( + select( + DocumentArchive.business_id.label("business_id"), + func.max(DocumentArchive.archive_version).label("archive_version"), + ) + .where(DocumentArchive.document_type == "采购订单", DocumentArchive.file_format == "PDF") + .group_by(DocumentArchive.business_id) + .subquery() + ) + latest_archive_id_subquery = ( + select( + DocumentArchive.business_id.label("business_id"), + func.max(DocumentArchive.id).label("archive_id"), + ) + .join( + latest_archive_version_subquery, + and_( + latest_archive_version_subquery.c.business_id == DocumentArchive.business_id, + latest_archive_version_subquery.c.archive_version == DocumentArchive.archive_version, + ), + ) + .where(DocumentArchive.document_type == "采购订单", DocumentArchive.file_format == "PDF") + .group_by(DocumentArchive.business_id) + .subquery() + ) + latest_archive_subquery = ( + select( + DocumentArchive.business_id.label("business_id"), + DocumentArchive.status.label("archive_status"), + DocumentArchive.archive_version.label("archive_version"), + DocumentArchive.created_at.label("archive_created_at"), + DocumentArchive.error_message.label("archive_error_message"), + ) + .join( + latest_archive_id_subquery, + latest_archive_id_subquery.c.archive_id == DocumentArchive.id, + ) + .where(DocumentArchive.document_type == "采购订单", DocumentArchive.file_format == "PDF") + .subquery() + ) + total_order_qty = func.coalesce(line_subquery.c.total_order_qty, 0) + total_order_weight = func.coalesce(line_subquery.c.total_order_weight_kg, 0) + total_received_qty = func.coalesce(line_subquery.c.total_received_qty, 0) + total_received_weight = func.coalesce(line_subquery.c.total_received_weight_kg, 0) + total_return_weight = func.coalesce(line_subquery.c.total_return_weight_kg, 0) + pending_qc_count = func.coalesce(line_subquery.c.pending_qc_count, 0) + rejected_count = func.coalesce(line_subquery.c.rejected_count, 0) + display_status = case( + (pending_qc_count > 0, "PENDING_QC"), + (and_(total_order_qty > 0, total_received_qty >= total_order_qty), "RECEIVED"), + (total_received_qty > 0, "PARTIAL_RECEIVED"), + (and_(total_order_weight > 0, total_received_weight >= total_order_weight), "RECEIVED"), + (total_received_weight > 0, "PARTIAL_RECEIVED"), + (rejected_count > 0, "REJECTED"), + else_=PurchaseOrder.status, + ) + purchaser = aliased(Employee) + return ( + select( + PurchaseOrder.id.label("purchase_order_id"), + PurchaseOrder.po_no.label("po_no"), + case( + (func.coalesce(line_subquery.c.source_order_count, 0) > 0, "SALES_ORDER"), + else_="STOCKING", + ).label("source_type"), + line_subquery.c.source_order_nos.label("source_order_nos"), + PurchaseOrder.supplier_id.label("supplier_id"), + Supplier.supplier_code.label("supplier_code"), + Supplier.supplier_name.label("supplier_name"), + PurchaseOrder.order_date.label("order_date"), + PurchaseOrder.expected_date.label("expected_date"), + PurchaseOrder.warning_lead_days.label("warning_lead_days"), + PurchaseOrder.purchaser_employee_id.label("purchaser_employee_id"), + purchaser.employee_name.label("purchaser_name"), + PurchaseOrder.target_warehouse_type.label("target_warehouse_type"), + PurchaseOrder.tax_rate.label("tax_rate"), + PurchaseOrder.total_amount.label("total_amount"), + PurchaseOrder.status.label("status"), + display_status.label("display_status"), + PurchaseOrder.remark.label("remark"), + func.coalesce(line_subquery.c.line_count, 0).label("line_count"), + total_order_qty.label("total_order_qty"), + total_order_weight.label("total_order_weight_kg"), + total_received_qty.label("total_received_qty"), + total_received_weight.label("total_received_weight_kg"), + (total_return_weight > 0).label("has_return"), + total_return_weight.label("total_return_weight_kg"), + func.coalesce(latest_archive_subquery.c.archive_status, "未生成").label("archive_status"), + latest_archive_subquery.c.archive_version.label("archive_version"), + latest_archive_subquery.c.archive_created_at.label("archive_created_at"), + latest_archive_subquery.c.archive_error_message.label("archive_error_message"), + ) + .join(Supplier, Supplier.id == PurchaseOrder.supplier_id) + .outerjoin(purchaser, purchaser.id == PurchaseOrder.purchaser_employee_id) + .outerjoin(line_subquery, line_subquery.c.purchase_order_id == PurchaseOrder.id) + .outerjoin(latest_archive_subquery, latest_archive_subquery.c.business_id == PurchaseOrder.id) + .order_by(PurchaseOrder.id.desc()) + .limit(limit) + ) + + +def get_purchase_order_items_query(limit: int = 100, purchase_order_id: int | None = None) -> Select: + receipt_item_subquery = ( + select( + PurchaseReceiptItem.purchase_order_item_id.label("purchase_order_item_id"), + func.coalesce(func.sum(case((PurchaseReceiptItem.status == "PASSED", PurchaseReceiptItem.accepted_qty), else_=0)), 0).label("accepted_qty"), + func.coalesce(func.sum(case((PurchaseReceiptItem.status == "PASSED", PurchaseReceiptItem.accepted_weight_kg), else_=0)), 0).label("accepted_weight_kg"), + func.coalesce(func.sum(case((PurchaseReceiptItem.status == "PENDING_QC", 1), else_=0)), 0).label("pending_count"), + func.coalesce(func.sum(case((PurchaseReceiptItem.status == "REJECTED", 1), else_=0)), 0).label("rejected_count"), + ) + .group_by(PurchaseReceiptItem.purchase_order_item_id) + .subquery() + ) + return_item_subquery = ( + select( + PurchaseReturnItem.purchase_order_item_id.label("purchase_order_item_id"), + func.coalesce(func.sum(PurchaseReturnItem.return_weight_kg), 0).label("returned_weight_kg"), + ) + .join(PurchaseReturn, PurchaseReturn.id == PurchaseReturnItem.purchase_return_id) + .where(PurchaseReturn.status != "CANCELED", PurchaseReturnItem.status != "CANCELED") + .group_by(PurchaseReturnItem.purchase_order_item_id) + .subquery() + ) + accepted_qty = func.coalesce(receipt_item_subquery.c.accepted_qty, 0) + accepted_weight = func.coalesce(receipt_item_subquery.c.accepted_weight_kg, 0) + returned_weight = func.coalesce(return_item_subquery.c.returned_weight_kg, 0) + net_received_weight = case((accepted_weight - returned_weight < 0, 0), else_=accepted_weight - returned_weight) + item_display_status = case( + (func.coalesce(receipt_item_subquery.c.pending_count, 0) > 0, "PENDING_QC"), + (and_(PurchaseOrderItem.order_qty > 0, accepted_qty >= PurchaseOrderItem.order_qty), "CLOSED"), + (accepted_qty > 0, "PARTIAL_RECEIVED"), + (and_(PurchaseOrderItem.order_weight_kg > 0, accepted_weight >= PurchaseOrderItem.order_weight_kg), "CLOSED"), + (accepted_weight > 0, "PARTIAL_RECEIVED"), + (func.coalesce(receipt_item_subquery.c.rejected_count, 0) > 0, "REJECTED"), + else_=PurchaseOrderItem.status, + ) + stmt = ( + select( + PurchaseOrderItem.id.label("purchase_order_item_id"), + PurchaseOrderItem.purchase_order_id.label("purchase_order_id"), + PurchaseOrder.po_no.label("po_no"), + PurchaseOrderItem.line_no.label("line_no"), + PurchaseOrderItem.material_item_id.label("material_item_id"), + Item.item_code.label("material_code"), + Item.item_name.label("material_name"), + Item.specification.label("specification"), + Item.material_grade.label("material_grade"), + Material.quality_rule.label("performance_requirement"), + Material.purchase_calc_mode.label("purchase_calc_mode"), + PurchaseOrderItem.source_demand_id.label("source_demand_id"), + PurchaseOrderItem.order_qty.label("order_qty"), + PurchaseOrderItem.order_weight_kg.label("order_weight_kg"), + accepted_qty.label("received_qty"), + accepted_weight.label("received_weight_kg"), + returned_weight.label("returned_weight_kg"), + net_received_weight.label("net_received_weight_kg"), + (returned_weight > 0).label("has_return"), + PurchaseOrderItem.unit_price.label("unit_price"), + PurchaseOrderItem.line_amount.label("line_amount"), + PurchaseOrderItem.status.label("status"), + item_display_status.label("display_status"), + PurchaseOrderItem.remark.label("remark"), + ) + .join(PurchaseOrder, PurchaseOrder.id == PurchaseOrderItem.purchase_order_id) + .join(Item, Item.id == PurchaseOrderItem.material_item_id) + .outerjoin(Material, Material.item_id == PurchaseOrderItem.material_item_id) + .outerjoin(receipt_item_subquery, receipt_item_subquery.c.purchase_order_item_id == PurchaseOrderItem.id) + .outerjoin(return_item_subquery, return_item_subquery.c.purchase_order_item_id == PurchaseOrderItem.id) + .order_by(PurchaseOrderItem.id.desc()) + .limit(limit) + ) + if purchase_order_id: + stmt = stmt.where(PurchaseOrderItem.purchase_order_id == purchase_order_id) + return stmt + + +def get_purchase_receipt_query(limit: int = 50) -> Select: + line_subquery = ( + select( + PurchaseReceiptItem.receipt_id.label("receipt_id"), + func.count(PurchaseReceiptItem.id).label("line_count"), + func.coalesce(func.sum(PurchaseReceiptItem.received_qty), 0).label("total_received_qty"), + func.coalesce(func.sum(PurchaseReceiptItem.received_weight_kg), 0).label("total_received_weight_kg"), + func.group_concat(func.distinct(Item.item_name)).label("material_names"), + func.coalesce(func.sum(case((PurchaseReceiptItem.status == "PENDING_QC", 1), else_=0)), 0).label("pending_count"), + func.coalesce(func.sum(case((PurchaseReceiptItem.status == "PASSED", 1), else_=0)), 0).label("passed_count"), + func.coalesce(func.sum(case((PurchaseReceiptItem.status == "REJECTED", 1), else_=0)), 0).label("rejected_count"), + ) + .join(Item, Item.id == PurchaseReceiptItem.material_item_id) + .group_by(PurchaseReceiptItem.receipt_id) + .subquery() + ) + line_count = func.coalesce(line_subquery.c.line_count, 0) + pending_count = func.coalesce(line_subquery.c.pending_count, 0) + passed_count = func.coalesce(line_subquery.c.passed_count, 0) + rejected_count = func.coalesce(line_subquery.c.rejected_count, 0) + receipt_status = case( + (line_count <= 0, PurchaseReceipt.status), + (pending_count > 0, "PENDING_QC"), + (passed_count == line_count, "PASSED"), + (rejected_count == line_count, "REJECTED"), + (passed_count + rejected_count == line_count, "PARTIAL"), + else_=PurchaseReceipt.status, + ) + latest_archive_version_subquery = ( + select( + DocumentArchive.business_id.label("business_id"), + func.max(DocumentArchive.archive_version).label("archive_version"), + ) + .where(DocumentArchive.document_type == "到货入库单", DocumentArchive.file_format == "PDF") + .group_by(DocumentArchive.business_id) + .subquery() + ) + latest_archive_id_subquery = ( + select( + DocumentArchive.business_id.label("business_id"), + func.max(DocumentArchive.id).label("archive_id"), + ) + .join( + latest_archive_version_subquery, + and_( + latest_archive_version_subquery.c.business_id == DocumentArchive.business_id, + latest_archive_version_subquery.c.archive_version == DocumentArchive.archive_version, + ), + ) + .where(DocumentArchive.document_type == "到货入库单", DocumentArchive.file_format == "PDF") + .group_by(DocumentArchive.business_id) + .subquery() + ) + latest_archive_subquery = ( + select( + DocumentArchive.business_id.label("business_id"), + DocumentArchive.status.label("archive_status"), + DocumentArchive.archive_version.label("archive_version"), + DocumentArchive.created_at.label("archive_created_at"), + DocumentArchive.error_message.label("archive_error_message"), + ) + .join( + latest_archive_id_subquery, + latest_archive_id_subquery.c.archive_id == DocumentArchive.id, + ) + .where(DocumentArchive.document_type == "到货入库单", DocumentArchive.file_format == "PDF") + .subquery() + ) + receiver = aliased(Employee) + return ( + select( + PurchaseReceipt.id.label("receipt_id"), + PurchaseReceipt.receipt_no.label("receipt_no"), + PurchaseReceipt.purchase_order_id.label("purchase_order_id"), + PurchaseOrder.po_no.label("po_no"), + PurchaseOrder.supplier_id.label("supplier_id"), + Supplier.supplier_name.label("supplier_name"), + PurchaseReceipt.warehouse_id.label("warehouse_id"), + Warehouse.warehouse_name.label("warehouse_name"), + PurchaseOrder.target_warehouse_type.label("target_warehouse_type"), + PurchaseReceipt.receipt_date.label("receipt_date"), + PurchaseReceipt.receiver_employee_id.label("receiver_employee_id"), + receiver.employee_name.label("receiver_name"), + PurchaseReceipt.supplier_delivery_no.label("supplier_delivery_no"), + PurchaseReceipt.logistics_waybill_no.label("logistics_waybill_no"), + PurchaseReceipt.logistics_freight_amount.label("logistics_freight_amount"), + PurchaseReceipt.logistics_photo_url.label("logistics_photo_url"), + line_subquery.c.material_names.label("material_names"), + receipt_status.label("status"), + PurchaseReceipt.remark.label("remark"), + func.coalesce(line_subquery.c.line_count, 0).label("line_count"), + func.coalesce(line_subquery.c.total_received_qty, 0).label("total_received_qty"), + func.coalesce(line_subquery.c.total_received_weight_kg, 0).label("total_received_weight_kg"), + func.coalesce(latest_archive_subquery.c.archive_status, "未生成").label("archive_status"), + latest_archive_subquery.c.archive_version.label("archive_version"), + latest_archive_subquery.c.archive_created_at.label("archive_created_at"), + latest_archive_subquery.c.archive_error_message.label("archive_error_message"), + ) + .join(PurchaseOrder, PurchaseOrder.id == PurchaseReceipt.purchase_order_id) + .join(Supplier, Supplier.id == PurchaseOrder.supplier_id) + .join(Warehouse, Warehouse.id == PurchaseReceipt.warehouse_id) + .outerjoin(receiver, receiver.id == PurchaseReceipt.receiver_employee_id) + .outerjoin(line_subquery, line_subquery.c.receipt_id == PurchaseReceipt.id) + .outerjoin(latest_archive_subquery, latest_archive_subquery.c.business_id == PurchaseReceipt.id) + .order_by(PurchaseReceipt.id.desc()) + .limit(limit) + ) + + +def get_purchase_receipt_items_query(limit: int = 100, receipt_id: int | None = None) -> Select: + stmt = ( + select( + PurchaseReceiptItem.id.label("receipt_item_id"), + PurchaseReceiptItem.receipt_id.label("receipt_id"), + PurchaseReceipt.receipt_no.label("receipt_no"), + PurchaseReceiptItem.line_no.label("line_no"), + PurchaseReceiptItem.purchase_order_item_id.label("purchase_order_item_id"), + PurchaseReceiptItem.material_item_id.label("material_item_id"), + Item.item_code.label("material_code"), + Item.item_name.label("material_name"), + PurchaseReceipt.warehouse_id.label("warehouse_id"), + Warehouse.warehouse_name.label("warehouse_name"), + PurchaseReceiptItem.location_id.label("location_id"), + WarehouseLocation.location_name.label("location_name"), + PurchaseReceiptItem.lot_no.label("lot_no"), + PurchaseReceiptItem.received_qty.label("received_qty"), + PurchaseReceiptItem.received_weight_kg.label("received_weight_kg"), + PurchaseReceiptItem.accepted_qty.label("accepted_qty"), + PurchaseReceiptItem.accepted_weight_kg.label("accepted_weight_kg"), + PurchaseReceiptItem.unit_cost.label("unit_cost"), + PurchaseReceiptItem.status.label("status"), + case( + (PurchaseReceiptItem.status == "PENDING_QC", "PENDING_QC"), + (PurchaseReceiptItem.status == "PASSED", "PASS"), + (PurchaseReceiptItem.status == "REJECTED", "REJECT"), + else_=func.coalesce(StockLot.quality_status, "PENDING_QC"), + ).label("quality_status"), + PurchaseReceiptItem.remark.label("remark"), + ) + .join(PurchaseReceipt, PurchaseReceipt.id == PurchaseReceiptItem.receipt_id) + .join(Warehouse, Warehouse.id == PurchaseReceipt.warehouse_id) + .join(Item, Item.id == PurchaseReceiptItem.material_item_id) + .outerjoin(WarehouseLocation, WarehouseLocation.id == PurchaseReceiptItem.location_id) + .outerjoin( + StockLot, + or_( + and_( + StockLot.source_doc_type == "PURCHASE_RECEIPT", + StockLot.source_doc_id == PurchaseReceiptItem.receipt_id, + StockLot.source_line_id == PurchaseReceiptItem.id, + ), + StockLot.id == PurchaseReceiptItem.merge_to_lot_id, + ), + ) + .order_by(PurchaseReceiptItem.id.desc()) + .limit(limit) + ) + if receipt_id: + stmt = stmt.where(PurchaseReceiptItem.receipt_id == receipt_id) + return stmt + + +def sync_purchase_receipt_status(db: Session, receipt_id: int) -> str | None: + receipt = db.get(PurchaseReceipt, receipt_id) + if not receipt: + return None + + statuses = [ + str(status or "").upper() + for status in db.scalars( + select(PurchaseReceiptItem.status).where(PurchaseReceiptItem.receipt_id == receipt_id) + ).all() + ] + if not statuses: + return receipt.status + + if any(status == "PENDING_QC" for status in statuses): + next_status = "PENDING_QC" + elif all(status == "PASSED" for status in statuses): + next_status = "PASSED" + elif all(status == "REJECTED" for status in statuses): + next_status = "REJECTED" + elif all(status in {"PASSED", "REJECTED"} for status in statuses): + next_status = "PARTIAL" + else: + next_status = receipt.status + + receipt.status = next_status + db.add(receipt) + return next_status + + +def get_stock_lots_query(limit: int = 100, item_type: str | None = None) -> Select: + source_material_lot = aliased(StockLot) + stmt = ( + select( + StockLot.id.label("lot_id"), + StockLot.lot_no.label("lot_no"), + StockLot.parent_lot_id.label("parent_lot_id"), + StockLot.lot_role.label("lot_role"), + StockLot.material_sub_batch_no.label("material_sub_batch_no"), + StockLot.item_id.label("item_id"), + Item.item_code.label("item_code"), + Item.item_name.label("item_name"), + Item.item_type.label("item_type"), + Item.specification.label("specification"), + StockLot.warehouse_id.label("warehouse_id"), + Warehouse.warehouse_name.label("warehouse_name"), + Warehouse.warehouse_type.label("warehouse_type"), + StockLot.location_id.label("location_id"), + WarehouseLocation.location_name.label("location_name"), + StockLot.source_doc_type.label("source_doc_type"), + StockLot.source_doc_id.label("source_doc_id"), + StockLot.source_line_id.label("source_line_id"), + PurchaseReceipt.receipt_no.label("source_receipt_no"), + PurchaseOrder.po_no.label("source_purchase_order_no"), + StockLot.source_material_lot_id.label("source_material_lot_id"), + func.coalesce(source_material_lot.lot_no, StockLot.source_material_sub_batch_no).label( + "source_material_lot_no" + ), + StockLot.source_material_sub_batch_no.label("source_material_sub_batch_no"), + StockLot.source_material_summary.label("source_material_summary"), + StockLot.logistics_waybill_no.label("logistics_waybill_no"), + StockLot.logistics_freight_amount.label("logistics_freight_amount"), + StockLot.logistics_photo_url.label("logistics_photo_url"), + case((Warehouse.warehouse_type.in_(("RAW", "SCRAP")), literal(0)), else_=StockLot.inbound_qty).label("inbound_qty"), + StockLot.inbound_weight_kg.label("inbound_weight_kg"), + case((Warehouse.warehouse_type.in_(("RAW", "SCRAP")), literal(0)), else_=StockLot.remaining_qty).label("remaining_qty"), + StockLot.remaining_weight_kg.label("remaining_weight_kg"), + case((Warehouse.warehouse_type.in_(("RAW", "SCRAP")), literal(0)), else_=StockLot.locked_qty).label("locked_qty"), + StockLot.locked_weight_kg.label("locked_weight_kg"), + StockLot.unit_cost.label("unit_cost"), + StockLot.quality_status.label("quality_status"), + StockLot.remark.label("quality_user_name"), + StockLot.status.label("status"), + StockLot.created_at.label("created_at"), + ) + .join(Item, Item.id == StockLot.item_id) + .join(Warehouse, Warehouse.id == StockLot.warehouse_id) + .outerjoin(WarehouseLocation, WarehouseLocation.id == StockLot.location_id) + .outerjoin( + PurchaseReceiptItem, + and_( + StockLot.source_doc_type == "PURCHASE_RECEIPT", + PurchaseReceiptItem.receipt_id == StockLot.source_doc_id, + PurchaseReceiptItem.id == StockLot.source_line_id, + ), + ) + .outerjoin(PurchaseReceipt, PurchaseReceipt.id == PurchaseReceiptItem.receipt_id) + .outerjoin(PurchaseOrder, PurchaseOrder.id == PurchaseReceipt.purchase_order_id) + .outerjoin(source_material_lot, source_material_lot.id == StockLot.source_material_lot_id) + .order_by(StockLot.id.desc()) + .limit(limit) + ) + if item_type: + stmt = stmt.where(Item.item_type == item_type) + return stmt + + +def get_stock_lot_options_query(material_item_id: int | None = None) -> Select: + usable_weight_expr = StockLot.remaining_weight_kg - StockLot.locked_weight_kg + is_available_expr = case( + ( + (StockLot.quality_status == "PASS") + & (StockLot.status == "AVAILABLE") + & (usable_weight_expr > 0), + 1, + ), + else_=0, + ) + stmt = ( + select( + StockLot.id.label("lot_id"), + StockLot.lot_no.label("lot_no"), + StockLot.item_id.label("item_id"), + Item.item_code.label("item_code"), + Item.item_name.label("item_name"), + StockLot.warehouse_id.label("warehouse_id"), + Warehouse.warehouse_name.label("warehouse_name"), + StockLot.remaining_weight_kg.label("remaining_weight_kg"), + StockLot.unit_cost.label("unit_cost"), + StockLot.quality_status.label("quality_status"), + StockLot.status.label("status"), + is_available_expr.label("is_available_for_issue"), + case( + (StockLot.quality_status != "PASS", "未质检放行"), + (usable_weight_expr <= 0, "已用完"), + (StockLot.status != "AVAILABLE", "当前状态不可用"), + else_=None, + ).label("disabled_reason"), + ) + .join(Item, Item.id == StockLot.item_id) + .join(Warehouse, Warehouse.id == StockLot.warehouse_id) + .where(Warehouse.warehouse_type == "RAW") + .order_by(is_available_expr.desc(), StockLot.lot_no.asc(), StockLot.id.asc()) + ) + if material_item_id is not None: + stmt = stmt.where(StockLot.item_id == material_item_id) + return stmt + + +def get_stock_lot_purchase_links(db: Session, lot_id: int) -> list[dict[str, object]]: + lot = db.get(StockLot, lot_id) + if not lot: + raise HTTPException(status_code=404, detail="库存批次不存在") + + returned_subquery = ( + select( + PurchaseReturnItem.purchase_order_item_id.label("purchase_order_item_id"), + PurchaseReturnItem.lot_id.label("lot_id"), + func.coalesce(func.sum(PurchaseReturnItem.return_weight_kg), 0).label("returned_weight_kg"), + ) + .join(PurchaseReturn, PurchaseReturn.id == PurchaseReturnItem.purchase_return_id) + .where(PurchaseReturn.status != "CANCELED", PurchaseReturnItem.status != "CANCELED") + .group_by(PurchaseReturnItem.purchase_order_item_id, PurchaseReturnItem.lot_id) + .subquery() + ) + + accepted_weight = func.coalesce(func.sum(PurchaseReceiptItem.accepted_weight_kg), 0) + returned_weight = func.coalesce(returned_subquery.c.returned_weight_kg, 0) + available_by_receipt = accepted_weight - returned_weight + non_negative_returnable = case((available_by_receipt < 0, 0), else_=available_by_receipt) + available_lot_weight = StockLot.remaining_weight_kg - StockLot.locked_weight_kg + non_negative_available_lot_weight = case((available_lot_weight < 0, 0), else_=available_lot_weight) + returnable_weight = case( + (non_negative_returnable > non_negative_available_lot_weight, non_negative_available_lot_weight), + else_=non_negative_returnable, + ) + direct_receipt_item = and_( + StockLot.source_doc_type == "PURCHASE_RECEIPT", + PurchaseReceiptItem.receipt_id == StockLot.source_doc_id, + PurchaseReceiptItem.id == StockLot.source_line_id, + ) + merged_receipt_item = PurchaseReceiptItem.merge_to_lot_id == StockLot.id + stmt = ( + select( + PurchaseOrder.id.label("purchase_order_id"), + PurchaseOrder.po_no.label("po_no"), + PurchaseOrderItem.id.label("purchase_order_item_id"), + PurchaseOrderItem.material_item_id.label("material_item_id"), + Item.item_code.label("material_code"), + Item.item_name.label("material_name"), + StockLot.id.label("lot_id"), + StockLot.lot_no.label("lot_no"), + accepted_weight.label("accepted_weight_kg"), + returned_weight.label("returned_weight_kg"), + returnable_weight.label("returnable_weight_kg"), + PurchaseReceiptItem.unit_cost.label("unit_cost"), + ) + .select_from(PurchaseReceiptItem) + .join(PurchaseReceipt, PurchaseReceipt.id == PurchaseReceiptItem.receipt_id) + .join(PurchaseOrder, PurchaseOrder.id == PurchaseReceipt.purchase_order_id) + .join(PurchaseOrderItem, PurchaseOrderItem.id == PurchaseReceiptItem.purchase_order_item_id) + .join(Item, Item.id == PurchaseReceiptItem.material_item_id) + .join(StockLot, StockLot.id == lot_id) + .outerjoin( + returned_subquery, + (returned_subquery.c.purchase_order_item_id == PurchaseOrderItem.id) + & (returned_subquery.c.lot_id == StockLot.id), + ) + .where( + PurchaseReceipt.warehouse_id == StockLot.warehouse_id, + or_(direct_receipt_item, merged_receipt_item), + PurchaseReceiptItem.material_item_id == lot.item_id, + PurchaseReceiptItem.lot_no == lot.lot_no, + PurchaseReceiptItem.status == "PASSED", + ) + .group_by( + PurchaseOrder.id, + PurchaseOrder.po_no, + PurchaseOrderItem.id, + PurchaseOrderItem.material_item_id, + Item.item_code, + Item.item_name, + StockLot.id, + StockLot.lot_no, + returned_subquery.c.returned_weight_kg, + PurchaseReceiptItem.unit_cost, + StockLot.remaining_weight_kg, + StockLot.locked_weight_kg, + ) + .order_by(PurchaseOrder.id.asc(), PurchaseOrderItem.line_no.asc()) + ) + return [dict(row) for row in db.execute(stmt).mappings().all()] + + +def get_stock_balances_query(limit: int = 100) -> Select: + stock_unit = aliased(Unit) + purchase_unit = aliased(Unit) + sales_unit = aliased(Unit) + return ( + select( + StockBalance.id.label("stock_balance_id"), + StockBalance.item_id.label("item_id"), + Item.item_code.label("item_code"), + Item.item_name.label("item_name"), + Item.item_type.label("item_type"), + Item.specification.label("specification"), + Item.remark.label("item_remark"), + stock_unit.unit_name.label("stock_unit_name"), + purchase_unit.unit_name.label("purchase_unit_name"), + sales_unit.unit_name.label("sales_unit_name"), + StockBalance.warehouse_id.label("warehouse_id"), + Warehouse.warehouse_name.label("warehouse_name"), + Warehouse.warehouse_type.label("warehouse_type"), + StockBalance.location_id.label("location_id"), + WarehouseLocation.location_name.label("location_name"), + case((Warehouse.warehouse_type.in_(("RAW", "SCRAP")), literal(0)), else_=StockBalance.qty_on_hand).label("qty_on_hand"), + StockBalance.weight_on_hand_kg.label("weight_on_hand_kg"), + case((Warehouse.warehouse_type.in_(("RAW", "SCRAP")), literal(0)), else_=StockBalance.qty_available).label("qty_available"), + StockBalance.weight_available_kg.label("weight_available_kg"), + case((Warehouse.warehouse_type.in_(("RAW", "SCRAP")), literal(0)), else_=StockBalance.qty_allocated).label("qty_allocated"), + StockBalance.weight_allocated_kg.label("weight_allocated_kg"), + StockBalance.avg_unit_cost.label("avg_unit_cost"), + StockBalance.updated_at.label("updated_at"), + ) + .join(Item, Item.id == StockBalance.item_id) + .join(Warehouse, Warehouse.id == StockBalance.warehouse_id) + .outerjoin(WarehouseLocation, WarehouseLocation.id == StockBalance.location_id) + .outerjoin(stock_unit, stock_unit.id == Item.stock_unit_id) + .outerjoin(purchase_unit, purchase_unit.id == Item.purchase_unit_id) + .outerjoin(sales_unit, sales_unit.id == Item.sales_unit_id) + .order_by(StockBalance.updated_at.desc(), StockBalance.id.desc()) + .limit(limit) + ) + + +WAREHOUSE_LEDGER_TYPES = {"RAW", "SEMI", "FINISHED", "AUX", "SCRAP", "RETURN"} + +WAREHOUSE_LEDGER_SORT_COLUMNS = { + "biz_time": InventoryTxn.biz_time, + "txn_type": InventoryTxn.txn_type, + "item_code": Item.item_code, + "item_name": Item.item_name, + "lot_no": StockLot.lot_no, + "qty_change": InventoryTxn.qty_change, + "weight_change_kg": InventoryTxn.weight_change_kg, + "amount": InventoryTxn.amount, +} + + +def inventory_txn_direction_expression(): + return case( + (or_(InventoryTxn.txn_type.like("STOCKTAKE_%"), InventoryTxn.source_doc_type == "STOCKTAKE"), "ADJUST"), + (or_(InventoryTxn.weight_change_kg > 0, InventoryTxn.qty_change > 0), "IN"), + (or_(InventoryTxn.weight_change_kg < 0, InventoryTxn.qty_change < 0), "OUT"), + else_="ADJUST", + ) + + +def inventory_txn_archive_document_type_expression(): + return case( + ( + and_( + InventoryTxn.source_doc_type == "生产台账", + InventoryTxn.txn_type.in_(("PRODUCTION_OUT", "MATERIAL_ISSUE")), + ), + DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, + ), + ( + InventoryTxn.txn_type.in_( + ( + "FG_IN", + "PRODUCTION_FINISHED_IN", + "PRODUCTION_SURPLUS_IN", + "PRODUCTION_SCRAP_IN", + "成品入库", + "生产余料入库", + "生产废料入库", + ) + ), + DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + ), + else_=DOCUMENT_TYPE_WAREHOUSE_OPERATION, + ) + + +def inventory_txn_archive_business_id_expression(): + return case( + ( + and_( + InventoryTxn.source_doc_type == "生产台账", + InventoryTxn.txn_type.in_(("PRODUCTION_OUT", "MATERIAL_ISSUE")), + ), + InventoryTxn.source_doc_id, + ), + else_=InventoryTxn.id, + ) + + +def latest_inventory_archive_subquery(): + return ( + select( + DocumentArchive.document_type.label("document_type"), + DocumentArchive.business_id.label("business_id"), + DocumentArchive.status.label("archive_status"), + DocumentArchive.error_message.label("archive_error_message"), + func.row_number() + .over( + partition_by=(DocumentArchive.document_type, DocumentArchive.business_id), + order_by=(DocumentArchive.archive_version.desc(), DocumentArchive.id.desc()), + ) + .label("row_no"), + ) + .where(DocumentArchive.file_format == FILE_FORMAT_PDF) + .subquery() + ) + + +def get_inventory_txn_ledger_query( + *, + warehouse_type: str, + warehouse_id: int | None = None, + keyword: str | None = None, + direction: str = "ALL", + txn_type: str | None = None, + item_id: int | None = None, + lot_no: str | None = None, + start_date: date | None = None, + end_date: date | None = None, + sort_key: str = "biz_time", + sort_direction: str = "desc", +) -> Select: + normalized_warehouse_type = str(warehouse_type or "").upper() + direction_expr = inventory_txn_direction_expression().label("direction") + archive_document_type_expr = inventory_txn_archive_document_type_expression().label("archive_document_type") + archive_business_id_expr = inventory_txn_archive_business_id_expression().label("archive_business_id") + latest_archive_subquery = latest_inventory_archive_subquery() + operator_employee = aliased(Employee) + source_material_lot = aliased(StockLot) + stmt = ( + select( + InventoryTxn.id.label("inventory_txn_id"), + InventoryTxn.txn_no.label("txn_no"), + InventoryTxn.txn_type.label("txn_type"), + InventoryTxn.item_id.label("item_id"), + Item.item_code.label("item_code"), + Item.item_name.label("item_name"), + InventoryTxn.warehouse_id.label("warehouse_id"), + Warehouse.warehouse_name.label("warehouse_name"), + Warehouse.warehouse_type.label("warehouse_type"), + InventoryTxn.location_id.label("location_id"), + WarehouseLocation.location_name.label("location_name"), + InventoryTxn.lot_id.label("lot_id"), + StockLot.lot_no.label("lot_no"), + case((Warehouse.warehouse_type.in_(("RAW", "SCRAP")), literal(0)), else_=InventoryTxn.qty_change).label("qty_change"), + InventoryTxn.weight_change_kg.label("weight_change_kg"), + InventoryTxn.unit_cost.label("unit_cost"), + InventoryTxn.amount.label("amount"), + InventoryTxn.source_doc_type.label("source_doc_type"), + InventoryTxn.source_doc_id.label("source_doc_id"), + InventoryTxn.source_line_id.label("source_line_id"), + StockLot.source_material_lot_id.label("source_material_lot_id"), + func.coalesce(source_material_lot.lot_no, StockLot.source_material_sub_batch_no).label( + "source_material_lot_no" + ), + StockLot.source_material_sub_batch_no.label("source_material_sub_batch_no"), + StockLot.source_material_summary.label("source_material_summary"), + InventoryTxn.biz_time.label("biz_time"), + InventoryTxn.operator_user_id.label("operator_user_id"), + func.coalesce(operator_employee.employee_name, User.nickname, User.username).label("operator_name"), + InventoryTxn.logistics_waybill_no.label("logistics_waybill_no"), + InventoryTxn.logistics_freight_amount.label("logistics_freight_amount"), + InventoryTxn.logistics_photo_url.label("logistics_photo_url"), + InventoryTxn.remark.label("remark"), + direction_expr, + archive_document_type_expr, + archive_business_id_expr, + func.coalesce(latest_archive_subquery.c.archive_status, ARCHIVE_STATUS_MISSING).label("archive_status"), + latest_archive_subquery.c.archive_error_message.label("archive_error_message"), + ) + .join(Item, Item.id == InventoryTxn.item_id) + .join(Warehouse, Warehouse.id == InventoryTxn.warehouse_id) + .outerjoin(WarehouseLocation, WarehouseLocation.id == InventoryTxn.location_id) + .outerjoin(StockLot, StockLot.id == InventoryTxn.lot_id) + .outerjoin(source_material_lot, source_material_lot.id == StockLot.source_material_lot_id) + .outerjoin(User, User.id == InventoryTxn.operator_user_id) + .outerjoin(operator_employee, operator_employee.id == User.employee_id) + .outerjoin( + latest_archive_subquery, + and_( + latest_archive_subquery.c.document_type == archive_document_type_expr, + latest_archive_subquery.c.business_id == archive_business_id_expr, + latest_archive_subquery.c.row_no == 1, + ), + ) + .where(Warehouse.warehouse_type == normalized_warehouse_type) + ) + if warehouse_id: + stmt = stmt.where(InventoryTxn.warehouse_id == warehouse_id) + if txn_type: + stmt = stmt.where(InventoryTxn.txn_type == str(txn_type).upper()) + if item_id: + stmt = stmt.where(InventoryTxn.item_id == item_id) + if lot_no: + lot_no_pattern = f"%{lot_no.strip()}%" + stmt = stmt.where( + or_( + StockLot.lot_no.like(lot_no_pattern), + source_material_lot.lot_no.like(lot_no_pattern), + StockLot.source_material_sub_batch_no.like(lot_no_pattern), + ) + ) + if start_date: + stmt = stmt.where(InventoryTxn.biz_time >= start_of_day(start_date)) + if end_date: + stmt = stmt.where(InventoryTxn.biz_time <= end_of_day(end_date)) + if keyword and keyword.strip(): + pattern = f"%{keyword.strip()}%" + stmt = stmt.where( + or_( + Item.item_code.like(pattern), + Item.item_name.like(pattern), + InventoryTxn.txn_type.like(pattern), + InventoryTxn.source_doc_type.like(pattern), + InventoryTxn.txn_no.like(pattern), + InventoryTxn.logistics_waybill_no.like(pattern), + InventoryTxn.remark.like(pattern), + StockLot.lot_no.like(pattern), + source_material_lot.lot_no.like(pattern), + StockLot.source_material_sub_batch_no.like(pattern), + StockLot.source_material_summary.like(pattern), + ) + ) + normalized_direction = str(direction or "ALL").upper() + if normalized_direction in {"IN", "OUT", "ADJUST"}: + stmt = stmt.where(direction_expr == normalized_direction) + + sort_column = WAREHOUSE_LEDGER_SORT_COLUMNS.get(sort_key, InventoryTxn.biz_time) + if str(sort_direction or "desc").lower() == "asc": + stmt = stmt.order_by(sort_column.asc(), InventoryTxn.id.asc()) + else: + stmt = stmt.order_by(sort_column.desc(), InventoryTxn.id.desc()) + return stmt + + +def summarize_inventory_txn_ledger(db: Session, ledger_stmt: Select) -> dict[str, object]: + ledger_subquery = ledger_stmt.order_by(None).subquery() + row = db.execute( + select( + func.coalesce(func.sum(case((ledger_subquery.c.direction == "IN", 1), else_=0)), 0).label("in_count"), + func.coalesce(func.sum(case((ledger_subquery.c.direction == "OUT", 1), else_=0)), 0).label("out_count"), + func.coalesce(func.sum(case((ledger_subquery.c.direction == "ADJUST", 1), else_=0)), 0).label("adjust_count"), + func.coalesce( + func.sum(case((ledger_subquery.c.direction == "IN", func.abs(ledger_subquery.c.qty_change)), else_=0)), + 0, + ).label("in_qty"), + func.coalesce( + func.sum(case((ledger_subquery.c.direction == "OUT", func.abs(ledger_subquery.c.qty_change)), else_=0)), + 0, + ).label("out_qty"), + func.coalesce( + func.sum(case((ledger_subquery.c.direction == "IN", func.abs(ledger_subquery.c.weight_change_kg)), else_=0)), + 0, + ).label("in_weight_kg"), + func.coalesce( + func.sum(case((ledger_subquery.c.direction == "OUT", func.abs(ledger_subquery.c.weight_change_kg)), else_=0)), + 0, + ).label("out_weight_kg"), + func.max(ledger_subquery.c.biz_time).label("latest_biz_time"), + ) + ).mappings().one() + return { + "in_count": int(row["in_count"] or 0), + "out_count": int(row["out_count"] or 0), + "adjust_count": int(row["adjust_count"] or 0), + "in_qty": to_decimal(row["in_qty"]), + "out_qty": to_decimal(row["out_qty"]), + "in_weight_kg": to_decimal(row["in_weight_kg"]), + "out_weight_kg": to_decimal(row["out_weight_kg"]), + "latest_biz_time": row["latest_biz_time"], + } + + +def get_inventory_txn_query(limit: int = 200) -> Select: + source_material_lot = aliased(StockLot) + archive_document_type_expr = inventory_txn_archive_document_type_expression().label("archive_document_type") + archive_business_id_expr = inventory_txn_archive_business_id_expression().label("archive_business_id") + latest_archive_subquery = latest_inventory_archive_subquery() + return ( + select( + InventoryTxn.id.label("inventory_txn_id"), + InventoryTxn.txn_no.label("txn_no"), + InventoryTxn.txn_type.label("txn_type"), + InventoryTxn.item_id.label("item_id"), + Item.item_code.label("item_code"), + Item.item_name.label("item_name"), + InventoryTxn.warehouse_id.label("warehouse_id"), + Warehouse.warehouse_name.label("warehouse_name"), + Warehouse.warehouse_type.label("warehouse_type"), + InventoryTxn.location_id.label("location_id"), + WarehouseLocation.location_name.label("location_name"), + InventoryTxn.lot_id.label("lot_id"), + StockLot.lot_no.label("lot_no"), + case((Warehouse.warehouse_type.in_(("RAW", "SCRAP")), literal(0)), else_=InventoryTxn.qty_change).label("qty_change"), + InventoryTxn.weight_change_kg.label("weight_change_kg"), + InventoryTxn.unit_cost.label("unit_cost"), + InventoryTxn.amount.label("amount"), + InventoryTxn.source_doc_type.label("source_doc_type"), + InventoryTxn.source_doc_id.label("source_doc_id"), + InventoryTxn.source_line_id.label("source_line_id"), + StockLot.source_material_lot_id.label("source_material_lot_id"), + func.coalesce(source_material_lot.lot_no, StockLot.source_material_sub_batch_no).label( + "source_material_lot_no" + ), + StockLot.source_material_sub_batch_no.label("source_material_sub_batch_no"), + StockLot.source_material_summary.label("source_material_summary"), + InventoryTxn.biz_time.label("biz_time"), + InventoryTxn.logistics_waybill_no.label("logistics_waybill_no"), + InventoryTxn.logistics_freight_amount.label("logistics_freight_amount"), + InventoryTxn.logistics_photo_url.label("logistics_photo_url"), + InventoryTxn.remark.label("remark"), + archive_document_type_expr, + archive_business_id_expr, + func.coalesce(latest_archive_subquery.c.archive_status, ARCHIVE_STATUS_MISSING).label("archive_status"), + latest_archive_subquery.c.archive_error_message.label("archive_error_message"), + ) + .join(Item, Item.id == InventoryTxn.item_id) + .join(Warehouse, Warehouse.id == InventoryTxn.warehouse_id) + .outerjoin(WarehouseLocation, WarehouseLocation.id == InventoryTxn.location_id) + .outerjoin(StockLot, StockLot.id == InventoryTxn.lot_id) + .outerjoin(source_material_lot, source_material_lot.id == StockLot.source_material_lot_id) + .outerjoin( + latest_archive_subquery, + and_( + latest_archive_subquery.c.document_type == archive_document_type_expr, + latest_archive_subquery.c.business_id == archive_business_id_expr, + latest_archive_subquery.c.row_no == 1, + ), + ) + .order_by(InventoryTxn.biz_time.desc(), InventoryTxn.id.desc()) + .limit(limit) + ) + + +def get_inventory_settlements(db: Session, limit: int = 200) -> list[dict[str, object]]: + payable_amount = func.coalesce( + func.sum( + PurchaseReceiptItem.received_weight_kg * PurchaseReceiptItem.unit_cost + ), + 0, + ) + payable_rows = db.execute( + select( + PurchaseReceipt.id.label("source_doc_id"), + PurchaseReceipt.receipt_no.label("doc_no"), + Supplier.supplier_name.label("counterparty_name"), + PurchaseReceipt.receipt_date.label("biz_date"), + payable_amount.label("amount"), + PurchaseReceipt.status.label("status"), + PurchaseReceipt.remark.label("remark"), + ) + .join(PurchaseOrder, PurchaseOrder.id == PurchaseReceipt.purchase_order_id) + .join(Supplier, Supplier.id == PurchaseOrder.supplier_id) + .join(PurchaseReceiptItem, PurchaseReceiptItem.receipt_id == PurchaseReceipt.id) + .group_by( + PurchaseReceipt.id, + PurchaseReceipt.receipt_no, + Supplier.supplier_name, + PurchaseReceipt.receipt_date, + PurchaseReceipt.status, + PurchaseReceipt.remark, + ) + .order_by(PurchaseReceipt.receipt_date.desc(), PurchaseReceipt.id.desc()) + .limit(limit) + ).mappings().all() + + order_customer = aliased(Customer) + direct_customer = aliased(Customer) + receivable_rows = db.execute( + select( + Delivery.id.label("source_doc_id"), + Delivery.delivery_no.label("doc_no"), + func.coalesce(direct_customer.customer_name, order_customer.customer_name).label("counterparty_name"), + Delivery.delivery_date.label("biz_date"), + func.coalesce(func.sum(DeliveryItem.line_amount), 0).label("amount"), + Delivery.status.label("status"), + Delivery.remark.label("remark"), + ) + .outerjoin(SalesOrder, SalesOrder.id == Delivery.sales_order_id) + .outerjoin(order_customer, order_customer.id == SalesOrder.customer_id) + .outerjoin(direct_customer, direct_customer.id == Delivery.customer_id) + .join(DeliveryItem, DeliveryItem.delivery_id == Delivery.id) + .group_by( + Delivery.id, + Delivery.delivery_no, + order_customer.customer_name, + direct_customer.customer_name, + Delivery.delivery_date, + Delivery.status, + Delivery.remark, + ) + .order_by(Delivery.delivery_date.desc(), Delivery.id.desc()) + .limit(limit) + ).mappings().all() + + rows: list[dict[str, object]] = [] + for row in payable_rows: + amount = to_decimal(row["amount"], "0.01") + rows.append( + { + "settlement_type": "PAYABLE", + "source_doc_type": "PURCHASE_RECEIPT", + "source_doc_id": row["source_doc_id"], + "doc_no": row["doc_no"], + "counterparty_name": row["counterparty_name"], + "biz_date": row["biz_date"], + "amount": float(amount), + "settled_amount": 0, + "open_amount": float(amount), + "status": row["status"], + "remark": row["remark"], + } + ) + for row in receivable_rows: + amount = to_decimal(row["amount"], "0.01") + rows.append( + { + "settlement_type": "RECEIVABLE", + "source_doc_type": "DELIVERY", + "source_doc_id": row["source_doc_id"], + "doc_no": row["doc_no"], + "counterparty_name": row["counterparty_name"], + "biz_date": row["biz_date"], + "amount": float(amount), + "settled_amount": 0, + "open_amount": float(amount), + "status": row["status"], + "remark": row["remark"], + } + ) + return sorted(rows, key=lambda item: str(item["biz_date"]), reverse=True)[:limit] + + +def get_work_orders_query(limit: int = 100) -> Select: + product_item = aliased(Item) + line_subquery = ( + select( + WorkOrderOperation.work_order_id.label("work_order_id"), + func.count(WorkOrderOperation.id).label("operation_count"), + func.coalesce(func.sum(WorkOrderOperation.is_abnormal), 0).label("abnormal_operation_count"), + func.coalesce( + func.sum( + (WorkOrderOperation.status == "DONE").cast(Integer) # type: ignore[attr-defined] + ), + 0, + ).label("done_count"), + ) + .group_by(WorkOrderOperation.work_order_id) + .subquery() + ) + final_operation_seq_subquery = ( + select( + WorkOrderOperation.work_order_id.label("work_order_id"), + func.max(WorkOrderOperation.seq_no).label("max_seq_no"), + ) + .group_by(WorkOrderOperation.work_order_id) + .subquery() + ) + final_operation = aliased(WorkOrderOperation) + active_scrap_record_ids = ( + select(func.max(ScrapRecord.id).label("scrap_record_id")) + .where(ScrapRecord.status != "OVERRIDDEN") + .group_by(ScrapRecord.report_id) + .subquery() + ) + rework_subquery = ( + select( + ScrapRecord.work_order_id.label("work_order_id"), + func.coalesce(func.sum(ScrapRecord.scrap_qty), 0).label("rework_qty"), + ) + .where(ScrapRecord.id.in_(select(active_scrap_record_ids.c.scrap_record_id))) + .where( + ScrapRecord.disposal_type == "REWORK", + ScrapRecord.status == "CONFIRMED", + ) + .group_by(ScrapRecord.work_order_id) + .subquery() + ) + receipt_subquery = ( + select( + CompletionReceipt.work_order_id.label("work_order_id"), + func.coalesce(func.sum(CompletionReceiptItem.receipt_qty), 0).label("received_qty"), + ) + .join(CompletionReceiptItem, CompletionReceiptItem.completion_receipt_id == CompletionReceipt.id) + .group_by(CompletionReceipt.work_order_id) + .subquery() + ) + return ( + select( + WorkOrder.id.label("work_order_id"), + WorkOrder.work_order_no.label("work_order_no"), + WorkOrder.work_order_type.label("work_order_type"), + WorkOrder.source_sales_order_item_id.label("source_sales_order_item_id"), + SalesOrder.order_no.label("source_order_no"), + WorkOrder.product_item_id.label("product_item_id"), + product_item.item_code.label("product_code"), + product_item.item_name.label("product_name"), + Product.version_no.label("product_version_no"), + WorkOrder.route_id.label("route_id"), + ProcessRoute.route_name.label("route_name"), + WorkOrder.bom_id.label("bom_id"), + WorkOrder.planned_qty.label("planned_qty"), + WorkOrder.released_qty.label("released_qty"), + WorkOrder.finished_qty.label("finished_qty"), + WorkOrder.scrap_qty.label("scrap_qty"), + WorkOrder.planned_start_time.label("planned_start_time"), + WorkOrder.planned_end_time.label("planned_end_time"), + WorkOrder.actual_start_time.label("actual_start_time"), + WorkOrder.actual_end_time.label("actual_end_time"), + WorkOrder.priority_level.label("priority_level"), + WorkOrder.status.label("status"), + WorkOrder.remark.label("remark"), + func.coalesce(line_subquery.c.operation_count, 0).label("operation_count"), + func.coalesce(line_subquery.c.abnormal_operation_count, 0).label("abnormal_operation_count"), + ( + func.coalesce(final_operation.good_qty, 0) + func.coalesce(rework_subquery.c.rework_qty, 0) + ).label("reported_good_qty"), + func.coalesce(receipt_subquery.c.received_qty, 0).label("received_qty"), + func.greatest( + ( + func.coalesce(final_operation.good_qty, 0) + func.coalesce(rework_subquery.c.rework_qty, 0) + ) + - func.coalesce(receipt_subquery.c.received_qty, 0), + 0, + ).label("max_receivable_qty"), + ( + (func.coalesce(line_subquery.c.operation_count, 0) > 0) + & (func.coalesce(line_subquery.c.done_count, 0) == func.coalesce(line_subquery.c.operation_count, 0)) + ).label("all_operations_done"), + ) + .join(product_item, product_item.id == WorkOrder.product_item_id) + .join(ProcessRoute, ProcessRoute.id == WorkOrder.route_id) + .outerjoin(Product, Product.item_id == WorkOrder.product_item_id) + .outerjoin(SalesOrderItem, SalesOrderItem.id == WorkOrder.source_sales_order_item_id) + .outerjoin(SalesOrder, SalesOrder.id == SalesOrderItem.sales_order_id) + .outerjoin(line_subquery, line_subquery.c.work_order_id == WorkOrder.id) + .outerjoin(final_operation_seq_subquery, final_operation_seq_subquery.c.work_order_id == WorkOrder.id) + .outerjoin(rework_subquery, rework_subquery.c.work_order_id == WorkOrder.id) + .outerjoin( + final_operation, + and_( + final_operation.work_order_id == WorkOrder.id, + final_operation.seq_no == final_operation_seq_subquery.c.max_seq_no, + ), + ) + .outerjoin(receipt_subquery, receipt_subquery.c.work_order_id == WorkOrder.id) + .order_by(WorkOrder.id.desc()) + .limit(limit) + ) + + +def get_work_order_materials_query(limit: int = 200, work_order_id: int | None = None) -> Select: + issue_subquery = ( + select( + WorkOrderMaterialIssue.work_order_material_id.label("work_order_material_id"), + func.group_concat(WorkOrderMaterialIssue.material_sub_batch_no).label("material_sub_batch_summary"), + func.coalesce(func.sum(WorkOrderMaterialIssue.amount), 0).label("issue_amount"), + ) + .group_by(WorkOrderMaterialIssue.work_order_material_id) + .subquery() + ) + stmt = ( + select( + WorkOrderMaterial.id.label("work_order_material_id"), + WorkOrderMaterial.work_order_id.label("work_order_id"), + WorkOrder.work_order_no.label("work_order_no"), + WorkOrderMaterial.bom_item_id.label("bom_item_id"), + WorkOrderMaterial.material_item_id.label("material_item_id"), + Item.item_code.label("material_code"), + Item.item_name.label("material_name"), + literal(0).label("required_qty"), + WorkOrderMaterial.required_weight_kg.label("required_weight_kg"), + literal(0).label("issued_qty"), + WorkOrderMaterial.issued_weight_kg.label("issued_weight_kg"), + literal(0).label("returned_qty"), + WorkOrderMaterial.returned_weight_kg.label("returned_weight_kg"), + issue_subquery.c.material_sub_batch_summary.label("material_sub_batch_summary"), + func.coalesce(issue_subquery.c.issue_amount, 0).label("issue_amount"), + WorkOrderMaterial.status.label("status"), + WorkOrderMaterial.remark.label("remark"), + ) + .join(WorkOrder, WorkOrder.id == WorkOrderMaterial.work_order_id) + .join(Item, Item.id == WorkOrderMaterial.material_item_id) + .outerjoin(issue_subquery, issue_subquery.c.work_order_material_id == WorkOrderMaterial.id) + .order_by(WorkOrderMaterial.id.desc()) + .limit(limit) + ) + if work_order_id: + stmt = stmt.where(WorkOrderMaterial.work_order_id == work_order_id) + return stmt + + +def get_work_order_material_issues_query(limit: int = 300, work_order_id: int | None = None) -> Select: + stmt = ( + select( + WorkOrderMaterialIssue.id.label("material_issue_id"), + WorkOrderMaterialIssue.work_order_material_id.label("work_order_material_id"), + WorkOrderMaterialIssue.work_order_id.label("work_order_id"), + WorkOrder.work_order_no.label("work_order_no"), + WorkOrderMaterialIssue.material_item_id.label("material_item_id"), + Item.item_code.label("material_code"), + Item.item_name.label("material_name"), + WorkOrderMaterialIssue.source_lot_id.label("source_lot_id"), + StockLot.lot_no.label("source_lot_no"), + WorkOrderMaterialIssue.material_sub_batch_no.label("material_sub_batch_no"), + literal(0).label("issued_qty"), + WorkOrderMaterialIssue.issued_weight_kg.label("issued_weight_kg"), + WorkOrderMaterialIssue.unit_cost.label("unit_cost"), + WorkOrderMaterialIssue.amount.label("amount"), + WorkOrderMaterialIssue.issue_time.label("issue_time"), + WorkOrderMaterialIssue.remark.label("remark"), + ) + .join(WorkOrder, WorkOrder.id == WorkOrderMaterialIssue.work_order_id) + .join(Item, Item.id == WorkOrderMaterialIssue.material_item_id) + .join(StockLot, StockLot.id == WorkOrderMaterialIssue.source_lot_id) + .order_by(WorkOrderMaterialIssue.issue_time.desc(), WorkOrderMaterialIssue.id.desc()) + .limit(limit) + ) + if work_order_id: + stmt = stmt.where(WorkOrderMaterialIssue.work_order_id == work_order_id) + return stmt + + +def get_work_order_operations_query(limit: int = 200, work_order_id: int | None = None) -> Select: + stmt = ( + select( + WorkOrderOperation.id.label("work_order_operation_id"), + WorkOrderOperation.work_order_id.label("work_order_id"), + WorkOrder.work_order_no.label("work_order_no"), + WorkOrderOperation.route_operation_id.label("route_operation_id"), + WorkOrderOperation.seq_no.label("seq_no"), + WorkOrderOperation.operation_name.label("operation_name"), + WorkOrderOperation.work_center_id.label("work_center_id"), + WorkCenter.center_name.label("work_center_name"), + WorkOrderOperation.planned_qty.label("planned_qty"), + WorkOrderOperation.report_qty.label("report_qty"), + WorkOrderOperation.good_qty.label("good_qty"), + WorkOrderOperation.scrap_qty.label("scrap_qty"), + WorkOrderOperation.rework_qty.label("rework_qty"), + WorkOrderOperation.std_labor_cost.label("std_labor_cost"), + WorkOrderOperation.std_machine_cost.label("std_machine_cost"), + WorkOrderOperation.std_outsource_cost.label("std_outsource_cost"), + WorkOrderOperation.allowed_scrap_rate.label("allowed_scrap_rate"), + WorkOrderOperation.is_abnormal.label("is_abnormal"), + WorkOrderOperation.status.label("status"), + WorkOrderOperation.remark.label("remark"), + ) + .join(WorkOrder, WorkOrder.id == WorkOrderOperation.work_order_id) + .join(WorkCenter, WorkCenter.id == WorkOrderOperation.work_center_id) + .order_by(WorkOrderOperation.work_order_id.desc(), WorkOrderOperation.seq_no) + .limit(limit) + ) + if work_order_id: + stmt = stmt.where(WorkOrderOperation.work_order_id == work_order_id) + return stmt + + +def get_operation_reports_query(limit: int = 200, work_order_id: int | None = None) -> Select: + employee = aliased(Employee) + equipment = aliased(Equipment) + stmt = ( + select( + OperationReport.id.label("report_id"), + OperationReport.report_no.label("report_no"), + OperationReport.work_order_id.label("work_order_id"), + WorkOrder.work_order_no.label("work_order_no"), + OperationReport.work_order_operation_id.label("work_order_operation_id"), + WorkOrderOperation.seq_no.label("seq_no"), + WorkOrderOperation.operation_name.label("operation_name"), + OperationReport.employee_id.label("employee_id"), + employee.employee_name.label("employee_name"), + OperationReport.equipment_id.label("equipment_id"), + equipment.equipment_name.label("equipment_name"), + OperationReport.report_source.label("report_source"), + OperationReport.shift_code.label("shift_code"), + OperationReport.start_time.label("start_time"), + OperationReport.end_time.label("end_time"), + OperationReport.report_qty.label("report_qty"), + OperationReport.good_qty.label("good_qty"), + OperationReport.scrap_qty.label("scrap_qty"), + OperationReport.rework_qty.label("rework_qty"), + OperationReport.downtime_minutes.label("downtime_minutes"), + OperationReport.extra_cost_amount.label("extra_cost_amount"), + OperationReport.is_abnormal.label("is_abnormal"), + WorkOrderOperation.allowed_scrap_rate.label("allowed_scrap_rate"), + ProcessRouteOperation.std_cycle_seconds.label("ref_cycle_seconds"), + OperationReport.scrap_reason.label("scrap_reason"), + OperationReport.remark.label("remark"), + ) + .join(WorkOrder, WorkOrder.id == OperationReport.work_order_id) + .join(WorkOrderOperation, WorkOrderOperation.id == OperationReport.work_order_operation_id) + .outerjoin(ProcessRouteOperation, ProcessRouteOperation.id == WorkOrderOperation.route_operation_id) + .join(employee, employee.id == OperationReport.employee_id) + .outerjoin(equipment, equipment.id == OperationReport.equipment_id) + .order_by(OperationReport.id.desc()) + .limit(limit) + ) + if work_order_id: + stmt = stmt.where(OperationReport.work_order_id == work_order_id) + return stmt + + +def get_scrap_records_query(limit: int = 200) -> Select: + return ( + select( + ScrapRecord.id.label("scrap_record_id"), + ScrapRecord.scrap_no.label("scrap_no"), + ScrapRecord.report_id.label("report_id"), + OperationReport.report_no.label("report_no"), + ScrapRecord.work_order_id.label("work_order_id"), + WorkOrder.work_order_no.label("work_order_no"), + ScrapRecord.work_order_operation_id.label("work_order_operation_id"), + WorkOrderOperation.operation_name.label("operation_name"), + ScrapRecord.item_id.label("item_id"), + Item.item_code.label("item_code"), + Item.item_name.label("item_name"), + ScrapRecord.scrap_qty.label("scrap_qty"), + ScrapRecord.scrap_weight_kg.label("scrap_weight_kg"), + ScrapRecord.scrap_reason.label("scrap_reason"), + ScrapRecord.disposal_type.label("disposal_type"), + ScrapRecord.saleable_scrap_weight_kg.label("saleable_scrap_weight_kg"), + ScrapRecord.scrap_cost_amount.label("scrap_cost_amount"), + ScrapRecord.status.label("status"), + ScrapRecord.created_at.label("created_at"), + ) + .join(OperationReport, OperationReport.id == ScrapRecord.report_id) + .join(WorkOrder, WorkOrder.id == ScrapRecord.work_order_id) + .join(WorkOrderOperation, WorkOrderOperation.id == ScrapRecord.work_order_operation_id) + .join(Item, Item.id == ScrapRecord.item_id) + .order_by(ScrapRecord.id.desc()) + .limit(limit) + ) + + +def get_completion_receipts_query(limit: int = 100) -> Select: + line_subquery = ( + select( + CompletionReceiptItem.completion_receipt_id.label("completion_receipt_id"), + func.count(CompletionReceiptItem.id).label("line_count"), + func.coalesce(func.sum(CompletionReceiptItem.receipt_qty), 0).label("total_receipt_qty"), + func.coalesce(func.sum(CompletionReceiptItem.receipt_weight_kg), 0).label("total_receipt_weight_kg"), + ) + .group_by(CompletionReceiptItem.completion_receipt_id) + .subquery() + ) + first_item_subquery = ( + select( + CompletionReceiptItem.completion_receipt_id.label("completion_receipt_id"), + func.min(CompletionReceiptItem.product_item_id).label("product_item_id"), + ) + .group_by(CompletionReceiptItem.completion_receipt_id) + .subquery() + ) + receiver = aliased(Employee) + return ( + select( + CompletionReceipt.id.label("completion_receipt_id"), + CompletionReceipt.receipt_no.label("receipt_no"), + CompletionReceipt.work_order_id.label("work_order_id"), + WorkOrder.work_order_no.label("work_order_no"), + first_item_subquery.c.product_item_id.label("product_item_id"), + Item.item_code.label("product_code"), + Item.item_name.label("product_name"), + CompletionReceipt.warehouse_id.label("warehouse_id"), + Warehouse.warehouse_name.label("warehouse_name"), + CompletionReceipt.receipt_time.label("receipt_time"), + CompletionReceipt.receiver_employee_id.label("receiver_employee_id"), + receiver.employee_name.label("receiver_name"), + CompletionReceipt.status.label("status"), + func.coalesce(line_subquery.c.line_count, 0).label("line_count"), + func.coalesce(line_subquery.c.total_receipt_qty, 0).label("total_receipt_qty"), + func.coalesce(line_subquery.c.total_receipt_weight_kg, 0).label("total_receipt_weight_kg"), + CompletionReceipt.remark.label("remark"), + ) + .join(WorkOrder, WorkOrder.id == CompletionReceipt.work_order_id) + .join(Warehouse, Warehouse.id == CompletionReceipt.warehouse_id) + .outerjoin(receiver, receiver.id == CompletionReceipt.receiver_employee_id) + .outerjoin(line_subquery, line_subquery.c.completion_receipt_id == CompletionReceipt.id) + .outerjoin(first_item_subquery, first_item_subquery.c.completion_receipt_id == CompletionReceipt.id) + .outerjoin(Item, Item.id == first_item_subquery.c.product_item_id) + .order_by(CompletionReceipt.id.desc()) + .limit(limit) + ) + + +def get_completion_receipt_items_query(limit: int = 200, receipt_id: int | None = None) -> Select: + source_material_lot = aliased(StockLot) + stmt = ( + select( + CompletionReceiptItem.id.label("completion_receipt_item_id"), + CompletionReceiptItem.completion_receipt_id.label("completion_receipt_id"), + CompletionReceipt.receipt_no.label("receipt_no"), + CompletionReceiptItem.line_no.label("line_no"), + CompletionReceiptItem.product_item_id.label("product_item_id"), + Item.item_code.label("product_code"), + Item.item_name.label("product_name"), + CompletionReceipt.warehouse_id.label("warehouse_id"), + Warehouse.warehouse_name.label("warehouse_name"), + CompletionReceiptItem.location_id.label("location_id"), + WarehouseLocation.location_name.label("location_name"), + CompletionReceiptItem.lot_no.label("lot_no"), + CompletionReceiptItem.source_material_lot_id.label("source_material_lot_id"), + func.coalesce(source_material_lot.lot_no, CompletionReceiptItem.source_material_sub_batch_no).label( + "source_material_lot_no" + ), + func.coalesce(source_material_lot.lot_no, CompletionReceiptItem.source_material_sub_batch_no).label( + "source_material_sub_batch_no" + ), + CompletionReceiptItem.source_material_summary.label("source_material_summary"), + CompletionReceiptItem.receipt_qty.label("receipt_qty"), + CompletionReceiptItem.receipt_weight_kg.label("receipt_weight_kg"), + CompletionReceiptItem.unit_cost.label("unit_cost"), + CompletionReceiptItem.status.label("status"), + CompletionReceiptItem.remark.label("remark"), + ) + .join(CompletionReceipt, CompletionReceipt.id == CompletionReceiptItem.completion_receipt_id) + .join(Warehouse, Warehouse.id == CompletionReceipt.warehouse_id) + .join(Item, Item.id == CompletionReceiptItem.product_item_id) + .outerjoin(WarehouseLocation, WarehouseLocation.id == CompletionReceiptItem.location_id) + .outerjoin(source_material_lot, source_material_lot.id == CompletionReceiptItem.source_material_lot_id) + .order_by(CompletionReceiptItem.id.desc()) + .limit(limit) + ) + if receipt_id: + stmt = stmt.where(CompletionReceiptItem.completion_receipt_id == receipt_id) + return stmt + + +def get_deliveries_query(limit: int = 100) -> Select: + line_subquery = ( + select( + DeliveryItem.delivery_id.label("delivery_id"), + func.count(DeliveryItem.id).label("line_count"), + func.coalesce(func.sum(DeliveryItem.delivery_qty), 0).label("total_delivery_qty"), + func.coalesce(func.sum(DeliveryItem.line_amount), 0).label("total_line_amount"), + func.group_concat(func.distinct(Item.item_name)).label("product_names"), + ) + .join(Item, Item.id == DeliveryItem.product_item_id) + .group_by(DeliveryItem.delivery_id) + .subquery() + ) + shipper = aliased(Employee) + order_customer = aliased(Customer) + direct_customer = aliased(Customer) + return ( + select( + Delivery.id.label("delivery_id"), + Delivery.delivery_no.label("delivery_no"), + Delivery.sales_order_id.label("sales_order_id"), + Delivery.customer_id.label("customer_id"), + SalesOrder.order_no.label("order_no"), + func.coalesce(direct_customer.customer_name, order_customer.customer_name).label("customer_name"), + Delivery.warehouse_id.label("warehouse_id"), + Warehouse.warehouse_name.label("warehouse_name"), + Delivery.delivery_date.label("delivery_date"), + Delivery.shipper_employee_id.label("shipper_employee_id"), + shipper.employee_name.label("shipper_name"), + Delivery.consignee_name.label("consignee_name"), + Delivery.consignee_phone.label("consignee_phone"), + Delivery.delivery_address.label("delivery_address"), + Delivery.logistics_waybill_no.label("logistics_waybill_no"), + Delivery.logistics_freight_amount.label("logistics_freight_amount"), + Delivery.logistics_photo_url.label("logistics_photo_url"), + line_subquery.c.product_names.label("product_names"), + Delivery.status.label("status"), + func.coalesce(line_subquery.c.line_count, 0).label("line_count"), + func.coalesce(line_subquery.c.total_delivery_qty, 0).label("total_delivery_qty"), + func.coalesce(line_subquery.c.total_line_amount, 0).label("total_line_amount"), + Delivery.remark.label("remark"), + ) + .outerjoin(SalesOrder, SalesOrder.id == Delivery.sales_order_id) + .outerjoin(order_customer, order_customer.id == SalesOrder.customer_id) + .outerjoin(direct_customer, direct_customer.id == Delivery.customer_id) + .join(Warehouse, Warehouse.id == Delivery.warehouse_id) + .outerjoin(shipper, shipper.id == Delivery.shipper_employee_id) + .outerjoin(line_subquery, line_subquery.c.delivery_id == Delivery.id) + .order_by(Delivery.id.desc()) + .limit(limit) + ) + + +def get_delivery_items_query(limit: int = 200, delivery_id: int | None = None) -> Select: + source_material_lot = aliased(StockLot) + source_material_item = aliased(Item) + stmt = ( + select( + DeliveryItem.id.label("delivery_item_id"), + DeliveryItem.delivery_id.label("delivery_id"), + Delivery.delivery_no.label("delivery_no"), + DeliveryItem.line_no.label("line_no"), + DeliveryItem.sales_order_item_id.label("sales_order_item_id"), + SalesOrder.order_no.label("order_no"), + DeliveryItem.product_item_id.label("product_item_id"), + Item.item_code.label("product_code"), + Item.item_name.label("product_name"), + DeliveryItem.lot_id.label("lot_id"), + StockLot.lot_no.label("lot_no"), + StockLot.source_material_lot_id.label("source_material_lot_id"), + func.coalesce(source_material_lot.lot_no, StockLot.source_material_sub_batch_no).label("source_material_lot_no"), + source_material_item.item_code.label("source_material_code"), + source_material_item.item_name.label("source_material_name"), + func.coalesce(source_material_lot.lot_no, StockLot.source_material_sub_batch_no).label( + "source_material_sub_batch_no" + ), + StockLot.source_material_summary.label("source_material_summary"), + DeliveryItem.delivery_qty.label("delivery_qty"), + DeliveryItem.delivery_weight_kg.label("delivery_weight_kg"), + DeliveryItem.unit_price.label("unit_price"), + DeliveryItem.line_amount.label("line_amount"), + DeliveryItem.status.label("status"), + DeliveryItem.remark.label("remark"), + ) + .join(Delivery, Delivery.id == DeliveryItem.delivery_id) + .outerjoin(SalesOrder, SalesOrder.id == Delivery.sales_order_id) + .join(Item, Item.id == DeliveryItem.product_item_id) + .outerjoin(StockLot, StockLot.id == DeliveryItem.lot_id) + .outerjoin(source_material_lot, source_material_lot.id == StockLot.source_material_lot_id) + .outerjoin(source_material_item, source_material_item.id == source_material_lot.item_id) + .order_by(DeliveryItem.id.desc()) + .limit(limit) + ) + if delivery_id: + stmt = stmt.where(DeliveryItem.delivery_id == delivery_id) + return stmt + + +def get_equipment_query(limit: int = 100) -> Select: + return ( + select( + Equipment.id.label("equipment_id"), + Equipment.equipment_code.label("equipment_code"), + Equipment.equipment_name.label("equipment_name"), + Equipment.category_name.label("category_name"), + Equipment.work_center_id.label("work_center_id"), + WorkCenter.center_name.label("work_center_name"), + Equipment.manufacturer.label("manufacturer"), + Equipment.model_no.label("model_no"), + Equipment.install_date.label("install_date"), + Equipment.rated_power_kw.label("rated_power_kw"), + Equipment.occupied_area_sqm.label("occupied_area_sqm"), + Equipment.maintenance_cycle_hours.label("maintenance_cycle_hours"), + Equipment.last_maintenance_at.label("last_maintenance_at"), + Equipment.next_maintenance_at.label("next_maintenance_at"), + Equipment.status.label("status"), + Equipment.remark.label("remark"), + ) + .join(WorkCenter, WorkCenter.id == Equipment.work_center_id) + .order_by(Equipment.id.desc()) + .limit(limit) + ) + + +def get_maintenance_plans_query(limit: int = 100) -> Select: + return ( + select( + MaintenancePlan.id.label("maintenance_plan_id"), + MaintenancePlan.plan_code.label("plan_code"), + MaintenancePlan.equipment_id.label("equipment_id"), + Equipment.equipment_code.label("equipment_code"), + Equipment.equipment_name.label("equipment_name"), + MaintenancePlan.plan_name.label("plan_name"), + MaintenancePlan.maintenance_type.label("maintenance_type"), + MaintenancePlan.cycle_hours.label("cycle_hours"), + MaintenancePlan.cycle_days.label("cycle_days"), + MaintenancePlan.last_execute_at.label("last_execute_at"), + MaintenancePlan.next_execute_at.label("next_execute_at"), + MaintenancePlan.status.label("status"), + MaintenancePlan.remark.label("remark"), + ) + .join(Equipment, Equipment.id == MaintenancePlan.equipment_id) + .order_by(MaintenancePlan.id.desc()) + .limit(limit) + ) + + +def get_maintenance_orders_query(limit: int = 100) -> Select: + owner = aliased(Employee) + return ( + select( + MaintenanceOrder.id.label("maintenance_order_id"), + MaintenanceOrder.order_no.label("order_no"), + MaintenanceOrder.equipment_id.label("equipment_id"), + Equipment.equipment_code.label("equipment_code"), + Equipment.equipment_name.label("equipment_name"), + MaintenanceOrder.plan_id.label("plan_id"), + MaintenancePlan.plan_name.label("plan_name"), + MaintenanceOrder.maintenance_type.label("maintenance_type"), + MaintenanceOrder.start_time.label("start_time"), + MaintenanceOrder.end_time.label("end_time"), + MaintenanceOrder.downtime_minutes.label("downtime_minutes"), + MaintenanceOrder.owner_employee_id.label("owner_employee_id"), + owner.employee_name.label("owner_name"), + MaintenanceOrder.result_summary.label("result_summary"), + MaintenanceOrder.cost_amount.label("cost_amount"), + MaintenanceOrder.status.label("status"), + ) + .join(Equipment, Equipment.id == MaintenanceOrder.equipment_id) + .outerjoin(MaintenancePlan, MaintenancePlan.id == MaintenanceOrder.plan_id) + .outerjoin(owner, owner.id == MaintenanceOrder.owner_employee_id) + .order_by(MaintenanceOrder.id.desc()) + .limit(limit) + ) + + +def get_quality_inspections_query(limit: int = 100, pending_only: bool = False) -> Select: + latest_archive_version_subquery = ( + select( + DocumentArchive.business_id.label("business_id"), + func.max(DocumentArchive.archive_version).label("archive_version"), + ) + .where(DocumentArchive.document_type == "质量校验单", DocumentArchive.file_format == "PDF") + .group_by(DocumentArchive.business_id) + .subquery() + ) + latest_archive_id_subquery = ( + select( + DocumentArchive.business_id.label("business_id"), + func.max(DocumentArchive.id).label("archive_id"), + ) + .join( + latest_archive_version_subquery, + and_( + latest_archive_version_subquery.c.business_id == DocumentArchive.business_id, + latest_archive_version_subquery.c.archive_version == DocumentArchive.archive_version, + ), + ) + .where(DocumentArchive.document_type == "质量校验单", DocumentArchive.file_format == "PDF") + .group_by(DocumentArchive.business_id) + .subquery() + ) + latest_archive_subquery = ( + select( + DocumentArchive.business_id.label("business_id"), + DocumentArchive.status.label("archive_status"), + DocumentArchive.archive_version.label("archive_version"), + DocumentArchive.created_at.label("archive_created_at"), + DocumentArchive.error_message.label("archive_error_message"), + ) + .join( + latest_archive_id_subquery, + latest_archive_id_subquery.c.archive_id == DocumentArchive.id, + ) + .where(DocumentArchive.document_type == "质量校验单", DocumentArchive.file_format == "PDF") + .subquery() + ) + stmt = ( + select( + PurchaseReceiptItem.id.label("receipt_item_id"), + PurchaseReceiptItem.receipt_id.label("receipt_id"), + PurchaseReceipt.receipt_no.label("receipt_no"), + PurchaseOrder.po_no.label("po_no"), + Supplier.supplier_name.label("supplier_name"), + PurchaseOrder.target_warehouse_type.label("target_warehouse_type"), + PurchaseReceiptItem.material_item_id.label("material_item_id"), + Item.item_code.label("material_code"), + Item.item_name.label("material_name"), + Warehouse.warehouse_name.label("warehouse_name"), + WarehouseLocation.location_name.label("location_name"), + PurchaseReceiptItem.lot_no.label("lot_no"), + PurchaseReceiptItem.received_qty.label("received_qty"), + PurchaseReceiptItem.received_weight_kg.label("received_weight_kg"), + PurchaseReceiptItem.accepted_qty.label("accepted_qty"), + PurchaseReceiptItem.accepted_weight_kg.label("accepted_weight_kg"), + PurchaseReceiptItem.status.label("status"), + case( + (PurchaseReceiptItem.status == "PENDING_QC", "PENDING_QC"), + (PurchaseReceiptItem.status == "PASSED", "PASS"), + (PurchaseReceiptItem.status == "REJECTED", "REJECT"), + else_=func.coalesce(StockLot.quality_status, "PENDING_QC"), + ).label("quality_status"), + StockLot.remark.label("inspector_name"), + PurchaseReceipt.receipt_date.label("receipt_date"), + PurchaseReceiptItem.remark.label("remark"), + func.coalesce(latest_archive_subquery.c.archive_status, "未生成").label("archive_status"), + latest_archive_subquery.c.archive_version.label("archive_version"), + latest_archive_subquery.c.archive_created_at.label("archive_created_at"), + latest_archive_subquery.c.archive_error_message.label("archive_error_message"), + ) + .join(PurchaseReceipt, PurchaseReceipt.id == PurchaseReceiptItem.receipt_id) + .join(PurchaseOrder, PurchaseOrder.id == PurchaseReceipt.purchase_order_id) + .join(Supplier, Supplier.id == PurchaseOrder.supplier_id) + .join(Item, Item.id == PurchaseReceiptItem.material_item_id) + .join(Warehouse, Warehouse.id == PurchaseReceipt.warehouse_id) + .outerjoin(WarehouseLocation, WarehouseLocation.id == PurchaseReceiptItem.location_id) + .outerjoin( + StockLot, + or_( + and_( + StockLot.source_doc_type == "PURCHASE_RECEIPT", + StockLot.source_doc_id == PurchaseReceipt.id, + StockLot.source_line_id == PurchaseReceiptItem.id, + ), + StockLot.id == PurchaseReceiptItem.merge_to_lot_id, + ), + ) + .outerjoin(latest_archive_subquery, latest_archive_subquery.c.business_id == PurchaseReceiptItem.id) + .order_by(PurchaseReceiptItem.id.desc()) + .limit(limit) + ) + if pending_only: + stmt = stmt.where(PurchaseReceiptItem.status == "PENDING_QC") + return stmt + + +def get_periods_query(limit: int = 50) -> Select: + return ( + select( + AccountingPeriod.id.label("period_id"), + AccountingPeriod.period_code.label("period_code"), + AccountingPeriod.year_no.label("year_no"), + AccountingPeriod.month_no.label("month_no"), + AccountingPeriod.start_date.label("start_date"), + AccountingPeriod.end_date.label("end_date"), + AccountingPeriod.close_status.label("close_status"), + AccountingPeriod.closed_at.label("closed_at"), + ) + .order_by(AccountingPeriod.year_no.desc(), AccountingPeriod.month_no.desc()) + .limit(limit) + ) + + +def get_overheads_query(limit: int = 100) -> Select: + return ( + select( + OverheadEntry.id.label("overhead_entry_id"), + OverheadEntry.period_id.label("period_id"), + AccountingPeriod.period_code.label("period_code"), + AccountingPeriod.year_no.label("year_no"), + AccountingPeriod.month_no.label("month_no"), + OverheadEntry.overhead_type.label("overhead_type"), + OverheadEntry.dept_id.label("dept_id"), + Department.dept_name.label("dept_name"), + OverheadEntry.amount.label("amount"), + OverheadEntry.allocation_basis.label("allocation_basis"), + OverheadEntry.description.label("description"), + OverheadEntry.created_at.label("created_at"), + ) + .join(AccountingPeriod, AccountingPeriod.id == OverheadEntry.period_id) + .outerjoin(Department, Department.id == OverheadEntry.dept_id) + .order_by(OverheadEntry.id.desc()) + .limit(limit) + ) + + +def get_statement_snapshots_query(limit: int = 100) -> Select: + return ( + select( + StatementSnapshot.id.label("statement_snapshot_id"), + StatementSnapshot.snapshot_no.label("snapshot_no"), + StatementSnapshot.period_id.label("period_id"), + AccountingPeriod.period_code.label("period_code"), + StatementSnapshot.statement_type.label("statement_type"), + StatementSnapshot.report_date.label("report_date"), + StatementSnapshot.summary_json.label("summary_json"), + StatementSnapshot.created_at.label("created_at"), + ) + .join(AccountingPeriod, AccountingPeriod.id == StatementSnapshot.period_id) + .order_by(StatementSnapshot.id.desc()) + .limit(limit) + ) + + +def get_return_orders_query(limit: int = 100) -> Select: + line_subquery = ( + select( + ReturnItem.return_order_id.label("return_order_id"), + func.count(ReturnItem.id).label("line_count"), + func.coalesce(func.sum(ReturnItem.return_qty), 0).label("total_return_qty"), + func.group_concat(func.distinct(Item.item_name)).label("product_names"), + ) + .join(Item, Item.id == ReturnItem.product_item_id) + .group_by(ReturnItem.return_order_id) + .subquery() + ) + handler = aliased(Employee) + return ( + select( + ReturnOrder.id.label("return_order_id"), + ReturnOrder.return_no.label("return_no"), + ReturnOrder.customer_id.label("customer_id"), + Customer.customer_name.label("customer_name"), + ReturnOrder.sales_order_id.label("sales_order_id"), + SalesOrder.order_no.label("order_no"), + ReturnOrder.delivery_id.label("delivery_id"), + Delivery.delivery_no.label("delivery_no"), + ReturnOrder.return_date.label("return_date"), + ReturnOrder.return_reason.label("return_reason"), + ReturnOrder.handler_employee_id.label("handler_employee_id"), + handler.employee_name.label("handler_name"), + line_subquery.c.product_names.label("product_names"), + ReturnOrder.status.label("status"), + func.coalesce(line_subquery.c.line_count, 0).label("line_count"), + func.coalesce(line_subquery.c.total_return_qty, 0).label("total_return_qty"), + ReturnOrder.remark.label("remark"), + ) + .join(Customer, Customer.id == ReturnOrder.customer_id) + .outerjoin(SalesOrder, SalesOrder.id == ReturnOrder.sales_order_id) + .outerjoin(Delivery, Delivery.id == ReturnOrder.delivery_id) + .outerjoin(handler, handler.id == ReturnOrder.handler_employee_id) + .outerjoin(line_subquery, line_subquery.c.return_order_id == ReturnOrder.id) + .order_by(ReturnOrder.id.desc()) + .limit(limit) + ) + + +def get_return_items_query(limit: int = 200, return_order_id: int | None = None) -> Select: + source_lot = aliased(StockLot) + source_material_lot = aliased(StockLot) + source_material_item = aliased(Item) + stmt = ( + select( + ReturnItem.id.label("return_item_id"), + ReturnItem.return_order_id.label("return_order_id"), + ReturnOrder.return_no.label("return_no"), + ReturnItem.line_no.label("line_no"), + ReturnItem.sales_order_item_id.label("sales_order_item_id"), + ReturnItem.delivery_item_id.label("delivery_item_id"), + Delivery.delivery_no.label("delivery_no"), + ReturnItem.product_item_id.label("product_item_id"), + Item.item_code.label("product_code"), + Item.item_name.label("product_name"), + ReturnItem.source_lot_id.label("source_lot_id"), + source_lot.lot_no.label("source_lot_no"), + source_lot.source_material_lot_id.label("source_material_lot_id"), + func.coalesce(source_material_lot.lot_no, source_lot.source_material_sub_batch_no).label("source_material_lot_no"), + source_material_item.item_code.label("source_material_code"), + source_material_item.item_name.label("source_material_name"), + func.coalesce(source_material_lot.lot_no, source_lot.source_material_sub_batch_no).label( + "source_material_sub_batch_no" + ), + source_lot.source_material_summary.label("source_material_summary"), + ReturnItem.return_qty.label("return_qty"), + ReturnItem.return_weight_kg.label("return_weight_kg"), + ReturnItem.disposition_status.label("disposition_status"), + ReturnItem.responsibility_type.label("responsibility_type"), + ReturnItem.remark.label("remark"), + ) + .join(ReturnOrder, ReturnOrder.id == ReturnItem.return_order_id) + .join(Item, Item.id == ReturnItem.product_item_id) + .outerjoin(DeliveryItem, DeliveryItem.id == ReturnItem.delivery_item_id) + .outerjoin(Delivery, Delivery.id == DeliveryItem.delivery_id) + .outerjoin(source_lot, source_lot.id == ReturnItem.source_lot_id) + .outerjoin(source_material_lot, source_material_lot.id == source_lot.source_material_lot_id) + .outerjoin(source_material_item, source_material_item.id == source_material_lot.item_id) + .order_by(ReturnItem.id.desc()) + .limit(limit) + ) + if return_order_id: + stmt = stmt.where(ReturnItem.return_order_id == return_order_id) + return stmt + + +def get_return_dispositions_query(limit: int = 100) -> Select: + rework_work_order = aliased(WorkOrder) + report_subquery = ( + select( + OperationReport.work_order_id.label("work_order_id"), + func.coalesce(func.sum(OperationReport.good_qty), 0).label("rework_report_good_qty"), + func.coalesce(func.sum(OperationReport.scrap_qty), 0).label("rework_report_scrap_qty"), + ) + .group_by(OperationReport.work_order_id) + .subquery() + ) + finished_inbound_subquery = ( + select( + InventoryTxn.source_doc_id.label("work_order_id"), + func.coalesce(func.sum(InventoryTxn.qty_change), 0).label("rework_finished_inbound_qty"), + ) + .where( + InventoryTxn.txn_type == "REWORK_FINISHED_IN", + InventoryTxn.source_doc_type == "WORK_ORDER", + ) + .group_by(InventoryTxn.source_doc_id) + .subquery() + ) + scrap_inbound_subquery = ( + select( + StockLot.source_doc_id.label("work_order_id"), + func.coalesce(func.sum(StockLot.inbound_qty), 0).label("rework_scrap_inbound_qty"), + ) + .where( + StockLot.lot_role == "REWORK_SCRAP", + StockLot.source_doc_type == "WORK_ORDER", + ) + .group_by(StockLot.source_doc_id) + .subquery() + ) + report_good_qty = func.coalesce(report_subquery.c.rework_report_good_qty, 0) + report_scrap_qty = func.coalesce(report_subquery.c.rework_report_scrap_qty, 0) + finished_inbound_qty = func.coalesce(finished_inbound_subquery.c.rework_finished_inbound_qty, 0) + scrap_inbound_qty = func.coalesce(scrap_inbound_subquery.c.rework_scrap_inbound_qty, 0) + remaining_finished_qty = case((report_good_qty - finished_inbound_qty < 0, 0), else_=report_good_qty - finished_inbound_qty) + remaining_scrap_qty = case((report_scrap_qty - scrap_inbound_qty < 0, 0), else_=report_scrap_qty - scrap_inbound_qty) + processed_qty = finished_inbound_qty + scrap_inbound_qty + return ( + select( + ReturnDisposition.id.label("return_disposition_id"), + ReturnDisposition.disposition_no.label("disposition_no"), + ReturnDisposition.return_item_id.label("return_item_id"), + ReturnOrder.return_no.label("return_no"), + ReturnItem.product_item_id.label("product_item_id"), + Item.item_code.label("product_code"), + Item.item_name.label("product_name"), + ReturnItem.return_qty.label("return_qty"), + ReturnItem.return_weight_kg.label("return_weight_kg"), + ReturnDisposition.disposition_type.label("disposition_type"), + ReturnDisposition.rework_work_order_id.label("rework_work_order_id"), + rework_work_order.work_order_no.label("rework_work_order_no"), + report_good_qty.label("rework_report_good_qty"), + report_scrap_qty.label("rework_report_scrap_qty"), + finished_inbound_qty.label("rework_finished_inbound_qty"), + scrap_inbound_qty.label("rework_scrap_inbound_qty"), + remaining_finished_qty.label("remaining_rework_finished_inbound_qty"), + remaining_scrap_qty.label("remaining_rework_scrap_inbound_qty"), + case((processed_qty >= ReturnItem.return_qty, True), else_=False).label("rework_close_ready"), + case( + (func.upper(func.coalesce(rework_work_order.status, "")) == "SETTLED", "已结单"), + (processed_qty >= ReturnItem.return_qty, "可结单"), + (processed_qty > 0, "部分处理"), + else_="待处理", + ).label("rework_close_status"), + ReturnDisposition.scrap_weight_kg.label("scrap_weight_kg"), + ReturnDisposition.extra_cost_amount.label("extra_cost_amount"), + ReturnDisposition.scrap_sale_amount.label("scrap_sale_amount"), + ReturnDisposition.closed_at.label("closed_at"), + ReturnDisposition.status.label("status"), + ReturnDisposition.remark.label("remark"), + ) + .join(ReturnItem, ReturnItem.id == ReturnDisposition.return_item_id) + .join(ReturnOrder, ReturnOrder.id == ReturnItem.return_order_id) + .join(Item, Item.id == ReturnItem.product_item_id) + .outerjoin(rework_work_order, rework_work_order.id == ReturnDisposition.rework_work_order_id) + .outerjoin(report_subquery, report_subquery.c.work_order_id == ReturnDisposition.rework_work_order_id) + .outerjoin(finished_inbound_subquery, finished_inbound_subquery.c.work_order_id == ReturnDisposition.rework_work_order_id) + .outerjoin(scrap_inbound_subquery, scrap_inbound_subquery.c.work_order_id == ReturnDisposition.rework_work_order_id) + .order_by(ReturnDisposition.id.desc()) + .limit(limit) + ) diff --git a/backend/app/services/production_batch_ledger.py b/backend/app/services/production_batch_ledger.py new file mode 100644 index 0000000..058b9fc --- /dev/null +++ b/backend/app/services/production_batch_ledger.py @@ -0,0 +1,406 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal, ROUND_FLOOR + +from fastapi import HTTPException +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.master_data import Bom, BomItem, Item +from app.models.operations import ProductionBatchLedger, ProductionBatchLedgerTxn, StockLot +from app.services.operations import to_decimal + + +INBOUND_TOLERANCE_MULTIPLIER = Decimal("1.15") + + +@dataclass +class ProductionBatchLedgerSnapshot: + production_ledger_id: int + material_lot_no: str + product_item_id: int + material_item_id: int + gross_weight_per_piece_kg: Decimal + net_weight_per_piece_kg: Decimal + total_issued_weight_kg: Decimal + finished_inbound_qty: Decimal + surplus_inbound_weight_kg: Decimal + scrap_inbound_weight_kg: Decimal + writeoff_weight_kg: Decimal + outside_weight_kg: Decimal + reference_finished_qty: Decimal + used_material_weight_kg: Decimal + theoretical_scrap_weight_kg: Decimal + theoretical_surplus_weight_kg: Decimal + + +def _round_weight(value: Decimal) -> Decimal: + return to_decimal(value, "0.001") + + +def _non_negative(value: Decimal) -> Decimal: + return value if value > 0 else Decimal("0") + + +def _append_txn( + db: Session, + *, + ledger: ProductionBatchLedger, + txn_type: str, + qty_delta: Decimal = Decimal("0"), + weight_delta_kg: Decimal = Decimal("0"), + source_doc_type: str | None = None, + source_doc_id: int | None = None, + source_line_id: int | None = None, + biz_time: datetime | None = None, + operator_user_id: int | None = None, + remark: str | None = None, +) -> ProductionBatchLedgerTxn: + txn = ProductionBatchLedgerTxn( + production_ledger_id=ledger.id, + txn_type=txn_type, + qty_delta=qty_delta, + weight_delta_kg=weight_delta_kg, + outside_weight_after_kg=to_decimal(ledger.outside_weight_kg), + source_doc_type=source_doc_type, + source_doc_id=source_doc_id, + source_line_id=source_line_id, + biz_time=biz_time or datetime.now(), + operator_user_id=operator_user_id, + remark=remark, + ) + db.add(txn) + db.flush() + return txn + + +def _get_bom_weight_profile(db: Session, product_item_id: int, material_item_id: int) -> tuple[Decimal, Decimal]: + row = db.execute( + select( + BomItem.gross_weight_per_piece_kg.label("gross_weight_per_piece_kg"), + BomItem.net_weight_per_piece_kg.label("net_weight_per_piece_kg"), + ) + .join(Bom, Bom.id == BomItem.bom_id) + .where( + Bom.product_item_id == product_item_id, + BomItem.material_item_id == material_item_id, + Bom.status == "ACTIVE", + ) + .order_by(Bom.is_default.desc(), BomItem.seq_no, BomItem.id) + .limit(1) + ).mappings().first() + gross = to_decimal(row["gross_weight_per_piece_kg"] if row else 0) + net = to_decimal(row["net_weight_per_piece_kg"] if row else 0) + if gross <= 0: + product = db.get(Item, product_item_id) + gross = to_decimal(product.unit_weight_kg if product else 0) + if net <= 0 or net > gross: + net = gross + return gross, net + + +def calculate_production_batch_ledger_snapshot( + db: Session, + production_ledger_id: int, + *, + smart_reporting_enabled: bool, + selected_finished_qty: Decimal | None = None, +) -> ProductionBatchLedgerSnapshot: + _ = smart_reporting_enabled + ledger = db.get(ProductionBatchLedger, production_ledger_id) + if not ledger: + raise HTTPException(status_code=404, detail="生产台账不存在") + gross, net = _get_bom_weight_profile(db, ledger.product_item_id, ledger.material_item_id) + total_issued = to_decimal(ledger.total_issued_weight_kg) + finished_qty = to_decimal(ledger.finished_inbound_qty) + basis_qty = finished_qty + to_decimal(selected_finished_qty) + reference_finished_qty = Decimal("0") + if gross > 0: + reference_finished_qty = (total_issued / gross).to_integral_value(rounding=ROUND_FLOOR) + used_weight = _round_weight(basis_qty * gross) + theoretical_scrap = _round_weight(_non_negative(basis_qty * _non_negative(gross - net) - to_decimal(ledger.scrap_inbound_weight_kg))) + theoretical_surplus = _round_weight(_non_negative(total_issued - used_weight - to_decimal(ledger.surplus_inbound_weight_kg))) + return ProductionBatchLedgerSnapshot( + production_ledger_id=ledger.id, + material_lot_no=ledger.material_lot_no, + product_item_id=ledger.product_item_id, + material_item_id=ledger.material_item_id, + gross_weight_per_piece_kg=gross, + net_weight_per_piece_kg=net, + total_issued_weight_kg=total_issued, + finished_inbound_qty=finished_qty, + surplus_inbound_weight_kg=to_decimal(ledger.surplus_inbound_weight_kg), + scrap_inbound_weight_kg=to_decimal(ledger.scrap_inbound_weight_kg), + writeoff_weight_kg=to_decimal(ledger.writeoff_weight_kg), + outside_weight_kg=to_decimal(ledger.outside_weight_kg), + reference_finished_qty=reference_finished_qty, + used_material_weight_kg=used_weight, + theoretical_scrap_weight_kg=theoretical_scrap, + theoretical_surplus_weight_kg=theoretical_surplus, + ) + + +def post_finished_inbound_to_ledger( + db: Session, + *, + production_ledger_id: int, + finished_qty: Decimal, + operator_user_id: int | None, + source_doc_type: str | None, + source_doc_id: int | None, + source_line_id: int | None, + biz_time: datetime | None = None, + remark: str | None = None, +) -> ProductionBatchLedger: + qty = to_decimal(finished_qty) + if qty < 0: + raise HTTPException(status_code=400, detail="成品入库数量不能小于0") + ledger = db.get(ProductionBatchLedger, production_ledger_id) + if not ledger: + raise HTTPException(status_code=404, detail="生产台账不存在") + gross, _ = _get_bom_weight_profile(db, ledger.product_item_id, ledger.material_item_id) + used_weight = _round_weight(qty * gross) + ledger.finished_inbound_qty = to_decimal(ledger.finished_inbound_qty) + qty + ledger.outside_weight_kg = _round_weight(_non_negative(to_decimal(ledger.outside_weight_kg) - used_weight)) + db.add(ledger) + db.flush() + _append_txn( + db, + ledger=ledger, + txn_type="成品入库", + qty_delta=qty, + weight_delta_kg=-used_weight, + source_doc_type=source_doc_type, + source_doc_id=source_doc_id, + source_line_id=source_line_id, + biz_time=biz_time, + operator_user_id=operator_user_id, + remark=remark or "成品入库联动生产台账", + ) + return ledger + + +def post_surplus_inbound_to_ledger( + db: Session, + *, + production_ledger_id: int, + surplus_weight_kg: Decimal, + operator_user_id: int | None, + source_doc_type: str | None, + source_doc_id: int | None, + source_line_id: int | None, + biz_time: datetime | None = None, + remark: str | None = None, +) -> ProductionBatchLedger: + weight = _round_weight(to_decimal(surplus_weight_kg)) + if weight < 0: + raise HTTPException(status_code=400, detail="余料入库重量不能小于0") + ledger = db.get(ProductionBatchLedger, production_ledger_id) + if not ledger: + raise HTTPException(status_code=404, detail="生产台账不存在") + ledger.surplus_inbound_weight_kg = _round_weight(to_decimal(ledger.surplus_inbound_weight_kg) + weight) + ledger.outside_weight_kg = _round_weight(_non_negative(to_decimal(ledger.outside_weight_kg) - weight)) + db.add(ledger) + db.flush() + _append_txn( + db, + ledger=ledger, + txn_type="生产余料入库", + weight_delta_kg=-weight, + source_doc_type=source_doc_type, + source_doc_id=source_doc_id, + source_line_id=source_line_id, + biz_time=biz_time, + operator_user_id=operator_user_id, + remark=remark or "生产余料入库联动生产台账", + ) + return ledger + + +def post_scrap_inbound_to_ledger( + db: Session, + *, + production_ledger_id: int, + scrap_weight_kg: Decimal, + operator_user_id: int | None, + source_doc_type: str | None, + source_doc_id: int | None, + source_line_id: int | None, + biz_time: datetime | None = None, + remark: str | None = None, +) -> ProductionBatchLedger: + weight = _round_weight(to_decimal(scrap_weight_kg)) + if weight < 0: + raise HTTPException(status_code=400, detail="废料入库重量不能小于0") + ledger = db.get(ProductionBatchLedger, production_ledger_id) + if not ledger: + raise HTTPException(status_code=404, detail="生产台账不存在") + ledger.scrap_inbound_weight_kg = _round_weight(to_decimal(ledger.scrap_inbound_weight_kg) + weight) + ledger.outside_weight_kg = _round_weight(_non_negative(to_decimal(ledger.outside_weight_kg) - weight)) + db.add(ledger) + db.flush() + _append_txn( + db, + ledger=ledger, + txn_type="生产废料入库", + weight_delta_kg=-weight, + source_doc_type=source_doc_type, + source_doc_id=source_doc_id, + source_line_id=source_line_id, + biz_time=biz_time, + operator_user_id=operator_user_id, + remark=remark or "生产废料入库联动生产台账", + ) + return ledger + + +def _get_production_ledger( + db: Session, + *, + material_lot_id: int, + product_item_id: int, +) -> ProductionBatchLedger | None: + return db.scalar( + select(ProductionBatchLedger) + .where( + ProductionBatchLedger.material_lot_id == material_lot_id, + ProductionBatchLedger.product_item_id == product_item_id, + ) + .with_for_update() + ) + + +def record_production_issue_to_ledger( + db: Session, + *, + material_lot_id: int, + product_item_id: int, + issued_weight_kg: Decimal, + operator_user_id: int | None, + source_doc_type: str | None, + source_doc_id: int | None, + source_line_id: int | None, + biz_time: datetime | None = None, + remark: str | None = None, +) -> ProductionBatchLedger: + issued_weight = _round_weight(to_decimal(issued_weight_kg)) + if issued_weight <= 0: + raise HTTPException(status_code=400, detail="生产出库重量必须大于0") + lot = db.get(StockLot, material_lot_id) + if not lot: + raise HTTPException(status_code=404, detail="材料库存批次号不存在") + product = db.get(Item, product_item_id) + if not product: + raise HTTPException(status_code=404, detail="产品不存在") + + now = biz_time or datetime.now() + ledger = _get_production_ledger(db, material_lot_id=material_lot_id, product_item_id=product_item_id) + if ledger and str(ledger.status or "") == "锁单": + ledger.status = "在生产" + ledger.miniapp_selectable = 1 + ledger.reopened_at = now + db.add(ledger) + db.flush() + _append_txn( + db, + ledger=ledger, + txn_type="重新开工", + source_doc_type=source_doc_type, + source_doc_id=source_doc_id, + source_line_id=source_line_id, + biz_time=now, + operator_user_id=operator_user_id, + remark="锁单后再次生产出库,生产台账重新进入在生产状态", + ) + + if not ledger: + ledger = ProductionBatchLedger( + material_lot_id=material_lot_id, + material_lot_no=lot.lot_no, + material_item_id=lot.item_id, + product_item_id=product_item_id, + status="在生产", + miniapp_selectable=1, + total_issued_weight_kg=Decimal("0"), + outside_weight_kg=Decimal("0"), + finished_inbound_qty=Decimal("0"), + surplus_inbound_weight_kg=Decimal("0"), + scrap_inbound_weight_kg=Decimal("0"), + writeoff_weight_kg=Decimal("0"), + first_issue_time=now, + last_issue_time=now, + remark=remark, + ) + db.add(ledger) + db.flush() + + ledger.status = "在生产" + ledger.miniapp_selectable = 1 + ledger.total_issued_weight_kg = _round_weight(to_decimal(ledger.total_issued_weight_kg) + issued_weight) + ledger.outside_weight_kg = _round_weight(to_decimal(ledger.outside_weight_kg) + issued_weight) + ledger.last_issue_time = now + if not ledger.first_issue_time: + ledger.first_issue_time = now + db.add(ledger) + db.flush() + _append_txn( + db, + ledger=ledger, + txn_type="生产出库", + weight_delta_kg=issued_weight, + source_doc_type=source_doc_type, + source_doc_id=source_doc_id, + source_line_id=source_line_id, + biz_time=now, + operator_user_id=operator_user_id, + remark=remark or "生产出库累计到生产台账", + ) + return ledger + + +def lock_production_batch_ledger( + db: Session, + *, + production_ledger_id: int, + operator_user_id: int | None, + biz_time: datetime | None = None, + remark: str | None = None, +) -> ProductionBatchLedger: + ledger = db.get(ProductionBatchLedger, production_ledger_id) + if not ledger: + raise HTTPException(status_code=404, detail="生产台账不存在") + + now = biz_time or datetime.now() + remaining = _round_weight(to_decimal(ledger.outside_weight_kg)) + if remaining > 0: + ledger.writeoff_weight_kg = _round_weight(to_decimal(ledger.writeoff_weight_kg) + remaining) + ledger.outside_weight_kg = Decimal("0") + db.add(ledger) + db.flush() + _append_txn( + db, + ledger=ledger, + txn_type="结单核销", + weight_delta_kg=-remaining, + biz_time=now, + operator_user_id=operator_user_id, + remark=remark or "该批材料结单时确认库外剩余重量已无现场存量", + ) + + ledger.status = "锁单" + ledger.miniapp_selectable = 0 + ledger.locked_at = now + ledger.lock_count = int(ledger.lock_count or 0) + 1 + db.add(ledger) + db.flush() + _append_txn( + db, + ledger=ledger, + txn_type="该批材料结单", + biz_time=now, + operator_user_id=operator_user_id, + remark=remark or "该批材料已结单,小程序不再显示该材料库存批次号", + ) + return ledger diff --git a/backend/app/services/production_work_order_inbound.py b/backend/app/services/production_work_order_inbound.py new file mode 100644 index 0000000..13cfa4c --- /dev/null +++ b/backend/app/services/production_work_order_inbound.py @@ -0,0 +1,553 @@ +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal, ROUND_FLOOR +from uuid import uuid4 + +from fastapi import HTTPException +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models.master_data import BomItem, Item, Warehouse +from app.models.operations import ( + CompletionReceipt, + CompletionReceiptItem, + InventoryTxn, + StockLot, + WorkOrder, + WorkOrderMaterial, + WorkOrderMaterialIssue, +) +from app.schemas.operations import ( + ProductionWorkOrderInboundCreate, + ProductionWorkOrderInboundPreviewRead, + ProductionWorkOrderInboundRead, +) +from app.services.document_archives import DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, generate_document_archive +from app.services.operations import create_inventory_txn, to_decimal, upsert_stock_balance +from app.services.production_work_order_ledger import get_work_order_limit_snapshot +from app.services.sales_planning import code_token, next_doc_no +from app.services.stocktake import ensure_warehouses_unlocked + + +DEVIATION_RATE = Decimal("0.15") +FINISHED_QTY_UPPER_MULTIPLIER = Decimal("1.15") + + +def _production_document_batch_no(now: datetime) -> str: + return f"生产结算{now.strftime('%Y%m%d%H%M%S')}{uuid4().int % 1_000_000:06d}" + + +def _with_document_batch_no(remark: str, batch_no: str | None) -> str: + if not batch_no: + return remark + return f"{remark};单据批次号:{batch_no}" + + +def _round_decimal(value: Decimal, places: str = "0.001") -> Decimal: + return to_decimal(value, places) + + +def _non_negative(value: Decimal) -> Decimal: + return max(to_decimal(value), Decimal("0")) + + +def _deviation_exceeds_limit(actual: Decimal, theoretical: Decimal) -> bool: + actual = to_decimal(actual) + theoretical = to_decimal(theoretical) + if theoretical == 0: + return actual != 0 + return abs(actual - theoretical) / abs(theoretical) > DEVIATION_RATE + + +def _floor_qty(value: Decimal) -> Decimal: + return to_decimal(value).to_integral_value(rounding=ROUND_FLOOR) + + +def _reference_finished_qty(issued_weight: Decimal, gross_weight: Decimal) -> Decimal: + issued_weight = to_decimal(issued_weight) + gross_weight = to_decimal(gross_weight) + if issued_weight <= 0 or gross_weight <= 0: + return Decimal("0") + return _floor_qty(issued_weight / gross_weight) + + +def _finished_qty_exceeds_reference_limit(total_finished_qty: Decimal, reference_finished_qty: Decimal) -> bool: + total_finished_qty = to_decimal(total_finished_qty) + reference_finished_qty = to_decimal(reference_finished_qty) + if reference_finished_qty <= 0: + return total_finished_qty > 0 + return total_finished_qty > reference_finished_qty * FINISHED_QTY_UPPER_MULTIPLIER + + +def _remark_with_deviation(base: str, deviation_remark: str | None) -> str: + deviation = str(deviation_remark or "").strip() + if not deviation: + return base + return f"{base};偏差说明:{deviation}" + + +def _source_issue(db: Session, work_order_id: int) -> WorkOrderMaterialIssue: + issues = db.scalars( + select(WorkOrderMaterialIssue) + .where(WorkOrderMaterialIssue.work_order_id == work_order_id) + .order_by(WorkOrderMaterialIssue.id) + ).all() + if not issues: + raise HTTPException(status_code=400, detail="该工单没有材料库存批次领用记录,不能进行生产工单入库") + source_lot_ids = {int(issue.source_lot_id) for issue in issues if issue.source_lot_id is not None} + if not source_lot_ids: + raise HTTPException(status_code=400, detail="该工单没有材料来源库存批次,不能进行生产工单入库") + if len(source_lot_ids) > 1: + raise HTTPException(status_code=400, detail="该工单存在多个材料来源库存批次,不能进行生产工单入库") + source_lot_id = next(iter(source_lot_ids)) + return next(issue for issue in issues if issue.source_lot_id is not None and int(issue.source_lot_id) == source_lot_id) + + +def _get_work_order(db: Session, work_order_id: int) -> WorkOrder: + work_order = db.get(WorkOrder, work_order_id) + if not work_order: + raise HTTPException(status_code=404, detail="生产工单不存在") + if str(work_order.status or "").upper() == "SETTLED": + raise HTTPException(status_code=400, detail="该生产工单已结单,不能进行生产工单入库") + return work_order + + +def _get_warehouse(db: Session, warehouse_type: str) -> Warehouse: + warehouse = db.scalar( + select(Warehouse) + .where( + Warehouse.warehouse_type == warehouse_type, + Warehouse.status == "ACTIVE", + ) + .order_by(Warehouse.id) + .limit(1) + ) + if not warehouse: + warehouse_type_name = {"FINISHED": "成品", "SCRAP": "废料"}.get(warehouse_type, "目标") + raise HTTPException(status_code=400, detail=f"缺少启用的{warehouse_type_name}仓库,不能进行生产工单入库") + return warehouse + + +def _get_bom_item(db: Session, work_order: WorkOrder, material_item_id: int) -> BomItem: + bom_item = db.scalar( + select(BomItem) + .where( + BomItem.bom_id == work_order.bom_id, + BomItem.material_item_id == material_item_id, + ) + .order_by(BomItem.seq_no, BomItem.id) + .limit(1) + ) + if not bom_item: + raise HTTPException(status_code=400, detail="工单需用清单缺少领用材料,不能进行生产工单入库") + return bom_item + + +def _build_receipt_no(db: Session, work_order: WorkOrder, now: datetime) -> str: + work_order_part = code_token(work_order.work_order_no, f"WO{work_order.id:04d}", 18) + return next_doc_no(db, CompletionReceipt, "receipt_no", f"WOI-NB-{now.strftime('%Y%m%d')}-{work_order_part}") + + +def _build_lot_no(db: Session, prefix: str, work_order: WorkOrder, item: Item, now: datetime) -> str: + item_part = code_token(item.item_code or item.item_name, f"ITEM{item.id:04d}", 12) + work_order_part = code_token(work_order.work_order_no, f"WO{work_order.id:04d}", 10) + return next_doc_no(db, StockLot, "lot_no", f"{prefix}-NB-{now.strftime('%Y%m%d')}-{item_part}-{work_order_part}") + + +def _append_settle_remark(work_order: WorkOrder, deviation_remark: str | None, remark: str | None) -> None: + parts = [part.strip() for part in str(work_order.remark or "").replace(";", ";").split(";") if part.strip()] + parts.append("生产工单入库已结单") + if str(deviation_remark or "").strip(): + parts.append(f"偏差说明:{str(deviation_remark).strip()}") + if str(remark or "").strip(): + parts.append(f"入库说明:{str(remark).strip()}") + work_order.remark = ";".join(dict.fromkeys(parts))[-255:] + + +def preview_production_work_order_inbound( + db: Session, + *, + work_order_id: int, + finished_qty: float | Decimal | None = None, +) -> ProductionWorkOrderInboundPreviewRead: + work_order = _get_work_order(db, work_order_id) + issue = _source_issue(db, work_order_id) + source_lot = db.get(StockLot, issue.source_lot_id) + product = db.get(Item, work_order.product_item_id) + material = db.get(Item, issue.material_item_id) + if not source_lot: + raise HTTPException(status_code=400, detail="来源库存批次不存在,不能进行生产工单入库") + if not product: + raise HTTPException(status_code=400, detail="工单产品不存在,不能进行生产工单入库") + if not material: + raise HTTPException(status_code=400, detail="领用材料不存在,不能进行生产工单入库") + + snapshot = get_work_order_limit_snapshot(db, work_order_id) + bom_item = _get_bom_item(db, work_order, issue.material_item_id) + gross = to_decimal(bom_item.gross_weight_per_piece_kg or snapshot.gross_weight_per_piece_kg) + net = to_decimal(bom_item.net_weight_per_piece_kg or snapshot.net_weight_per_piece_kg) + if gross <= 0: + gross = to_decimal(snapshot.gross_weight_per_piece_kg) + if net <= 0: + net = to_decimal(snapshot.net_weight_per_piece_kg) + if gross <= 0: + raise HTTPException(status_code=400, detail="产品需用清单缺少单件毛重,不能进行生产工单入库") + if gross < net: + net = gross + + reference_finished_qty = _reference_finished_qty(to_decimal(snapshot.issued_weight_kg), gross) + finished_received_qty = to_decimal(snapshot.finished_received_qty) + remaining_reference_finished_qty = _non_negative(reference_finished_qty - finished_received_qty) + selected_finished_qty = to_decimal(finished_qty) if finished_qty is not None else remaining_reference_finished_qty + if selected_finished_qty < 0: + raise HTTPException(status_code=400, detail="成品入库数量不能小于0") + total_finished_qty = finished_received_qty + selected_finished_qty + theoretical_surplus = _non_negative( + to_decimal(snapshot.issued_weight_kg) - to_decimal(snapshot.returned_material_weight_kg) - total_finished_qty * gross + ) + theoretical_scrap = _non_negative(to_decimal(snapshot.max_scrap_inbound_weight_kg)) + + return ProductionWorkOrderInboundPreviewRead( + work_order_id=work_order.id, + work_order_no=work_order.work_order_no, + product_item_id=product.id, + product_code=product.item_code, + product_name=product.item_name, + source_lot_id=source_lot.id, + source_lot_no=source_lot.lot_no, + material_item_id=material.id, + material_code=material.item_code, + material_name=material.item_name, + gross_weight_per_piece_kg=float(_round_decimal(gross)), + net_weight_per_piece_kg=float(_round_decimal(net)), + issued_weight_kg=float(_round_decimal(to_decimal(snapshot.issued_weight_kg))), + returned_weight_kg=float(_round_decimal(to_decimal(snapshot.returned_material_weight_kg))), + final_operation_good_qty=float(_round_decimal(to_decimal(snapshot.final_operation_good_qty))), + finished_inbound_qty=float(_round_decimal(to_decimal(snapshot.finished_received_qty))), + reported_scrap_qty=float(_round_decimal(to_decimal(snapshot.total_operation_scrap_qty))), + scrap_inbound_weight_kg=float(_round_decimal(to_decimal(snapshot.scrap_inbound_weight_kg))), + reference_finished_qty=float(_round_decimal(reference_finished_qty)), + remaining_reference_finished_qty=float(_round_decimal(remaining_reference_finished_qty)), + finished_qty_upper_limit=float(_round_decimal(reference_finished_qty * FINISHED_QTY_UPPER_MULTIPLIER)), + finished_qty=float(_round_decimal(selected_finished_qty)), + theoretical_surplus_weight_kg=float(_round_decimal(theoretical_surplus)), + theoretical_scrap_weight_kg=float(_round_decimal(theoretical_scrap)), + ) + + +def create_production_work_order_inbound( + db: Session, + *, + payload: ProductionWorkOrderInboundCreate, + operator_user_id: int | None, +) -> ProductionWorkOrderInboundRead: + work_order = _get_work_order(db, payload.work_order_id) + issue = _source_issue(db, payload.work_order_id) + source_lot = db.get(StockLot, issue.source_lot_id) + product = db.get(Item, work_order.product_item_id) + material = db.get(Item, issue.material_item_id) + if not source_lot or not product or not material: + raise HTTPException(status_code=400, detail="工单入库来源数据不完整") + + finished_qty = to_decimal(payload.finished_qty) + surplus_weight = to_decimal(payload.surplus_weight_kg) + scrap_weight = to_decimal(payload.scrap_weight_kg) + if min(finished_qty, surplus_weight, scrap_weight) < 0: + raise HTTPException(status_code=400, detail="入库数量或重量不能小于0") + if finished_qty <= 0 and surplus_weight <= 0 and scrap_weight <= 0: + raise HTTPException(status_code=400, detail="生产工单入库至少需要填写成品、余料或废料中的一项") + + preview = preview_production_work_order_inbound(db, work_order_id=payload.work_order_id, finished_qty=finished_qty) + theoretical_surplus = to_decimal(preview.theoretical_surplus_weight_kg) + theoretical_scrap = to_decimal(preview.theoretical_scrap_weight_kg) + total_finished_qty = to_decimal(preview.finished_inbound_qty) + finished_qty + reference_finished_qty = to_decimal(preview.reference_finished_qty) + if _finished_qty_exceeds_reference_limit(total_finished_qty, reference_finished_qty) and not str(payload.deviation_remark or "").strip(): + raise HTTPException(status_code=400, detail="成品入库数量超过本工单参考生产数量上限15%,请填写偏差说明") + if ( + _deviation_exceeds_limit(surplus_weight, theoretical_surplus) + or _deviation_exceeds_limit(scrap_weight, theoretical_scrap) + ) and not str(payload.deviation_remark or "").strip(): + raise HTTPException(status_code=400, detail="余料或废料入库重量与理论值偏差超过上下15%,请填写偏差说明") + if payload.settle_work_order and not payload.settle_confirmed: + raise HTTPException(status_code=400, detail="结单前必须确认本次入库明细及结单影响") + + now = datetime.now() + bom_item = _get_bom_item(db, work_order, issue.material_item_id) + gross = to_decimal(bom_item.gross_weight_per_piece_kg or preview.gross_weight_per_piece_kg) + product_weight = to_decimal(product.unit_weight_kg or bom_item.net_weight_per_piece_kg or preview.net_weight_per_piece_kg) + finished_warehouse = _get_warehouse(db, "FINISHED") if finished_qty > 0 else None + scrap_warehouse = _get_warehouse(db, "SCRAP") if scrap_weight > 0 else None + material_row = db.get(WorkOrderMaterial, issue.work_order_material_id) + if surplus_weight > 0 and not material_row: + raise HTTPException(status_code=400, detail="工单领料明细不存在,不能进行余料入库") + locked_check_warehouse_ids: set[int] = set() + if finished_qty > 0 and finished_warehouse: + locked_check_warehouse_ids.add(int(finished_warehouse.id)) + if surplus_weight > 0: + locked_check_warehouse_ids.add(int(source_lot.warehouse_id)) + if scrap_weight > 0 and scrap_warehouse: + locked_check_warehouse_ids.add(int(scrap_warehouse.id)) + ensure_warehouses_unlocked(db, locked_check_warehouse_ids, "生产工单入库") + + finished_lot_id = surplus_lot_id = scrap_lot_id = None + finished_txn_id = surplus_txn_id = scrap_txn_id = None + document_batch_no = _production_document_batch_no(now) + + if finished_qty > 0 and finished_warehouse: + receipt = CompletionReceipt( + receipt_no=_build_receipt_no(db, work_order, now), + work_order_id=work_order.id, + warehouse_id=finished_warehouse.id, + receipt_time=now, + receiver_employee_id=None, + status="POSTED", + remark=_remark_with_deviation(payload.remark or "生产工单入库", payload.deviation_remark), + ) + db.add(receipt) + db.flush() + lot_no = _build_lot_no(db, "FGI", work_order, product, now) + receipt_weight = finished_qty * product_weight + unit_cost = to_decimal(to_decimal(source_lot.unit_cost) * gross, "0.0001") + material_summary = f"{material.item_name}:{source_lot.lot_no}" + receipt_item = CompletionReceiptItem( + completion_receipt_id=receipt.id, + line_no=1, + product_item_id=product.id, + location_id=None, + lot_no=lot_no, + source_material_lot_id=source_lot.id, + source_material_sub_batch_no=issue.material_sub_batch_no, + source_material_summary=material_summary, + receipt_qty=finished_qty, + receipt_weight_kg=receipt_weight, + unit_cost=unit_cost, + status="POSTED", + remark=_remark_with_deviation(payload.remark or "生产工单入库", payload.deviation_remark), + ) + db.add(receipt_item) + db.flush() + finished_lot = StockLot( + lot_no=lot_no, + parent_lot_id=source_lot.id, + lot_role="FINISHED_FROM_RAW_BATCH", + material_sub_batch_no=None, + item_id=product.id, + warehouse_id=finished_warehouse.id, + location_id=None, + source_doc_type="FG_RECEIPT", + source_doc_id=receipt.id, + source_line_id=receipt_item.id, + source_material_lot_id=source_lot.id, + source_material_sub_batch_no=issue.material_sub_batch_no, + source_material_summary=material_summary, + inbound_qty=finished_qty, + inbound_weight_kg=receipt_weight, + remaining_qty=finished_qty, + remaining_weight_kg=receipt_weight, + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=unit_cost, + production_date=now.date(), + expire_date=None, + quality_status="PASS", + status="AVAILABLE", + remark=_with_document_batch_no( + _remark_with_deviation("生产工单入库;来源库存批次已记录", payload.deviation_remark), + document_batch_no, + ), + ) + db.add(finished_lot) + db.flush() + upsert_stock_balance( + db, + item_id=product.id, + warehouse_id=finished_warehouse.id, + location_id=None, + qty_delta=finished_qty, + weight_delta=receipt_weight, + available_qty_delta=finished_qty, + available_weight_delta=receipt_weight, + unit_cost=unit_cost, + ) + txn = create_inventory_txn( + db, + txn_type="FG_IN", + item_id=product.id, + warehouse_id=finished_warehouse.id, + location_id=None, + lot_id=finished_lot.id, + qty_change=finished_qty, + weight_change=receipt_weight, + unit_cost=unit_cost, + source_doc_type="FG_RECEIPT", + source_doc_id=receipt.id, + source_line_id=receipt_item.id, + biz_time=now, + operator_user_id=operator_user_id, + remark=_with_document_batch_no(_remark_with_deviation("生产工单入库", payload.deviation_remark), document_batch_no), + amount_basis="QTY", + ) + finished_lot_id = finished_lot.id + finished_txn_id = txn.id + work_order.finished_qty = to_decimal(work_order.finished_qty) + finished_qty + + if surplus_weight > 0: + source_lot.remaining_weight_kg = to_decimal(source_lot.remaining_weight_kg) + surplus_weight + source_lot.status = "AVAILABLE" + db.add(source_lot) + upsert_stock_balance( + db, + item_id=material.id, + warehouse_id=source_lot.warehouse_id, + location_id=source_lot.location_id, + qty_delta=Decimal("0"), + weight_delta=surplus_weight, + available_qty_delta=Decimal("0"), + available_weight_delta=surplus_weight, + unit_cost=to_decimal(source_lot.unit_cost), + ) + txn = create_inventory_txn( + db, + txn_type="PRODUCTION_SURPLUS_IN", + item_id=material.id, + warehouse_id=source_lot.warehouse_id, + location_id=source_lot.location_id, + lot_id=source_lot.id, + qty_change=Decimal("0"), + weight_change=surplus_weight, + unit_cost=to_decimal(source_lot.unit_cost), + source_doc_type="WORK_ORDER", + source_doc_id=work_order.id, + source_line_id=material_row.id if material_row else None, + biz_time=now, + operator_user_id=operator_user_id, + remark=_with_document_batch_no(_remark_with_deviation("生产余料入库", payload.deviation_remark), document_batch_no), + amount_basis="WEIGHT", + ) + if material_row: + material_row.returned_weight_kg = to_decimal(material_row.returned_weight_kg) + surplus_weight + db.add(material_row) + surplus_lot_id = source_lot.id + surplus_txn_id = txn.id + + if scrap_weight > 0 and scrap_warehouse: + scrap_lot_no = _build_lot_no(db, "SCRAPI", work_order, material, now) + scrap_lot = StockLot( + lot_no=scrap_lot_no, + parent_lot_id=source_lot.id, + lot_role="PRODUCTION_SCRAP", + material_sub_batch_no=None, + item_id=material.id, + warehouse_id=scrap_warehouse.id, + location_id=None, + source_doc_type="PRODUCTION_SCRAP_IN", + source_doc_id=work_order.id, + source_line_id=None, + source_material_lot_id=source_lot.id, + source_material_sub_batch_no=issue.material_sub_batch_no, + source_material_summary=f"{material.item_name}:{source_lot.lot_no}", + inbound_qty=Decimal("0"), + inbound_weight_kg=scrap_weight, + remaining_qty=Decimal("0"), + remaining_weight_kg=scrap_weight, + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=to_decimal(source_lot.unit_cost), + production_date=now.date(), + expire_date=None, + quality_status="SCRAP", + status="AVAILABLE", + remark=_with_document_batch_no( + _remark_with_deviation("生产废料入库;来源库存批次已记录", payload.deviation_remark), + document_batch_no, + ), + ) + db.add(scrap_lot) + db.flush() + upsert_stock_balance( + db, + item_id=material.id, + warehouse_id=scrap_warehouse.id, + location_id=None, + qty_delta=Decimal("0"), + weight_delta=scrap_weight, + available_qty_delta=Decimal("0"), + available_weight_delta=scrap_weight, + unit_cost=to_decimal(source_lot.unit_cost), + ) + txn = create_inventory_txn( + db, + txn_type="PRODUCTION_SCRAP_IN", + item_id=material.id, + warehouse_id=scrap_warehouse.id, + location_id=None, + lot_id=scrap_lot.id, + qty_change=Decimal("0"), + weight_change=scrap_weight, + unit_cost=to_decimal(source_lot.unit_cost), + source_doc_type="WORK_ORDER", + source_doc_id=work_order.id, + source_line_id=None, + biz_time=now, + operator_user_id=operator_user_id, + remark=_with_document_batch_no(_remark_with_deviation("生产废料入库", payload.deviation_remark), document_batch_no), + amount_basis="WEIGHT", + ) + scrap_lot_id = scrap_lot.id + scrap_txn_id = txn.id + + settled = False + if payload.settle_work_order: + work_order.status = "SETTLED" + work_order.actual_end_time = work_order.actual_end_time or now + _append_settle_remark(work_order, payload.deviation_remark, payload.remark) + settled = True + response_work_order_id = int(work_order.id) + response_work_order_no = str(work_order.work_order_no) + response_status = str(work_order.status) + response_remark = work_order.remark + main_txn_id = finished_txn_id or surplus_txn_id or scrap_txn_id + db.add(work_order) + db.commit() + + archive_status = "未生成" + archive_business_id = int(main_txn_id) if main_txn_id else None + archive_document_type = DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT if main_txn_id else None + archive_error_message = None + if main_txn_id: + try: + archive_result = generate_document_archive( + db, + DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + int(main_txn_id), + created_by=operator_user_id, + ) + archive_status = archive_result.archive_status + archive_error_message = archive_result.archive_error_message + except Exception as exc: + _ = exc + archive_status = "归档失败" + archive_error_message = "归档失败:PDF归档服务异常,请稍后重新生成" + + return ProductionWorkOrderInboundRead( + work_order_id=response_work_order_id, + work_order_no=response_work_order_no, + finished_qty=float(_round_decimal(finished_qty)), + surplus_weight_kg=float(_round_decimal(surplus_weight)), + scrap_weight_kg=float(_round_decimal(scrap_weight)), + finished_lot_id=finished_lot_id, + surplus_lot_id=surplus_lot_id, + scrap_lot_id=scrap_lot_id, + finished_txn_id=finished_txn_id, + surplus_txn_id=surplus_txn_id, + scrap_txn_id=scrap_txn_id, + settled=settled, + status=response_status, + remark=response_remark, + archive_status=archive_status, + archive_business_id=archive_business_id, + archive_document_type=archive_document_type, + archive_error_message=archive_error_message, + ) diff --git a/backend/app/services/production_work_order_ledger.py b/backend/app/services/production_work_order_ledger.py new file mode 100644 index 0000000..3f6ff0f --- /dev/null +++ b/backend/app/services/production_work_order_ledger.py @@ -0,0 +1,356 @@ +from __future__ import annotations + +from decimal import Decimal + +from fastapi import HTTPException +from pydantic import BaseModel +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models.master_data import BomItem, Item +from app.models.operations import ( + CompletionReceipt, + CompletionReceiptItem, + InventoryTxn, + StockLot, + WorkOrder, + WorkOrderMaterial, + WorkOrderMaterialIssue, + WorkOrderOperation, +) +from app.services.operations import to_decimal + + +INBOUND_TOLERANCE_MULTIPLIER = Decimal("1.15") + + +class WorkOrderLimitSnapshot(BaseModel): + work_order_id: int + gross_weight_per_piece_kg: Decimal + net_weight_per_piece_kg: Decimal + issued_weight_kg: Decimal + used_material_weight_kg: Decimal + returned_material_weight_kg: Decimal + final_operation_good_qty: Decimal + finished_received_qty: Decimal + total_operation_scrap_qty: Decimal + scrap_inbound_weight_kg: Decimal + standard_scrap_weight_kg: Decimal + max_finished_inbound_qty: Decimal + max_scrap_inbound_weight_kg: Decimal + max_surplus_inbound_weight_kg: Decimal + tolerance_finished_inbound_qty: Decimal + tolerance_scrap_inbound_weight_kg: Decimal + tolerance_surplus_inbound_weight_kg: Decimal + + +class WorkOrderLedgerMaterialRow(BaseModel): + material_item_id: int + material_code: str + material_name: str + source_lot_id: int + source_lot_no: str + issued_weight_kg: Decimal + returned_weight_kg: Decimal = Decimal("0") + unit_cost: Decimal = Decimal("0") + amount: Decimal = Decimal("0") + + +class WorkOrderLedgerOperationRow(BaseModel): + work_order_operation_id: int + seq_no: int + operation_name: str + report_qty: Decimal + good_qty: Decimal + scrap_qty: Decimal + status: str + + +class WorkOrderLedgerRow(BaseModel): + work_order_id: int + work_order_no: str + product_item_id: int + product_code: str + product_name: str + planned_qty: Decimal + status: str + remark: str | None = None + material_summary: str + source_lot_summary: str + issued_weight_kg: Decimal + used_material_weight_kg: Decimal + returned_material_weight_kg: Decimal + finished_received_qty: Decimal + max_finished_inbound_qty: Decimal + max_scrap_inbound_weight_kg: Decimal + max_surplus_inbound_weight_kg: Decimal + limits: WorkOrderLimitSnapshot + materials: list[WorkOrderLedgerMaterialRow] + operations: list[WorkOrderLedgerOperationRow] + + +def _non_negative(value: Decimal) -> Decimal: + return max(value, Decimal("0")) + + +def _round_decimal(value: Decimal, places: str = "0.001") -> Decimal: + return to_decimal(value, places) + + +def _get_work_order(db: Session, work_order_id: int) -> WorkOrder: + work_order = db.get(WorkOrder, work_order_id) + if not work_order: + raise HTTPException(status_code=404, detail="生产工单不存在") + return work_order + + +def _get_weight_profile(db: Session, work_order: WorkOrder) -> tuple[Decimal, Decimal]: + bom_item = db.scalar( + select(BomItem) + .where( + BomItem.bom_id == work_order.bom_id, + BomItem.gross_weight_per_piece_kg > 0, + ) + .order_by(BomItem.seq_no, BomItem.id) + .limit(1) + ) + if not bom_item: + bom_item = db.scalar( + select(BomItem) + .where(BomItem.bom_id == work_order.bom_id) + .order_by(BomItem.seq_no, BomItem.id) + .limit(1) + ) + product = db.get(Item, work_order.product_item_id) + gross = to_decimal(bom_item.gross_weight_per_piece_kg if bom_item else 0) + net = to_decimal(bom_item.net_weight_per_piece_kg if bom_item else 0) + if net <= 0 and product: + net = to_decimal(product.unit_weight_kg) + if gross <= 0: + gross = net + if gross <= 0: + raise HTTPException(status_code=400, detail="产品需用清单缺少产品毛重,无法计算工单入库上限") + if net < 0: + net = Decimal("0") + if gross < net: + gross = net + return gross, net + + +def _get_operations(db: Session, work_order_id: int) -> list[WorkOrderOperation]: + return list( + db.scalars( + select(WorkOrderOperation) + .where(WorkOrderOperation.work_order_id == work_order_id) + .order_by(WorkOrderOperation.seq_no, WorkOrderOperation.id) + ).all() + ) + + +def _get_finished_received_qty(db: Session, work_order_id: int) -> Decimal: + value = db.scalar( + select(func.coalesce(func.sum(CompletionReceiptItem.receipt_qty), 0)) + .join(CompletionReceipt, CompletionReceipt.id == CompletionReceiptItem.completion_receipt_id) + .where(CompletionReceipt.work_order_id == work_order_id) + ) + return to_decimal(value) + + +def _get_scrap_inbound_weight(db: Session, work_order_id: int) -> Decimal: + value = db.scalar( + select(func.coalesce(func.sum(InventoryTxn.weight_change_kg), 0)).where( + InventoryTxn.txn_type == "PRODUCTION_SCRAP_IN", + InventoryTxn.source_doc_type.in_(("WORK_ORDER", "PRODUCTION_SCRAP_IN")), + InventoryTxn.source_doc_id == work_order_id, + ) + ) + return _non_negative(to_decimal(value)) + + +def get_work_order_limit_snapshot(db: Session, work_order_id: int) -> WorkOrderLimitSnapshot: + work_order = _get_work_order(db, work_order_id) + gross_weight, net_weight = _get_weight_profile(db, work_order) + operations = _get_operations(db, work_order_id) + issued_weight = to_decimal( + db.scalar( + select(func.coalesce(func.sum(WorkOrderMaterialIssue.issued_weight_kg), 0)).where( + WorkOrderMaterialIssue.work_order_id == work_order_id + ) + ) + ) + returned_weight = to_decimal( + db.scalar( + select(func.coalesce(func.sum(WorkOrderMaterial.returned_weight_kg), 0)).where( + WorkOrderMaterial.work_order_id == work_order_id + ) + ) + ) + max_report_qty = max((to_decimal(operation.report_qty) for operation in operations), default=Decimal("0")) + used_material_weight = _round_decimal(max_report_qty * gross_weight) + final_good_qty = to_decimal(operations[-1].good_qty) if operations else Decimal("0") + finished_received_qty = _get_finished_received_qty(db, work_order_id) + total_scrap_qty = sum((to_decimal(operation.scrap_qty) for operation in operations), Decimal("0")) + scrap_inbound_weight = _get_scrap_inbound_weight(db, work_order_id) + edge_scrap_weight = _non_negative((max_report_qty - total_scrap_qty) * _non_negative(gross_weight - net_weight)) + rejected_product_scrap_weight = total_scrap_qty * gross_weight + standard_scrap_weight = edge_scrap_weight + rejected_product_scrap_weight + max_finished_inbound_qty = _non_negative(final_good_qty - finished_received_qty) + max_scrap_inbound_weight = _non_negative(standard_scrap_weight - scrap_inbound_weight) + max_surplus_inbound_weight = _non_negative(issued_weight - used_material_weight - returned_weight) + tolerance_scrap_inbound_weight = _non_negative( + standard_scrap_weight * INBOUND_TOLERANCE_MULTIPLIER - scrap_inbound_weight + ) + tolerance_surplus_inbound_weight = _non_negative( + (issued_weight - used_material_weight) * INBOUND_TOLERANCE_MULTIPLIER - returned_weight + ) + + return WorkOrderLimitSnapshot( + work_order_id=work_order_id, + gross_weight_per_piece_kg=_round_decimal(gross_weight), + net_weight_per_piece_kg=_round_decimal(net_weight), + issued_weight_kg=_round_decimal(issued_weight), + used_material_weight_kg=_round_decimal(used_material_weight), + returned_material_weight_kg=_round_decimal(returned_weight), + final_operation_good_qty=_round_decimal(final_good_qty), + finished_received_qty=_round_decimal(finished_received_qty), + total_operation_scrap_qty=_round_decimal(total_scrap_qty), + scrap_inbound_weight_kg=_round_decimal(scrap_inbound_weight), + standard_scrap_weight_kg=_round_decimal(standard_scrap_weight), + max_finished_inbound_qty=_round_decimal(max_finished_inbound_qty), + max_scrap_inbound_weight_kg=_round_decimal(max_scrap_inbound_weight), + max_surplus_inbound_weight_kg=_round_decimal(max_surplus_inbound_weight), + tolerance_finished_inbound_qty=_round_decimal(max_finished_inbound_qty * INBOUND_TOLERANCE_MULTIPLIER), + tolerance_scrap_inbound_weight_kg=_round_decimal(tolerance_scrap_inbound_weight), + tolerance_surplus_inbound_weight_kg=_round_decimal(tolerance_surplus_inbound_weight), + ) + + +def validate_inbound_with_tolerance(label: str, requested: Decimal, calculated_max: Decimal) -> None: + if requested <= 0: + raise HTTPException(status_code=400, detail=f"{label}必须大于0") + allowed = _round_decimal(_non_negative(calculated_max) * INBOUND_TOLERANCE_MULTIPLIER) + if requested > allowed: + raise HTTPException(status_code=400, detail=f"{label}超过允许入库上限 {allowed}") + + +def validate_work_order_source_lot( + db: Session, + work_order_id: int, + source_lot_id: int, + item_id: int, +) -> WorkOrderMaterial: + row = db.execute( + select(WorkOrderMaterial) + .join(WorkOrderMaterialIssue, WorkOrderMaterialIssue.work_order_material_id == WorkOrderMaterial.id) + .where( + WorkOrderMaterial.work_order_id == work_order_id, + WorkOrderMaterial.material_item_id == item_id, + WorkOrderMaterialIssue.source_lot_id == source_lot_id, + ) + .limit(1) + ).scalar_one_or_none() + if not row: + raise HTTPException(status_code=400, detail="来源库存批次号不属于该生产工单") + return row + + +def get_work_order_ledger_rows( + db: Session, + limit: int = 200, + work_order_id: int | None = None, +) -> list[WorkOrderLedgerRow]: + stmt = select(WorkOrder).order_by(WorkOrder.id.desc()).limit(limit) + if work_order_id: + stmt = stmt.where(WorkOrder.id == work_order_id) + work_orders = db.scalars(stmt).all() + rows: list[WorkOrderLedgerRow] = [] + + for work_order in work_orders: + product = db.get(Item, work_order.product_item_id) + limits = get_work_order_limit_snapshot(db, work_order.id) + surplus_return_subquery = ( + select( + InventoryTxn.lot_id.label("source_lot_id"), + func.coalesce(func.sum(InventoryTxn.weight_change_kg), 0).label("returned_weight_kg"), + ) + .where( + InventoryTxn.txn_type == "PRODUCTION_SURPLUS_IN", + InventoryTxn.source_doc_type == "WORK_ORDER", + InventoryTxn.source_doc_id == work_order.id, + ) + .group_by(InventoryTxn.lot_id) + .subquery() + ) + issue_rows = db.execute( + select( + WorkOrderMaterialIssue.material_item_id, + Item.item_code, + Item.item_name, + WorkOrderMaterialIssue.source_lot_id, + StockLot.lot_no, + WorkOrderMaterialIssue.issued_weight_kg, + func.coalesce(surplus_return_subquery.c.returned_weight_kg, 0).label("returned_weight_kg"), + WorkOrderMaterialIssue.unit_cost, + WorkOrderMaterialIssue.amount, + ) + .join(Item, Item.id == WorkOrderMaterialIssue.material_item_id) + .join(StockLot, StockLot.id == WorkOrderMaterialIssue.source_lot_id) + .join(WorkOrderMaterial, WorkOrderMaterial.id == WorkOrderMaterialIssue.work_order_material_id) + .outerjoin(surplus_return_subquery, surplus_return_subquery.c.source_lot_id == WorkOrderMaterialIssue.source_lot_id) + .where(WorkOrderMaterialIssue.work_order_id == work_order.id) + .order_by(WorkOrderMaterialIssue.id) + ).mappings().all() + material_rows = [ + WorkOrderLedgerMaterialRow( + material_item_id=int(issue["material_item_id"]), + material_code=issue["item_code"], + material_name=issue["item_name"], + source_lot_id=int(issue["source_lot_id"]), + source_lot_no=issue["lot_no"], + issued_weight_kg=_round_decimal(to_decimal(issue["issued_weight_kg"])), + returned_weight_kg=_round_decimal(to_decimal(issue["returned_weight_kg"])), + unit_cost=_round_decimal(to_decimal(issue["unit_cost"]), "0.0001"), + amount=_round_decimal(to_decimal(issue["amount"]), "0.01"), + ) + for issue in issue_rows + ] + operation_rows = [ + WorkOrderLedgerOperationRow( + work_order_operation_id=int(operation.id), + seq_no=int(operation.seq_no), + operation_name=operation.operation_name, + report_qty=_round_decimal(to_decimal(operation.report_qty)), + good_qty=_round_decimal(to_decimal(operation.good_qty)), + scrap_qty=_round_decimal(to_decimal(operation.scrap_qty)), + status=operation.status, + ) + for operation in _get_operations(db, work_order.id) + ] + material_names = sorted({row.material_name for row in material_rows}) + source_lot_nos = [row.source_lot_no for row in material_rows] + rows.append( + WorkOrderLedgerRow( + work_order_id=int(work_order.id), + work_order_no=work_order.work_order_no, + product_item_id=int(work_order.product_item_id), + product_code=product.item_code if product else "", + product_name=product.item_name if product else "", + planned_qty=_round_decimal(to_decimal(work_order.planned_qty)), + status=work_order.status, + remark=work_order.remark, + material_summary="、".join(material_names) if material_names else "-", + source_lot_summary="、".join(source_lot_nos) if source_lot_nos else "-", + issued_weight_kg=limits.issued_weight_kg, + used_material_weight_kg=limits.used_material_weight_kg, + returned_material_weight_kg=limits.returned_material_weight_kg, + finished_received_qty=limits.finished_received_qty, + max_finished_inbound_qty=limits.max_finished_inbound_qty, + max_scrap_inbound_weight_kg=limits.max_scrap_inbound_weight_kg, + max_surplus_inbound_weight_kg=limits.max_surplus_inbound_weight_kg, + limits=limits, + materials=material_rows, + operations=operation_rows, + ) + ) + + return rows diff --git a/backend/app/services/purchase_order_links.py b/backend/app/services/purchase_order_links.py new file mode 100644 index 0000000..98428f4 --- /dev/null +++ b/backend/app/services/purchase_order_links.py @@ -0,0 +1,54 @@ +from datetime import datetime + +from sqlalchemy import inspect, select +from sqlalchemy.orm import Session + +from app.models.operations import PurchaseOrderItem, PurchaseOrderSalesOrderLink +from app.models.planning import MaterialDemand +from app.models.sales import SalesOrder, SalesOrderItem + + +def ensure_purchase_order_sales_link_table(db: Session) -> None: + """Keep the explicit purchase-sales link table available when DB patches lag behind code.""" + if db.info.get("purchase_order_sales_link_table_ensured"): + return + bind = db.connection() + inspector = inspect(bind) + existed = inspector.has_table(PurchaseOrderSalesOrderLink.__tablename__) + PurchaseOrderSalesOrderLink.__table__.create(bind=bind, checkfirst=True) + if not existed: + backfill_purchase_order_sales_links(db) + db.info["purchase_order_sales_link_table_ensured"] = True + + +def backfill_purchase_order_sales_links(db: Session) -> None: + existing_pairs = { + (int(row.purchase_order_id), int(row.sales_order_id)) + for row in db.execute( + select(PurchaseOrderSalesOrderLink.purchase_order_id, PurchaseOrderSalesOrderLink.sales_order_id) + ) + } + inferred_rows = db.execute( + select( + PurchaseOrderItem.purchase_order_id.label("purchase_order_id"), + SalesOrder.id.label("sales_order_id"), + ) + .join(MaterialDemand, MaterialDemand.id == PurchaseOrderItem.source_demand_id) + .join(SalesOrderItem, SalesOrderItem.id == MaterialDemand.sales_order_item_id) + .join(SalesOrder, SalesOrder.id == SalesOrderItem.sales_order_id) + .distinct() + ).all() + now = datetime.now() + for row in inferred_rows: + pair = (int(row.purchase_order_id), int(row.sales_order_id)) + if pair in existing_pairs: + continue + db.add( + PurchaseOrderSalesOrderLink( + purchase_order_id=pair[0], + sales_order_id=pair[1], + created_at=now, + updated_at=now, + ) + ) + existing_pairs.add(pair) diff --git a/backend/app/services/sales_planning.py b/backend/app/services/sales_planning.py new file mode 100644 index 0000000..623c82f --- /dev/null +++ b/backend/app/services/sales_planning.py @@ -0,0 +1,397 @@ +from __future__ import annotations + +from datetime import date, datetime +from decimal import Decimal, ROUND_UP + +from fastapi import HTTPException +from sqlalchemy import Select, and_, case, delete, func, select +from sqlalchemy.orm import Session, aliased + +from app.models.master_data import Bom, BomItem, Item, Material, Product, StockBalance +from app.models.document_archive import DocumentArchive +from app.models.operations import Delivery, DeliveryItem, PurchaseOrderItem, WorkOrder +from app.models.org import Employee +from app.models.planning import MaterialDemand +from app.models.sales import Customer, SalesOrder, SalesOrderItem +from app.schemas.database import MaterialDemandRead + + +def build_serial(prefix: str) -> str: + return f"{prefix}{datetime.now().strftime('%Y%m%d%H%M%S%f')[-12:]}" + + +def code_token(value: str | None, fallback: str, max_length: int = 12) -> str: + token = "".join(ch for ch in str(value or "").upper() if ch.isalnum()) + return (token or fallback)[-max_length:] + + +def next_doc_no(db: Session, model, column_name: str, prefix: str) -> str: + column = getattr(model, column_name) + count = db.scalar(select(func.count(model.id)).where(column.like(f"{prefix}-%"))) or 0 + return f"{prefix}-{count + 1:03d}" + + +def decimalize(value: float | Decimal, places: str = "0.000001") -> Decimal: + return Decimal(str(value)).quantize(Decimal(places)) + + +def round_up_by_multiple(value: Decimal, multiple: Decimal) -> Decimal: + if multiple <= 0: + return decimalize(value) + return ((value / multiple).quantize(Decimal("1"), rounding=ROUND_UP) * multiple).quantize(Decimal("0.000001")) + + +def create_customer_code(db: Session) -> str: + date_part = date.today().strftime("%Y%m%d") + return next_doc_no(db, Customer, "customer_code", f"CUS-NB-{date_part}") + + +def build_mrp_demand_no(db: Session, sales_order: SalesOrder, order_item: SalesOrderItem, material_item: Item) -> str: + date_part = date.today().strftime("%Y%m%d") + order_part = code_token(sales_order.order_no, f"SO{sales_order.id:04d}", 12) + material_part = code_token(material_item.item_code or material_item.item_name, f"RM{material_item.id:04d}", 10) + prefix = f"MRP-NB-{date_part}-{order_part}-L{int(order_item.line_no or 0):02d}-{material_part}" + return next_doc_no(db, MaterialDemand, "demand_no", prefix) + + +def get_sales_order_query(limit: int = 50) -> Select: + sales_employee = aliased(Employee) + line_count_subquery = ( + select( + SalesOrderItem.sales_order_id.label("sales_order_id"), + func.count(SalesOrderItem.id).label("line_count"), + ) + .group_by(SalesOrderItem.sales_order_id) + .subquery() + ) + mrp_count_subquery = ( + select( + SalesOrderItem.sales_order_id.label("sales_order_id"), + func.count(MaterialDemand.id).label("mrp_count"), + ) + .join(MaterialDemand, MaterialDemand.sales_order_item_id == SalesOrderItem.id) + .where(MaterialDemand.status != "SUPERSEDED") + .group_by(SalesOrderItem.sales_order_id) + .subquery() + ) + purchase_count_subquery = ( + select( + SalesOrderItem.sales_order_id.label("sales_order_id"), + func.count(func.distinct(PurchaseOrderItem.purchase_order_id)).label("purchase_order_count"), + ) + .join(MaterialDemand, MaterialDemand.sales_order_item_id == SalesOrderItem.id) + .join(PurchaseOrderItem, PurchaseOrderItem.source_demand_id == MaterialDemand.id) + .group_by(SalesOrderItem.sales_order_id) + .subquery() + ) + delivery_count_subquery = ( + select( + Delivery.sales_order_id.label("sales_order_id"), + func.count(Delivery.id).label("delivery_count"), + ) + .where(Delivery.sales_order_id.is_not(None)) + .group_by(Delivery.sales_order_id) + .subquery() + ) + delivery_item_count_subquery = ( + select( + SalesOrderItem.sales_order_id.label("sales_order_id"), + func.count(DeliveryItem.id).label("delivery_item_count"), + ) + .join(DeliveryItem, DeliveryItem.sales_order_item_id == SalesOrderItem.id) + .group_by(SalesOrderItem.sales_order_id) + .subquery() + ) + work_order_count_subquery = ( + select( + SalesOrderItem.sales_order_id.label("sales_order_id"), + func.count(WorkOrder.id).label("work_order_count"), + ) + .join(WorkOrder, WorkOrder.source_sales_order_item_id == SalesOrderItem.id) + .group_by(SalesOrderItem.sales_order_id) + .subquery() + ) + latest_archive_version_subquery = ( + select( + DocumentArchive.business_id.label("business_id"), + func.max(DocumentArchive.archive_version).label("archive_version"), + ) + .where(DocumentArchive.document_type == "销售订单", DocumentArchive.file_format == "PDF") + .group_by(DocumentArchive.business_id) + .subquery() + ) + latest_archive_id_subquery = ( + select( + DocumentArchive.business_id.label("business_id"), + func.max(DocumentArchive.id).label("archive_id"), + ) + .join( + latest_archive_version_subquery, + and_( + latest_archive_version_subquery.c.business_id == DocumentArchive.business_id, + latest_archive_version_subquery.c.archive_version == DocumentArchive.archive_version, + ), + ) + .where(DocumentArchive.document_type == "销售订单", DocumentArchive.file_format == "PDF") + .group_by(DocumentArchive.business_id) + .subquery() + ) + latest_archive_subquery = ( + select( + DocumentArchive.business_id.label("business_id"), + DocumentArchive.status.label("archive_status"), + DocumentArchive.archive_version.label("archive_version"), + DocumentArchive.created_at.label("archive_created_at"), + DocumentArchive.error_message.label("archive_error_message"), + ) + .join( + latest_archive_id_subquery, + latest_archive_id_subquery.c.archive_id == DocumentArchive.id, + ) + .where(DocumentArchive.document_type == "销售订单", DocumentArchive.file_format == "PDF") + .subquery() + ) + + return ( + select( + SalesOrder.id.label("order_id"), + SalesOrder.order_no.label("order_no"), + SalesOrder.customer_id.label("customer_id"), + Customer.customer_code.label("customer_code"), + Customer.customer_name.label("customer_name"), + SalesOrder.sales_employee_id.label("sales_employee_id"), + sales_employee.employee_name.label("sales_employee_name"), + SalesOrder.order_date.label("order_date"), + SalesOrder.promised_date.label("promised_date"), + SalesOrder.delivery_address.label("delivery_address"), + SalesOrder.total_amount.label("total_amount"), + SalesOrder.status.label("status"), + func.coalesce(line_count_subquery.c.line_count, 0).label("line_count"), + func.coalesce(mrp_count_subquery.c.mrp_count, 0).label("mrp_count"), + func.coalesce(purchase_count_subquery.c.purchase_order_count, 0).label("purchase_order_count"), + func.coalesce(delivery_count_subquery.c.delivery_count, 0).label("delivery_count"), + func.coalesce(delivery_item_count_subquery.c.delivery_item_count, 0).label("delivery_item_count"), + func.coalesce(work_order_count_subquery.c.work_order_count, 0).label("work_order_count"), + func.coalesce(latest_archive_subquery.c.archive_status, "未生成").label("archive_status"), + latest_archive_subquery.c.archive_version.label("archive_version"), + latest_archive_subquery.c.archive_created_at.label("archive_created_at"), + latest_archive_subquery.c.archive_error_message.label("archive_error_message"), + case( + ( + and_( + func.coalesce(purchase_count_subquery.c.purchase_order_count, 0) == 0, + func.coalesce(delivery_count_subquery.c.delivery_count, 0) == 0, + func.coalesce(delivery_item_count_subquery.c.delivery_item_count, 0) == 0, + func.coalesce(work_order_count_subquery.c.work_order_count, 0) == 0, + ), + True, + ), + else_=False, + ).label("can_delete"), + ) + .join(Customer, Customer.id == SalesOrder.customer_id) + .outerjoin(sales_employee, sales_employee.id == SalesOrder.sales_employee_id) + .outerjoin(line_count_subquery, line_count_subquery.c.sales_order_id == SalesOrder.id) + .outerjoin(mrp_count_subquery, mrp_count_subquery.c.sales_order_id == SalesOrder.id) + .outerjoin(purchase_count_subquery, purchase_count_subquery.c.sales_order_id == SalesOrder.id) + .outerjoin(delivery_count_subquery, delivery_count_subquery.c.sales_order_id == SalesOrder.id) + .outerjoin(delivery_item_count_subquery, delivery_item_count_subquery.c.sales_order_id == SalesOrder.id) + .outerjoin(work_order_count_subquery, work_order_count_subquery.c.sales_order_id == SalesOrder.id) + .outerjoin(latest_archive_subquery, latest_archive_subquery.c.business_id == SalesOrder.id) + .order_by(SalesOrder.id.desc()) + .limit(limit) + ) + + +def get_sales_order_items_query(limit: int = 50, sales_order_id: int | None = None) -> Select: + stmt = ( + select( + SalesOrderItem.id.label("sales_order_item_id"), + SalesOrderItem.sales_order_id.label("sales_order_id"), + SalesOrder.order_no.label("order_no"), + SalesOrderItem.line_no.label("line_no"), + SalesOrderItem.product_item_id.label("product_item_id"), + Item.item_code.label("product_code"), + Item.item_name.label("product_name"), + SalesOrderItem.customer_part_no.label("customer_part_no"), + SalesOrderItem.order_qty.label("order_qty"), + SalesOrderItem.delivered_qty.label("delivered_qty"), + SalesOrderItem.unit_price.label("unit_price"), + SalesOrderItem.line_amount.label("line_amount"), + SalesOrderItem.promised_date.label("promised_date"), + SalesOrderItem.status.label("status"), + ) + .join(SalesOrder, SalesOrder.id == SalesOrderItem.sales_order_id) + .join(Item, Item.id == SalesOrderItem.product_item_id) + .order_by(SalesOrderItem.id.desc()) + .limit(limit) + ) + if sales_order_id: + stmt = stmt.where(SalesOrderItem.sales_order_id == sales_order_id) + return stmt + + +def get_mrp_demands_query(limit: int = 50, sales_order_id: int | None = None) -> Select: + product_item = aliased(Item) + material_item = aliased(Item) + material_ext = aliased(Material) + + stmt = ( + select( + MaterialDemand.id.label("demand_id"), + MaterialDemand.demand_no.label("demand_no"), + SalesOrder.order_no.label("order_no"), + MaterialDemand.material_item_id.label("material_item_id"), + product_item.item_code.label("product_code"), + product_item.item_name.label("product_name"), + material_item.item_code.label("material_code"), + material_item.item_name.label("material_name"), + material_ext.purchase_calc_mode.label("purchase_calc_mode"), + MaterialDemand.order_qty.label("order_qty"), + MaterialDemand.theoretical_weight_kg.label("theoretical_weight_kg"), + MaterialDemand.planned_weight_kg.label("planned_weight_kg"), + MaterialDemand.shortage_weight_kg.label("shortage_weight_kg"), + MaterialDemand.suggested_purchase_qty.label("suggested_purchase_qty"), + MaterialDemand.suggested_purchase_weight_kg.label("suggested_purchase_weight_kg"), + MaterialDemand.status.label("status"), + MaterialDemand.generated_at.label("generated_at"), + ) + .join(SalesOrderItem, SalesOrderItem.id == MaterialDemand.sales_order_item_id) + .join(SalesOrder, SalesOrder.id == SalesOrderItem.sales_order_id) + .join(product_item, product_item.id == MaterialDemand.product_item_id) + .join(material_item, material_item.id == MaterialDemand.material_item_id) + .outerjoin(material_ext, material_ext.item_id == MaterialDemand.material_item_id) + .where(MaterialDemand.status != "SUPERSEDED") + .order_by(MaterialDemand.id.desc()) + .limit(limit) + ) + if sales_order_id: + stmt = stmt.where(SalesOrder.id == sales_order_id) + return stmt + + +def generate_mrp_for_order(db: Session, sales_order_id: int) -> tuple[SalesOrder, list[MaterialDemandRead]]: + sales_order = db.get(SalesOrder, sales_order_id) + if not sales_order: + raise HTTPException(status_code=404, detail="订单不存在") + + order_items = db.scalars( + select(SalesOrderItem).where(SalesOrderItem.sales_order_id == sales_order_id).order_by(SalesOrderItem.line_no) + ).all() + if not order_items: + raise HTTPException(status_code=400, detail="订单没有明细,无法生成MRP") + + item_ids = [item.id for item in order_items] + if item_ids: + existing_demands = db.scalars( + select(MaterialDemand).where(MaterialDemand.sales_order_item_id.in_(item_ids)) + ).all() + existing_demand_ids = [demand.id for demand in existing_demands] + referenced_demand_ids: set[int] = set() + if existing_demand_ids: + referenced_demand_ids = set( + db.scalars( + select(PurchaseOrderItem.source_demand_id).where( + PurchaseOrderItem.source_demand_id.in_(existing_demand_ids) + ) + ).all() + ) + + deletable_demand_ids = [ + demand_id for demand_id in existing_demand_ids if demand_id not in referenced_demand_ids + ] + if deletable_demand_ids: + db.execute(delete(MaterialDemand).where(MaterialDemand.id.in_(deletable_demand_ids))) + + for demand in existing_demands: + if demand.id in referenced_demand_ids: + demand.status = "SUPERSEDED" + demand.remark = "已被重新生成的MRP替代,因已被采购订单引用而保留追溯" + db.add(demand) + + db.flush() + + generated_at = datetime.now() + created_rows: list[MaterialDemand] = [] + + for order_item in order_items: + product = db.get(Item, order_item.product_item_id) + if not product: + raise HTTPException(status_code=400, detail=f"产品ID {order_item.product_item_id} 不存在") + + bom = db.scalar( + select(Bom).where(Bom.product_item_id == product.id, Bom.is_default == 1, Bom.status == "ACTIVE") + ) + if not bom: + raise HTTPException(status_code=400, detail=f"产品 {product.item_name} 缺少默认BOM") + + bom_items = db.scalars(select(BomItem).where(BomItem.bom_id == bom.id).order_by(BomItem.seq_no)).all() + if not bom_items: + raise HTTPException(status_code=400, detail=f"产品 {product.item_name} 的BOM没有明细") + + order_qty = decimalize(order_item.order_qty) + + for bom_item in bom_items: + material_item = db.get(Item, bom_item.material_item_id) + if not material_item: + raise HTTPException(status_code=400, detail=f"BOM原材料ID {bom_item.material_item_id} 不存在") + + material_ext = db.scalar(select(Material).where(Material.item_id == material_item.id)) + if not material_ext: + raise HTTPException(status_code=400, detail=f"原材料 {material_item.item_name} 缺少采购扩展信息") + + loss_multiplier = Decimal("1") + decimalize(bom_item.loss_rate or 0, "0.0001") + material_unit_weight = Decimal(str(material_item.unit_weight_kg or 0)) + + available_stock_weight = Decimal( + str( + db.scalar( + select(func.coalesce(func.sum(StockBalance.weight_available_kg), 0)).where( + StockBalance.item_id == material_item.id + ) + ) + or 0 + ) + ) + + gross_weight_per_piece = decimalize(bom_item.gross_weight_per_piece_kg) + if gross_weight_per_piece <= 0 and material_unit_weight > 0: + gross_weight_per_piece = decimalize(bom_item.qty_per_piece) * material_unit_weight + theoretical_weight = order_qty * gross_weight_per_piece + planned_weight = theoretical_weight * loss_multiplier + + shortage_weight = max(Decimal("0"), planned_weight - available_stock_weight) + + suggested_purchase_weight = shortage_weight + purchase_multiple_weight = Decimal(str(material_ext.purchase_multiple_weight_kg or 0)) + min_purchase_weight = Decimal(str(material_ext.min_purchase_qty or 0)) + if suggested_purchase_weight > 0: + if purchase_multiple_weight > 0: + suggested_purchase_weight = round_up_by_multiple(suggested_purchase_weight, purchase_multiple_weight) + elif min_purchase_weight > 0 and suggested_purchase_weight < min_purchase_weight: + suggested_purchase_weight = min_purchase_weight.quantize(Decimal("0.000001")) + + demand = MaterialDemand( + demand_no=build_mrp_demand_no(db, sales_order, order_item, material_item), + sales_order_item_id=order_item.id, + product_item_id=product.id, + material_item_id=material_item.id, + bom_item_id=bom_item.id, + order_qty=order_qty, + theoretical_weight_kg=theoretical_weight, + planned_weight_kg=planned_weight, + available_stock_weight_kg=available_stock_weight, + shortage_weight_kg=shortage_weight, + suggested_purchase_qty=Decimal("0"), + suggested_purchase_weight_kg=suggested_purchase_weight, + status="NEW", + generated_at=generated_at, + remark="由订单自动生成", + ) + db.add(demand) + created_rows.append(demand) + + db.commit() + + rows = db.execute(get_mrp_demands_query(limit=200, sales_order_id=sales_order_id)).mappings().all() + return sales_order, [MaterialDemandRead.model_validate(dict(row)) for row in rows] diff --git a/backend/app/services/special_inventory_adjustment.py b/backend/app/services/special_inventory_adjustment.py new file mode 100644 index 0000000..1c60a59 --- /dev/null +++ b/backend/app/services/special_inventory_adjustment.py @@ -0,0 +1,425 @@ +from __future__ import annotations + +from datetime import date, datetime +from decimal import Decimal + +from fastapi import HTTPException +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.master_data import Item, Warehouse +from app.models.operations import InventoryTxn, SpecialWarehouseAdjustment, SpecialWarehouseAdjustmentLine, StockLot +from app.schemas.operations import SpecialWarehouseAdjustmentCreate +from app.services.operations import ( + build_inventory_lot_no, + clamp_non_negative, + create_inventory_txn, + get_location_or_default, + to_decimal, + upsert_stock_balance, +) +from app.services.sales_planning import next_doc_no +from app.services.stocktake import ensure_warehouses_unlocked + + +SOURCE_DOC_TYPE = "SPECIAL_ADJUSTMENT" +LOT_ROLE = "SPECIAL_ADJUSTMENT" + + +def _strip_text(value: str | None) -> str: + return str(value or "").strip() + + +def _raise_value_from_http(exc: HTTPException) -> None: + detail = getattr(exc, "detail", None) + raise ValueError(str(detail or exc)) from exc + + +def _format_decimal(value: Decimal) -> str: + normalized = value.quantize(Decimal("0.000001")).normalize() + if normalized == normalized.to_integral(): + return str(normalized.quantize(Decimal("1"))) + return format(normalized, "f") + + +def _trim_255(value: str) -> str: + return value[:255] + + +def _trim_to(value: str, limit: int) -> str: + if limit <= 0: + return "" + return value[:limit] + + +def _warehouse_type_label(warehouse_type: str) -> str: + return { + "AUX": "辅料", + "FINISHED": "成品", + "RETURN": "退货", + "SEMI": "半成品", + "SCRAP": "废品", + "RAW": "原材料", + }.get(warehouse_type, "库存") + + +def _build_adjustment_no(db: Session, *, direction: str, adjusted_at: datetime) -> str: + direction_label = "入库" if direction == "IN" else "出库" + return next_doc_no( + db, + SpecialWarehouseAdjustment, + "adjustment_no", + f"特殊调整{adjusted_at.strftime('%Y%m%d')}{direction_label}", + ) + + +def _build_special_lot_no( + db: Session, + *, + adjustment_id: int, + item_id: int, + warehouse_type: str, + now: datetime, +) -> str: + if warehouse_type == "RAW": + return build_inventory_lot_no(db, item_id) + prefix = f"{_warehouse_type_label(warehouse_type)}特殊{now.strftime('%Y%m%d')}-{adjustment_id}" + candidate = next_doc_no(db, StockLot, "lot_no", prefix) + sequence = 1 + while db.scalar(select(StockLot.id).where(StockLot.lot_no == candidate).limit(1)): + sequence += 1 + candidate = f"{prefix}-{sequence:03d}" + return candidate + + +def _normalize_change( + *, + warehouse_type: str, + raw_qty: Decimal, + raw_weight: Decimal, +) -> tuple[Decimal, Decimal]: + qty = to_decimal(raw_qty) + weight = to_decimal(raw_weight) + if qty < 0 or weight < 0: + raise ValueError("特殊调整数量和重量不能小于0") + + if warehouse_type in {"RAW", "SCRAP"}: + qty = Decimal("0") + if weight <= 0: + raise ValueError("特殊调整重量必须大于0") + elif warehouse_type == "AUX": + weight = Decimal("0") + if qty <= 0: + raise ValueError("特殊调整数量必须大于0") + elif warehouse_type == "SEMI": + if qty > 0 and weight > 0: + raise ValueError("半成品特殊调整只允许选择数量或重量一种口径") + if qty <= 0 and weight <= 0: + raise ValueError("特殊调整数量或重量必须大于0") + else: + if qty <= 0: + raise ValueError("特殊调整数量必须大于0") + weight = max(weight, Decimal("0")) + return to_decimal(qty), to_decimal(weight) + + +def _assert_out_available(lot: StockLot, *, qty: Decimal, weight: Decimal, warehouse_type: str) -> None: + available_qty = clamp_non_negative(to_decimal(lot.remaining_qty) - to_decimal(lot.locked_qty)) + available_weight = clamp_non_negative(to_decimal(lot.remaining_weight_kg) - to_decimal(lot.locked_weight_kg)) + if warehouse_type == "AUX" and qty > available_qty: + raise ValueError("特殊出库数量不能超过当前可用数量") + if warehouse_type in {"RAW", "SCRAP"} and weight > available_weight: + raise ValueError("特殊出库重量不能超过当前可用重量") + if warehouse_type not in {"AUX", "RAW", "SCRAP"}: + if qty > available_qty: + raise ValueError("特殊出库数量不能超过当前可用数量") + if weight > available_weight: + raise ValueError("特殊出库重量不能超过当前可用重量") + + +def _build_remark( + *, + direction: str, + reason: str, + line_reason: str | None, + before_qty: Decimal, + after_qty: Decimal, + before_weight: Decimal, + after_weight: Decimal, +) -> str: + direction_label = "特殊入库" if direction == "IN" else "特殊出库" + metrics = [ + f"调整前数量:{_format_decimal(before_qty)}", + f"调整后数量:{_format_decimal(after_qty)}", + f"调整前重量:{_format_decimal(before_weight)}", + f"调整后重量:{_format_decimal(after_weight)}", + ] + metric_text = ";".join(metrics) + reason_prefix = f"{direction_label}说明:" + reason_text = _strip_text(reason) + detail = _strip_text(line_reason) + + reserved_without_detail = f"{reason_prefix};{metric_text}" + reason_limit = max(0, 255 - len(reserved_without_detail)) + parts = [f"{reason_prefix}{_trim_to(reason_text, reason_limit)}", metric_text] + + if detail: + detail_prefix = "明细说明:" + detail_reserved = ";".join(parts) + f";{detail_prefix}" + detail_limit = max(0, 255 - len(detail_reserved)) + trimmed_detail = _trim_to(detail, detail_limit) + if trimmed_detail: + parts.append(f"{detail_prefix}{trimmed_detail}") + return ";".join(parts) + + +def _create_new_special_lot( + db: Session, + *, + adjustment_id: int, + item_id: int, + warehouse_id: int, + location_id: int | None, + warehouse_type: str, + qty: Decimal, + weight: Decimal, + unit_cost: Decimal, + reason: str, + now: datetime, +) -> StockLot: + summary = f"特殊入库说明:{reason}" + lot = StockLot( + lot_no=_build_special_lot_no( + db, + adjustment_id=adjustment_id, + item_id=item_id, + warehouse_type=warehouse_type, + now=now, + ), + parent_lot_id=None, + lot_role=LOT_ROLE, + material_sub_batch_no=None, + item_id=item_id, + warehouse_id=warehouse_id, + location_id=location_id, + source_doc_type=SOURCE_DOC_TYPE, + source_doc_id=adjustment_id, + source_line_id=None, + source_material_lot_id=None, + source_material_sub_batch_no=None, + source_material_summary=summary, + inbound_qty=qty, + inbound_weight_kg=weight, + remaining_qty=qty, + remaining_weight_kg=weight, + locked_qty=to_decimal(0), + locked_weight_kg=to_decimal(0), + unit_cost=unit_cost, + production_date=now.date() if isinstance(now.date(), date) else None, + expire_date=None, + quality_status="PASS", + status="AVAILABLE", + remark=_trim_255(summary), + created_at=now, + updated_at=now, + ) + db.add(lot) + db.flush() + return lot + + +def create_special_inventory_adjustment( + db: Session, + payload: SpecialWarehouseAdjustmentCreate, + user_id: int | None = None, +) -> SpecialWarehouseAdjustment: + now = datetime.now() + direction = str(payload.direction or "").upper() + reason = _strip_text(payload.reason) + if direction not in {"IN", "OUT"}: + raise ValueError("特殊库存调整方向只能是特殊入库或特殊出库") + direction_label = "特殊入库" if direction == "IN" else "特殊出库" + if len(reason) < 4: + raise ValueError(f"{direction_label}必须填写说明") + if len(reason) > 1000: + raise ValueError(f"{direction_label}说明不能超过1000个字") + + try: + warehouse = db.get(Warehouse, payload.warehouse_id) + if not warehouse: + raise ValueError("仓库不存在") + warehouse_type = str(warehouse.warehouse_type or "").upper() + ensure_warehouses_unlocked(db, [payload.warehouse_id], direction_label) + + adjustment = SpecialWarehouseAdjustment( + adjustment_no=_build_adjustment_no(db, direction=direction, adjusted_at=now), + warehouse_id=payload.warehouse_id, + warehouse_type=warehouse_type, + direction=direction, + reason=reason, + status="已确认", + operator_user_id=user_id, + adjusted_at=now, + created_at=now, + updated_at=now, + ) + db.add(adjustment) + db.flush() + + for index, line_payload in enumerate(payload.lines, start=1): + item = db.get(Item, line_payload.item_id) + if not item: + raise ValueError(f"物料不存在: {line_payload.item_id}") + location = get_location_or_default(db, payload.warehouse_id, line_payload.location_id) + if location and int(location.warehouse_id) != int(payload.warehouse_id): + raise ValueError("选择的库位不属于当前仓库") + + change_qty, change_weight = _normalize_change( + warehouse_type=warehouse_type, + raw_qty=to_decimal(line_payload.adjust_qty), + raw_weight=to_decimal(line_payload.adjust_weight_kg), + ) + unit_cost = to_decimal(line_payload.unit_cost, "0.0001") + lot: StockLot | None + + if direction == "OUT": + if not line_payload.lot_id: + raise ValueError("特殊出库必须选择库存批次") + lot = db.get(StockLot, line_payload.lot_id) + if not lot or int(lot.item_id) != int(line_payload.item_id) or int(lot.warehouse_id) != int(payload.warehouse_id): + raise ValueError("库存批次不存在或不属于当前仓库物料") + if location and lot.location_id and int(lot.location_id) != int(location.id): + raise ValueError("库存批次库位与选择库位不一致") + _assert_out_available(lot, qty=change_qty, weight=change_weight, warehouse_type=warehouse_type) + unit_cost = to_decimal(lot.unit_cost, "0.0001") if unit_cost <= 0 else unit_cost + before_qty = to_decimal(lot.remaining_qty) + before_weight = to_decimal(lot.remaining_weight_kg) + after_qty = clamp_non_negative(before_qty - change_qty) + after_weight = clamp_non_negative(before_weight - change_weight) + qty_delta = -change_qty + weight_delta = -change_weight + else: + if line_payload.lot_id: + lot = db.get(StockLot, line_payload.lot_id) + if not lot or int(lot.item_id) != int(line_payload.item_id) or int(lot.warehouse_id) != int(payload.warehouse_id): + raise ValueError("库存批次不存在或不属于当前仓库物料") + if location and lot.location_id and int(lot.location_id) != int(location.id): + raise ValueError("库存批次库位与选择库位不一致") + unit_cost = to_decimal(lot.unit_cost, "0.0001") if unit_cost <= 0 else unit_cost + before_qty = to_decimal(lot.remaining_qty) + before_weight = to_decimal(lot.remaining_weight_kg) + lot.inbound_qty = to_decimal(lot.inbound_qty) + change_qty + lot.inbound_weight_kg = to_decimal(lot.inbound_weight_kg) + change_weight + lot.remaining_qty = before_qty + change_qty + lot.remaining_weight_kg = before_weight + change_weight + lot.status = "AVAILABLE" + lot.updated_at = now + db.add(lot) + else: + before_qty = to_decimal(0) + before_weight = to_decimal(0) + lot = _create_new_special_lot( + db, + adjustment_id=adjustment.id, + item_id=line_payload.item_id, + warehouse_id=payload.warehouse_id, + location_id=location.id if location else None, + warehouse_type=warehouse_type, + qty=change_qty, + weight=change_weight, + unit_cost=unit_cost, + reason=reason, + now=now, + ) + after_qty = before_qty + change_qty + after_weight = before_weight + change_weight + qty_delta = change_qty + weight_delta = change_weight + + remark = _build_remark( + direction=direction, + reason=reason, + line_reason=line_payload.line_reason, + before_qty=before_qty, + after_qty=after_qty, + before_weight=before_weight, + after_weight=after_weight, + ) + if direction == "OUT": + lot.remaining_qty = after_qty + lot.remaining_weight_kg = after_weight + if after_qty <= 0 and after_weight <= 0: + lot.status = "DEPLETED" + lot.updated_at = now + db.add(lot) + + line = SpecialWarehouseAdjustmentLine( + adjustment_id=adjustment.id, + line_no=index, + item_id=line_payload.item_id, + warehouse_id=payload.warehouse_id, + location_id=lot.location_id if lot else (location.id if location else None), + lot_id=lot.id, + inventory_txn_id=None, + before_qty=before_qty, + change_qty=qty_delta, + after_qty=after_qty, + before_weight_kg=before_weight, + change_weight_kg=weight_delta, + after_weight_kg=after_weight, + unit_cost=unit_cost, + line_reason=_strip_text(line_payload.line_reason) or reason, + created_at=now, + updated_at=now, + ) + db.add(line) + db.flush() + if direction == "IN" and lot.source_doc_type == SOURCE_DOC_TYPE and lot.source_line_id is None: + lot.source_line_id = line.id + db.add(lot) + + upsert_stock_balance( + db, + item_id=line_payload.item_id, + warehouse_id=payload.warehouse_id, + location_id=line.location_id, + qty_delta=qty_delta, + weight_delta=weight_delta, + available_qty_delta=qty_delta, + available_weight_delta=weight_delta, + unit_cost=unit_cost, + ) + txn: InventoryTxn = create_inventory_txn( + db, + txn_type="SPECIAL_IN" if direction == "IN" else "SPECIAL_OUT", + item_id=line_payload.item_id, + warehouse_id=payload.warehouse_id, + location_id=line.location_id, + lot_id=lot.id, + qty_change=qty_delta, + weight_change=weight_delta, + unit_cost=unit_cost, + source_doc_type=SOURCE_DOC_TYPE, + source_doc_id=adjustment.id, + source_line_id=line.id, + biz_time=now, + operator_user_id=user_id, + remark=remark, + amount_basis="WEIGHT" if abs(weight_delta) > 0 else "QTY", + ) + line.inventory_txn_id = txn.id + line.updated_at = now + db.add(line) + + db.commit() + db.refresh(adjustment) + return adjustment + except HTTPException as exc: + db.rollback() + _raise_value_from_http(exc) + except ValueError: + db.rollback() + raise + except Exception: + db.rollback() + raise diff --git a/backend/app/services/stocktake.py b/backend/app/services/stocktake.py new file mode 100644 index 0000000..5c2b54a --- /dev/null +++ b/backend/app/services/stocktake.py @@ -0,0 +1,758 @@ +from __future__ import annotations + +from io import BytesIO +from datetime import date, datetime +from decimal import Decimal + +from fastapi import HTTPException +from openpyxl import Workbook, load_workbook +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models.master_data import Item, Warehouse +from app.models.operations import StockLot, Stocktake, StocktakeAdjustment, StocktakeLine, StocktakeWarehouse, WarehouseLocation +from app.services.operations import create_inventory_txn, to_decimal, upsert_stock_balance + +ACTIVE_STOCKTAKE_STATUSES = {"LOCKED", "EXPORTED", "IMPORTED"} + +STOCKTAKE_EXCEL_HEADERS = [ + "盘库单号", + "盘库校验码", + "仓库ID", + "快照行ID", + "仓库", + "库位", + "物料编码", + "物料名称", + "批次号", + "来源库存批次号", + "系统数量", + "系统重量(kg)", + "系统单价", + "盘点数量", + "盘点重量(kg)", + "盘点备注", +] +STOCKTAKE_QUANTITY_HEADERS = {"系统数量", "盘点数量"} + +EMPTY_STOCKTAKE_MARKER_REMARK = "空库盘点校验行,请保留此行用于校验盘库单号" + + +def build_stocktake_no(db: Session) -> str: + date_part = date.today().strftime("%Y%m%d") + prefix = f"PK-{date_part}" + count = db.scalar(select(func.count(Stocktake.id)).where(Stocktake.stocktake_no.like(f"{prefix}-%"))) or 0 + return f"{prefix}-{count + 1:03d}" + + +def stocktake_warehouse_type_label(value: str | None) -> str: + return { + "RAW": "原材料库", + "SEMI": "半成品库", + "FINISHED": "成品库", + "AUX": "辅料库", + "SCRAP": "废料库", + "RETURN": "退货库", + }.get(str(value or "").upper(), str(value or "未知仓库")) + + +def stocktake_validation_token(stocktake_no: str, warehouse_id: int, warehouse_type: str | None) -> str: + return f"{stocktake_no}|{int(warehouse_id)}|{str(warehouse_type or '').upper()}" + + +def stocktake_weight_only(warehouse_type: str | None, item_type: str | None) -> bool: + _ = item_type + return str(warehouse_type or "").upper() in {"RAW", "SCRAP"} + + +def stocktake_excel_headers_for_type(warehouse_type: str | None) -> list[str]: + if stocktake_weight_only(warehouse_type, None): + return [header for header in STOCKTAKE_EXCEL_HEADERS if header not in STOCKTAKE_QUANTITY_HEADERS] + return list(STOCKTAKE_EXCEL_HEADERS) + + +def stocktake_excel_row_for_headers(payload: dict[str, object], headers: list[str]) -> list[object]: + return [payload.get(header, "") for header in headers] + + +def get_locked_warehouse_ids(db: Session, warehouse_ids: list[int] | set[int]) -> set[int]: + ids = {int(item) for item in warehouse_ids if item} + if not ids: + return set() + rows = db.scalars( + select(StocktakeWarehouse.warehouse_id) + .join(Stocktake, Stocktake.id == StocktakeWarehouse.stocktake_id) + .where( + StocktakeWarehouse.warehouse_id.in_(ids), + StocktakeWarehouse.status == "LOCKED", + Stocktake.status.in_(ACTIVE_STOCKTAKE_STATUSES), + ) + ).all() + return {int(row) for row in rows} + + +def ensure_warehouses_unlocked(db: Session, warehouse_ids: list[int] | set[int], action_label: str) -> None: + locked_ids = get_locked_warehouse_ids(db, warehouse_ids) + if not locked_ids: + return + rows = db.scalars(select(Warehouse).where(Warehouse.id.in_(locked_ids)).order_by(Warehouse.id.asc())).all() + labels = "、".join(row.warehouse_name for row in rows) + raise HTTPException(status_code=423, detail=f"{labels}正在盘库,暂不能执行{action_label}") + + +def decimal_value(value: object, places: str = "0.000001") -> Decimal: + return Decimal(str(value or 0)).quantize(Decimal(places)) + + +def current_time() -> datetime: + return datetime.now() + + +def start_stocktake(db: Session, *, warehouse_ids: list[int], user_id: int | None, remark: str | None = None) -> Stocktake: + ids = [int(item) for item in warehouse_ids if item] + if not ids: + raise HTTPException(status_code=400, detail="请选择需要盘库的仓库") + if len(set(ids)) != 1: + raise HTTPException(status_code=400, detail="一次只能盘一个仓库,请先在仓库栏选中单个仓库") + ensure_warehouses_unlocked(db, ids, "开始盘库") + warehouses = db.scalars(select(Warehouse).where(Warehouse.id.in_(ids)).order_by(Warehouse.id.asc())).all() + if len(warehouses) != len(set(ids)): + raise HTTPException(status_code=404, detail="存在无效仓库,无法开始盘库") + + now = current_time() + stocktake = Stocktake( + stocktake_no=build_stocktake_no(db), + status="LOCKED", + started_by_user_id=user_id, + started_at=now, + remark=remark, + ) + db.add(stocktake) + db.flush() + + warehouses_by_id = {warehouse.id: warehouse for warehouse in warehouses} + stocktake_warehouses_by_id: dict[int, StocktakeWarehouse] = {} + for warehouse in warehouses: + row = StocktakeWarehouse( + stocktake_id=stocktake.id, + warehouse_id=warehouse.id, + warehouse_type=str(warehouse.warehouse_type or "").upper(), + warehouse_name=warehouse.warehouse_name, + status="LOCKED", + ) + db.add(row) + db.flush() + stocktake_warehouses_by_id[warehouse.id] = row + + lot_rows = db.execute( + select(StockLot, Item) + .join(Item, Item.id == StockLot.item_id) + .where( + StockLot.warehouse_id.in_(ids), + StockLot.status == "AVAILABLE", + StockLot.remaining_weight_kg > 0, + ) + .order_by(StockLot.warehouse_id.asc(), Item.item_code.asc(), StockLot.lot_no.asc(), StockLot.id.asc()) + ).all() + + line_no = 1 + for lot, item in lot_rows: + warehouse = warehouses_by_id.get(lot.warehouse_id) + if not warehouse: + continue + weight_only = stocktake_weight_only(warehouse.warehouse_type, item.item_type) + location_name = None + if lot.location_id: + location_name = db.scalar(select(WarehouseLocation.location_name).where(WarehouseLocation.id == lot.location_id)) + db.add( + StocktakeLine( + stocktake_id=stocktake.id, + stocktake_warehouse_id=stocktake_warehouses_by_id[warehouse.id].id, + line_no=line_no, + warehouse_id=warehouse.id, + warehouse_name=warehouse.warehouse_name, + warehouse_type=str(warehouse.warehouse_type or "").upper(), + location_id=lot.location_id, + location_name=location_name, + item_id=item.id, + item_code=item.item_code, + item_name=item.item_name, + item_type=item.item_type, + lot_id=lot.id, + lot_no=lot.lot_no, + source_material_sub_batch_no=lot.source_material_sub_batch_no or lot.material_sub_batch_no, + snapshot_qty=Decimal("0") if weight_only else to_decimal(lot.remaining_qty), + snapshot_weight_kg=to_decimal(lot.remaining_weight_kg), + snapshot_unit_cost=to_decimal(lot.unit_cost, "0.0001"), + counted_qty=None, + counted_weight_kg=None, + diff_qty=Decimal("0"), + diff_weight_kg=Decimal("0"), + diff_amount=Decimal("0"), + diff_type="MATCH", + row_status="SNAPSHOT", + remark=None, + ) + ) + line_no += 1 + + db.commit() + db.refresh(stocktake) + return stocktake + + +def _stocktake_lines(db: Session, stocktake_id: int) -> list[StocktakeLine]: + return db.scalars( + select(StocktakeLine) + .where(StocktakeLine.stocktake_id == stocktake_id) + .order_by(StocktakeLine.warehouse_id.asc(), StocktakeLine.line_no.asc()) + ).all() + + +def _single_stocktake_warehouse(db: Session, stocktake_id: int) -> StocktakeWarehouse: + rows = db.scalars( + select(StocktakeWarehouse) + .where(StocktakeWarehouse.stocktake_id == stocktake_id) + .order_by(StocktakeWarehouse.id.asc()) + ).all() + if len(rows) != 1: + raise HTTPException(status_code=400, detail="当前盘库单不是单仓库盘库,无法继续处理") + return rows[0] + + +def build_stocktake_workbook(db: Session, stocktake_id: int) -> Workbook: + stocktake = db.get(Stocktake, stocktake_id) + if not stocktake: + raise HTTPException(status_code=404, detail="盘库单不存在") + lines = _stocktake_lines(db, stocktake_id) + stocktake_warehouses = db.scalars( + select(StocktakeWarehouse).where(StocktakeWarehouse.stocktake_id == stocktake_id).order_by(StocktakeWarehouse.id.asc()) + ).all() + workbook = Workbook() + default_sheet = workbook.active + workbook.remove(default_sheet) + grouped: dict[int, list[StocktakeLine]] = {} + for line in lines: + grouped.setdefault(int(line.warehouse_id), []).append(line) + for warehouse in stocktake_warehouses: + grouped.setdefault(int(warehouse.warehouse_id), []) + warehouses_by_id = {int(warehouse.warehouse_id): warehouse for warehouse in stocktake_warehouses} + for warehouse_id, sheet_lines in grouped.items(): + warehouse = warehouses_by_id.get(int(warehouse_id)) + if not warehouse: + continue + sheet_name = stocktake_warehouse_type_label(warehouse.warehouse_type) + worksheet = workbook.create_sheet(title=sheet_name[:31]) + worksheet.freeze_panes = "A2" + headers = stocktake_excel_headers_for_type(warehouse.warehouse_type) + worksheet.append(headers) + if not sheet_lines: + token = stocktake_validation_token(stocktake.stocktake_no, warehouse.warehouse_id, warehouse.warehouse_type) + worksheet.append( + stocktake_excel_row_for_headers( + { + "盘库单号": stocktake.stocktake_no, + "盘库校验码": token, + "仓库ID": warehouse.warehouse_id, + "快照行ID": "", + "仓库": sheet_name, + "库位": "", + "物料编码": "", + "物料名称": "", + "批次号": "", + "来源库存批次号": "", + "系统数量": 0, + "系统重量(kg)": 0, + "系统单价": 0, + "盘点数量": 0, + "盘点重量(kg)": 0, + "盘点备注": EMPTY_STOCKTAKE_MARKER_REMARK, + }, + headers, + ) + ) + for line in sheet_lines: + worksheet.append( + stocktake_excel_row_for_headers( + { + "盘库单号": stocktake.stocktake_no, + "盘库校验码": stocktake_validation_token(stocktake.stocktake_no, line.warehouse_id, line.warehouse_type), + "仓库ID": line.warehouse_id, + "快照行ID": line.id, + "仓库": line.warehouse_name, + "库位": line.location_name or "", + "物料编码": line.item_code, + "物料名称": line.item_name, + "批次号": line.lot_no or "", + "来源库存批次号": line.source_material_sub_batch_no or "", + "系统数量": float(line.snapshot_qty or 0), + "系统重量(kg)": float(line.snapshot_weight_kg or 0), + "系统单价": float(line.snapshot_unit_cost or 0), + "盘点数量": float(line.snapshot_qty or 0), + "盘点重量(kg)": float(line.snapshot_weight_kg or 0), + "盘点备注": "", + }, + headers, + ) + ) + widths_by_header = { + "盘库单号": 18, + "盘库校验码": 30, + "仓库ID": 10, + "快照行ID": 12, + "仓库": 14, + "库位": 14, + "物料编码": 18, + "物料名称": 28, + "批次号": 22, + "来源库存批次号": 24, + "系统数量": 14, + "系统重量(kg)": 16, + "系统单价": 12, + "盘点数量": 14, + "盘点重量(kg)": 16, + "盘点备注": 24, + } + for index, header in enumerate(headers, start=1): + width = widths_by_header.get(header, 14) + worksheet.column_dimensions[worksheet.cell(row=1, column=index).column_letter].width = width + stocktake.exported_at = current_time() + stocktake.status = "EXPORTED" + db.add(stocktake) + db.commit() + return workbook + + +def _clean_text(value: object) -> str: + return str(value or "").strip() + + +def _read_workbook_rows(content: bytes, warehouse_type: str | None = None) -> list[dict[str, object]]: + try: + workbook = load_workbook(BytesIO(content), read_only=True, data_only=True) + except Exception as exc: + raise ValueError("Excel 文件读取失败,请确认上传的是盘库清单 xlsx 文件") from exc + rows: list[dict[str, object]] = [] + for worksheet in workbook.worksheets: + values = list(worksheet.iter_rows(values_only=True)) + if not values: + continue + headers = [_clean_text(item) for item in values[0]] + required_headers = stocktake_excel_headers_for_type(warehouse_type) + missing = [header for header in required_headers if header not in headers] + if missing: + raise ValueError(f"{worksheet.title} 缺少必要列:{'、'.join(missing)}") + for row in values[1:]: + if not any(_clean_text(item) for item in row): + continue + payload = {headers[index]: value for index, value in enumerate(row) if index < len(headers)} + payload["_sheet_name"] = worksheet.title + rows.append(payload) + return rows + + +def _is_empty_stocktake_marker_row(row: dict[str, object]) -> bool: + remark = _clean_text(row.get("盘点备注")) + if "空库" not in remark: + return False + if _clean_text(row.get("快照行ID")): + return False + if any(_clean_text(row.get(label)) for label in ["物料编码", "物料名称", "批次号", "来源库存批次号"]): + return False + return all( + decimal_value(row.get(label)) == 0 + for label in ["系统数量", "系统重量(kg)", "系统单价", "盘点数量", "盘点重量(kg)"] + ) + + +def _validate_stocktake_row_marker(row: dict[str, object], stocktake: Stocktake, warehouse: StocktakeWarehouse) -> None: + row_stocktake_no = _clean_text(row.get("盘库单号")) + if row_stocktake_no != stocktake.stocktake_no: + raise ValueError(f"存在不属于当前盘库单的行:{row_stocktake_no or '空盘库单号'}") + + row_warehouse_id = _clean_text(row.get("仓库ID")) + if row_warehouse_id and int(float(row_warehouse_id)) != int(warehouse.warehouse_id): + raise ValueError("导入清单不属于当前仓库,请确认仓库栏选中项和盘库清单一致") + + row_warehouse_name = _clean_text(row.get("仓库")) + if row_warehouse_name and row_warehouse_name != warehouse.warehouse_name: + raise ValueError("导入清单不属于当前仓库,请确认仓库栏选中项和盘库清单一致") + + expected_token = stocktake_validation_token(stocktake.stocktake_no, warehouse.warehouse_id, warehouse.warehouse_type) + row_token = _clean_text(row.get("盘库校验码")) + if row_token != expected_token: + raise ValueError("盘库校验码不匹配,请导入本次盘库导出的仓库清单") + + +def _reset_stocktake_preview_for_reimport(db: Session, stocktake_id: int, warehouse_id: int) -> dict[int, StocktakeLine]: + lines = db.scalars( + select(StocktakeLine) + .where(StocktakeLine.stocktake_id == stocktake_id, StocktakeLine.warehouse_id == warehouse_id) + .order_by(StocktakeLine.line_no.asc()) + ).all() + existing_lines: dict[int, StocktakeLine] = {} + for line in lines: + if line.lot_id is None and line.diff_type == "NEW_LOT": + db.delete(line) + continue + line.counted_qty = None + line.counted_weight_kg = None + line.diff_qty = Decimal("0") + line.diff_weight_kg = Decimal("0") + line.diff_amount = Decimal("0") + line.diff_type = "MATCH" + line.row_status = "SNAPSHOT" + line.error_message = None + line.remark = None + db.add(line) + existing_lines[line.id] = line + db.flush() + return existing_lines + + +def _line_diff_type(qty_diff: Decimal, weight_diff: Decimal, *, is_new: bool = False, is_missing: bool = False) -> str: + if is_new: + return "NEW_LOT" + if is_missing: + return "MISSING" + basis = weight_diff if weight_diff != 0 else qty_diff + if basis > 0: + return "GAIN" + if basis < 0: + return "LOSS" + return "MATCH" + + +def _stocktake_summary(lines: list[StocktakeLine]) -> dict[str, int | float]: + summary = { + "match_count": 0, + "gain_count": 0, + "loss_count": 0, + "new_lot_count": 0, + "missing_count": 0, + "error_count": 0, + "diff_amount": 0.0, + } + for line in lines: + key = { + "MATCH": "match_count", + "GAIN": "gain_count", + "LOSS": "loss_count", + "NEW_LOT": "new_lot_count", + "MISSING": "missing_count", + }.get(line.diff_type) + if key: + summary[key] += 1 + if line.row_status == "ERROR": + summary["error_count"] += 1 + summary["diff_amount"] += float(line.diff_amount or 0) + return summary + + +def import_stocktake_workbook(db: Session, stocktake_id: int, content: bytes, user_id: int | None) -> dict[str, object]: + stocktake = db.get(Stocktake, stocktake_id) + if not stocktake: + raise HTTPException(status_code=404, detail="盘库单不存在") + if stocktake.status not in {"LOCKED", "EXPORTED", "IMPORTED"}: + raise HTTPException(status_code=400, detail="当前盘库单状态不允许导入") + stocktake_warehouse = _single_stocktake_warehouse(db, stocktake_id) + + rows = _read_workbook_rows(content, stocktake_warehouse.warehouse_type) + + existing_lines = _reset_stocktake_preview_for_reimport(db, stocktake_id, stocktake_warehouse.warehouse_id) + seen_line_ids: set[int] = set() + next_line_no = max((line.line_no for line in existing_lines.values()), default=0) + 1 + + for row in rows: + _validate_stocktake_row_marker(row, stocktake, stocktake_warehouse) + if _is_empty_stocktake_marker_row(row): + continue + line_id_text = _clean_text(row.get("快照行ID")) + counted_qty = decimal_value(row.get("盘点数量")) + counted_weight = decimal_value(row.get("盘点重量(kg)")) + remark = _clean_text(row.get("盘点备注")) or None + if line_id_text: + line_id = int(float(line_id_text)) + line = existing_lines.get(line_id) + if not line: + raise ValueError(f"快照行ID {line_id} 不存在于当前盘库单") + line.error_message = None + if line_id in seen_line_ids: + line.error_message = "导入清单中该快照行重复" + seen_line_ids.add(line_id) + snapshot_qty = decimal_value(line.snapshot_qty) + snapshot_weight = decimal_value(line.snapshot_weight_kg) + weight_only = stocktake_weight_only(line.warehouse_type, line.item_type) + qty_diff = Decimal("0") if weight_only else counted_qty - snapshot_qty + weight_diff = counted_weight - snapshot_weight + line.counted_qty = Decimal("0") if weight_only else counted_qty + line.counted_weight_kg = counted_weight + line.diff_qty = qty_diff + line.diff_weight_kg = weight_diff + line.diff_amount = (weight_diff * decimal_value(line.snapshot_unit_cost, "0.0001")).quantize(Decimal("0.01")) + line.diff_type = _line_diff_type(qty_diff, weight_diff) + line.row_status = "ERROR" if line.error_message else "READY" + line.remark = remark + db.add(line) + continue + + item_code = _clean_text(row.get("物料编码")) + item = db.scalar(select(Item).where(Item.item_code == item_code).limit(1)) + warehouse = db.get(Warehouse, stocktake_warehouse.warehouse_id) + if not item or not warehouse: + raise ValueError(f"新增批次行物料或仓库不存在:{stocktake_warehouse.warehouse_name} / {item_code}") + unit_cost = decimal_value(row.get("系统单价"), "0.0001") + weight_only = stocktake_weight_only(warehouse.warehouse_type, item.item_type) + db.add( + StocktakeLine( + stocktake_id=stocktake.id, + stocktake_warehouse_id=stocktake_warehouse.id, + line_no=next_line_no, + warehouse_id=warehouse.id, + warehouse_name=warehouse.warehouse_name, + warehouse_type=str(warehouse.warehouse_type or "").upper(), + location_id=None, + location_name=_clean_text(row.get("库位")) or None, + item_id=item.id, + item_code=item.item_code, + item_name=item.item_name, + item_type=item.item_type, + lot_id=None, + lot_no=_clean_text(row.get("批次号")) or None, + source_material_sub_batch_no=_clean_text(row.get("来源库存批次号")) or None, + snapshot_qty=Decimal("0"), + snapshot_weight_kg=Decimal("0"), + snapshot_unit_cost=unit_cost, + counted_qty=Decimal("0") if weight_only else counted_qty, + counted_weight_kg=counted_weight, + diff_qty=Decimal("0") if weight_only else counted_qty, + diff_weight_kg=counted_weight, + diff_amount=(counted_weight * unit_cost).quantize(Decimal("0.01")), + diff_type="NEW_LOT", + row_status="READY", + remark=remark, + ) + ) + next_line_no += 1 + + for line_id, line in existing_lines.items(): + if line_id in seen_line_ids: + continue + line.counted_qty = Decimal("0") + line.counted_weight_kg = Decimal("0") + line.diff_qty = -decimal_value(line.snapshot_qty) + line.diff_weight_kg = -decimal_value(line.snapshot_weight_kg) + line.diff_amount = (decimal_value(line.diff_weight_kg) * decimal_value(line.snapshot_unit_cost, "0.0001")).quantize(Decimal("0.01")) + line.diff_type = "MISSING" + line.row_status = "READY" + line.error_message = None + line.remark = "导入清单缺失,按盘点为0处理" + db.add(line) + + stocktake.status = "IMPORTED" + stocktake.imported_by_user_id = user_id + stocktake.imported_at = current_time() + db.add(stocktake) + db.commit() + + lines = db.scalars( + select(StocktakeLine) + .where(StocktakeLine.stocktake_id == stocktake_id, StocktakeLine.warehouse_id == stocktake_warehouse.warehouse_id) + .order_by(StocktakeLine.line_no.asc()) + ).all() + return {"stocktake": stocktake, "lines": lines, "summary": _stocktake_summary(lines)} + + +def _stocktake_txn_type(diff_type: str) -> str: + return { + "GAIN": "STOCKTAKE_GAIN", + "LOSS": "STOCKTAKE_LOSS", + "MISSING": "STOCKTAKE_LOSS", + "NEW_LOT": "STOCKTAKE_NEW_LOT", + }.get(diff_type, "STOCKTAKE_GAIN") + + +def confirm_stocktake( + db: Session, + stocktake_id: int, + *, + user_id: int | None, + confirm_text: str | None = None, + remark: str | None = None, +) -> Stocktake: + _ = confirm_text + stocktake = db.get(Stocktake, stocktake_id) + if not stocktake: + raise HTTPException(status_code=404, detail="盘库单不存在") + lines = db.scalars(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake_id).order_by(StocktakeLine.line_no.asc())).all() + now = current_time() + if stocktake.status != "IMPORTED": + if stocktake.status in {"LOCKED", "EXPORTED"} and not lines: + stocktake.status = "IMPORTED" + stocktake.imported_by_user_id = user_id + stocktake.imported_at = now + db.add(stocktake) + db.flush() + else: + raise HTTPException(status_code=400, detail="请先导入盘点清单并确认差异后再过账") + error_lines = [line for line in lines if line.row_status == "ERROR"] + if error_lines: + raise HTTPException(status_code=400, detail=f"存在 {len(error_lines)} 行异常,不能确认盘库") + + for line in lines: + if line.diff_type == "MATCH": + continue + weight_only = stocktake_weight_only(line.warehouse_type, line.item_type) + unit_cost = decimal_value(line.snapshot_unit_cost, "0.0001") + qty_change = decimal_value(line.diff_qty) + weight_change = decimal_value(line.diff_weight_kg) + if line.diff_type == "NEW_LOT": + counted_qty = Decimal("0") if weight_only else max(Decimal("0"), decimal_value(line.counted_qty)) + counted_weight = max(Decimal("0"), decimal_value(line.counted_weight_kg)) + lot = StockLot( + lot_no=line.lot_no or f"{stocktake.stocktake_no}-NEW-{line.line_no:04d}", + lot_role="STOCKTAKE_NEW", + item_id=line.item_id, + warehouse_id=line.warehouse_id, + location_id=line.location_id, + source_doc_type="STOCKTAKE", + source_doc_id=stocktake.id, + source_line_id=line.id, + source_material_sub_batch_no=line.source_material_sub_batch_no, + source_material_summary="盘库新增批次", + inbound_qty=counted_qty, + inbound_weight_kg=counted_weight, + remaining_qty=counted_qty, + remaining_weight_kg=counted_weight, + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=unit_cost, + production_date=now.date(), + quality_status="PASS", + status="AVAILABLE", + remark=line.remark or "盘库新增", + ) + db.add(lot) + db.flush() + lot_id = lot.id + before_qty = Decimal("0") + before_weight = Decimal("0") + after_qty = counted_qty + after_weight = counted_weight + qty_change = after_qty + weight_change = after_weight + else: + lot = db.get(StockLot, line.lot_id) + if not lot: + raise HTTPException(status_code=400, detail=f"盘库行 {line.line_no} 对应批次不存在") + before_qty = Decimal("0") if weight_only else decimal_value(lot.remaining_qty) + before_weight = decimal_value(lot.remaining_weight_kg) + after_qty = Decimal("0") if weight_only else max(Decimal("0"), decimal_value(line.counted_qty)) + after_weight = max(Decimal("0"), decimal_value(line.counted_weight_kg)) + qty_change = after_qty - before_qty + weight_change = after_weight - before_weight + lot.remaining_qty = after_qty + lot.remaining_weight_kg = after_weight + lot.status = "AVAILABLE" if after_qty > 0 or after_weight > 0 else "DEPLETED" + db.add(lot) + lot_id = lot.id + + upsert_stock_balance( + db, + item_id=line.item_id, + warehouse_id=line.warehouse_id, + location_id=line.location_id, + qty_delta=qty_change, + weight_delta=weight_change, + available_qty_delta=qty_change, + available_weight_delta=weight_change, + unit_cost=unit_cost, + ) + txn = create_inventory_txn( + db, + txn_type=_stocktake_txn_type(line.diff_type), + item_id=line.item_id, + warehouse_id=line.warehouse_id, + location_id=line.location_id, + lot_id=lot_id, + qty_change=qty_change, + weight_change=weight_change, + unit_cost=unit_cost, + source_doc_type="STOCKTAKE", + source_doc_id=stocktake.id, + source_line_id=line.id, + biz_time=now, + operator_user_id=user_id, + remark=line.remark or remark or "盘库调整", + amount_basis="WEIGHT", + ) + db.flush() + db.add( + StocktakeAdjustment( + stocktake_id=stocktake.id, + stocktake_line_id=line.id, + warehouse_id=line.warehouse_id, + item_id=line.item_id, + lot_id=line.lot_id, + adjustment_lot_id=lot_id, + inventory_txn_id=txn.id, + adjustment_type=line.diff_type, + before_qty=before_qty, + after_qty=after_qty, + before_weight_kg=before_weight, + after_weight_kg=after_weight, + qty_change=qty_change, + weight_change_kg=weight_change, + unit_cost=unit_cost, + amount=(abs(weight_change) * unit_cost).quantize(Decimal("0.01")), + ) + ) + + for warehouse in db.scalars(select(StocktakeWarehouse).where(StocktakeWarehouse.stocktake_id == stocktake_id)).all(): + warehouse.status = "UNLOCKED" + db.add(warehouse) + stocktake.status = "CONFIRMED" + stocktake.confirmed_by_user_id = user_id + stocktake.confirmed_at = now + if remark: + stocktake.remark = remark + db.add(stocktake) + db.commit() + db.refresh(stocktake) + return stocktake + + +def cancel_stocktake(db: Session, stocktake_id: int, *, user_id: int | None, remark: str | None = None) -> Stocktake: + stocktake = db.get(Stocktake, stocktake_id) + if not stocktake: + raise HTTPException(status_code=404, detail="盘库单不存在") + if stocktake.status not in ACTIVE_STOCKTAKE_STATUSES: + raise HTTPException(status_code=400, detail="当前盘库单不能取消") + now = current_time() + for warehouse in db.scalars(select(StocktakeWarehouse).where(StocktakeWarehouse.stocktake_id == stocktake_id)).all(): + warehouse.status = "UNLOCKED" + db.add(warehouse) + stocktake.status = "CANCELED" + stocktake.canceled_by_user_id = user_id + stocktake.canceled_at = now + stocktake.remark = remark or stocktake.remark + db.add(stocktake) + db.commit() + db.refresh(stocktake) + return stocktake + + +def stocktake_lines_for_read(db: Session, stocktake_id: int) -> list[StocktakeLine]: + return db.scalars( + select(StocktakeLine) + .where(StocktakeLine.stocktake_id == stocktake_id) + .order_by(StocktakeLine.warehouse_id.asc(), StocktakeLine.line_no.asc()) + ).all() + + +def stocktake_warehouses_for_read(db: Session, stocktake_id: int) -> list[StocktakeWarehouse]: + return db.scalars( + select(StocktakeWarehouse) + .where(StocktakeWarehouse.stocktake_id == stocktake_id) + .order_by(StocktakeWarehouse.id.asc()) + ).all() diff --git a/backend/app/services/system_config.py b/backend/app/services/system_config.py new file mode 100644 index 0000000..da1316c --- /dev/null +++ b/backend/app/services/system_config.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +import re +from datetime import datetime + +from sqlalchemy import select, update +from sqlalchemy.orm import Session + +from app.models.org import SystemConfig + +RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE = "RAW_MATERIAL_LOT_PREFIX" +RAW_MATERIAL_LOT_PREFIX_DEFAULT = "YL" +SMART_OPERATION_REPORT_CONFIG_CODE = "SMART_OPERATION_REPORT_ENABLED" +MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE = "MINIAPP_OPERATION_REPORT_LAST_SYNCED_AT" +MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_NAME = "小程序报工上次同步成功时间" +MINIAPP_OPERATION_REPORT_LAST_SYNC_FORMAT = "%Y-%m-%d %H:%M:%S" + + +def normalize_raw_material_lot_prefix(value: str | None) -> str: + normalized = re.sub(r"[^A-Z0-9]", "", str(value or "").upper()) + return normalized or RAW_MATERIAL_LOT_PREFIX_DEFAULT + + +def normalize_enabled_config_value(value: object) -> str: + if isinstance(value, bool): + return "开启" if value else "关闭" + normalized = str(value or "").strip().lower() + if normalized in {"开启", "启用", "true", "1", "yes", "on", "enabled"}: + return "开启" + if normalized in {"关闭", "停用", "false", "0", "no", "off", "disabled"}: + return "关闭" + return "开启" + + +def get_system_config_value(db: Session, config_code: str, default: str = "") -> str: + value = db.scalar( + select(SystemConfig.config_value).where( + SystemConfig.config_code == config_code, + SystemConfig.status == "ACTIVE", + ) + ) + return str(value) if value is not None else default + + +def get_raw_material_lot_prefix(db: Session) -> str: + value = get_system_config_value( + db, + RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE, + RAW_MATERIAL_LOT_PREFIX_DEFAULT, + ) + return normalize_raw_material_lot_prefix(value) + + +def get_smart_operation_report_enabled(db: Session) -> bool: + value = get_system_config_value(db, SMART_OPERATION_REPORT_CONFIG_CODE, "开启") + return normalize_enabled_config_value(value) == "开启" + + +def format_miniapp_operation_report_last_sync(value: datetime) -> str: + return value.strftime(MINIAPP_OPERATION_REPORT_LAST_SYNC_FORMAT) + + +def parse_miniapp_operation_report_last_sync(value: str | None) -> datetime | None: + normalized = str(value or "").strip() + if not normalized: + return None + return datetime.strptime(normalized, MINIAPP_OPERATION_REPORT_LAST_SYNC_FORMAT) + + +def get_miniapp_operation_report_last_sync(db: Session) -> datetime | None: + value = get_system_config_value(db, MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE, "") + return parse_miniapp_operation_report_last_sync(value) + + +def upsert_miniapp_operation_report_last_sync( + db: Session, + timestamp: datetime, + *, + updated_by: int | None = None, +) -> SystemConfig: + return upsert_system_config( + db, + config_code=MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE, + config_name=MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_NAME, + config_value=format_miniapp_operation_report_last_sync(timestamp), + remark="工序报工小程序同步 ERP 的上次成功截止时间", + updated_by=updated_by, + now=timestamp, + ) + + +def ensure_miniapp_operation_report_last_sync_config( + db: Session, + *, + updated_by: int | None = None, +) -> SystemConfig: + row = db.scalar(select(SystemConfig).where(SystemConfig.config_code == MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE)) + if row: + return row + timestamp = datetime.now() + row = SystemConfig( + config_code=MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE, + config_name=MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_NAME, + config_value="", + remark="工序报工小程序同步 ERP 的上次成功截止时间", + status="ACTIVE", + created_by=updated_by, + updated_by=updated_by, + created_at=timestamp, + updated_at=timestamp, + ) + db.add(row) + db.flush() + return row + + +def lock_miniapp_operation_report_last_sync(db: Session) -> SystemConfig | None: + ensure_miniapp_operation_report_last_sync_config(db) + return db.scalar( + select(SystemConfig) + .where( + SystemConfig.config_code == MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE, + SystemConfig.status == "ACTIVE", + ) + .with_for_update() + ) + + +def upsert_smart_operation_report_config( + db: Session, + *, + enabled: bool, + updated_by: int | None = None, + remark: str | None = None, + now: datetime | None = None, +) -> SystemConfig: + return upsert_system_config( + db, + config_code=SMART_OPERATION_REPORT_CONFIG_CODE, + config_name="对接智能报工小程序", + config_value=normalize_enabled_config_value(enabled), + remark=remark or "开启后显示工序报工并按小程序报工数据计算生产进度;关闭后按ERP入库数据反推生产进度", + updated_by=updated_by, + now=now, + ) + + +def lock_raw_material_lot_prefix_config(db: Session) -> None: + """Serialize lot-number generation on databases that support row locks.""" + db.scalar( + select(SystemConfig.id) + .where( + SystemConfig.config_code == RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE, + SystemConfig.status == "ACTIVE", + ) + .with_for_update() + ) + + +def upsert_system_config( + db: Session, + *, + config_code: str, + config_name: str, + config_value: str, + remark: str | None = None, + updated_by: int | None = None, + now: datetime | None = None, +) -> SystemConfig: + timestamp = now or datetime.now() + saved_value = ( + normalize_raw_material_lot_prefix(config_value) + if config_code == RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE + else str(config_value or "").strip() + ) + row = db.scalar(select(SystemConfig).where(SystemConfig.config_code == config_code)) + if not row: + row = SystemConfig( + config_code=config_code, + created_by=updated_by, + created_at=timestamp, + ) + + row.config_name = config_name + row.config_value = saved_value + row.remark = remark + row.status = "ACTIVE" + row.updated_by = updated_by + row.updated_at = timestamp + db.add(row) + db.flush() + return row + + # Use a Core update so caller-supplied business timestamps are not overwritten by + # the global ORM before_update audit hook. + db.execute( + update(SystemConfig) + .where(SystemConfig.id == row.id) + .values( + config_name=config_name, + config_value=saved_value, + remark=remark, + status="ACTIVE", + updated_by=updated_by, + updated_at=timestamp, + ) + ) + db.flush() + db.refresh(row) + return row diff --git a/backend/app/services/system_permissions.py b/backend/app/services/system_permissions.py new file mode 100644 index 0000000..e9df3eb --- /dev/null +++ b/backend/app/services/system_permissions.py @@ -0,0 +1,905 @@ +from __future__ import annotations + +import re +from datetime import datetime + +from fastapi import HTTPException +from sqlalchemy import and_, delete, exists, func, or_, select +from sqlalchemy.orm import Session + +from app.models.org import ( + Department, + Employee, + OrgEmployeeBinding, + OrgManagerBinding, + Permission, + Role, + RolePermission, + User, + UserRole, +) +from app.schemas.system_permissions import ( + EmployeeOption, + MenuPermissionNode, + OrgEmployeeNode, + OrgEmployeeBind, + OrgNodeCreate, + OrgNodeRead, + OrgNodeUpdate, + PermissionRead, + RoleCreate, + RoleRead, + RoleUpdate, + SystemUserCreate, + SystemUserRead, +) +from app.services.auth import hash_password + + +MENU_PERMISSION_TREE = [ + ("MENU_DASHBOARD", "经营驾驶舱", []), + ( + "MENU_MASTER_DATA", + "基础资料", + [ + ("MENU_SUPPLIER_MANAGEMENT", "供应商名录", []), + ("MENU_MATERIAL_MANAGEMENT", "原材料名录", []), + ("MENU_SALES_PLANNING", "客户名录/销售订单", []), + ("MENU_PRODUCT_MANAGEMENT", "产品管理", []), + ("MENU_BOM_MANAGEMENT", "BOM管理", []), + ("MENU_ROUTE_MANAGEMENT", "工艺路线管理", []), + ("MENU_EMPLOYEE_MANAGEMENT", "人员管理", []), + ], + ), + ( + "MENU_PROCUREMENT", + "采购与库存", + [ + ("MENU_PURCHASE_ORDER", "采购订单", []), + ("MENU_PURCHASE_RECEIPT", "到货入库", []), + ("MENU_QUALITY_INSPECTION", "质量检验", []), + ("MENU_INVENTORY_LEDGER", "嘉恒仓库", []), + ], + ), + ( + "MENU_PRODUCTION", + "生产执行", + [ + ("MENU_WORK_ORDER", "工单台账", []), + ("MENU_OPERATION_REPORT", "工序报工", []), + ("MENU_EQUIPMENT_MANAGEMENT", "设备管理", []), + ], + ), + ("MENU_DELIVERY_MANAGEMENT", "发货概览", []), + ("MENU_FINANCE_REPORT", "财务分析", []), + ( + "MENU_SYSTEM_MANAGEMENT", + "系统管理", + [ + ("MENU_SYSTEM_PERMISSION", "系统权限管理", []), + ("MENU_SYSTEM_EXTENSION", "系统拓展", []), + ], + ), +] + +ORG_ROOT_CODE = "ORG_ROOT" +ORG_ROOT_NAME = "总公司" +VALID_CHILD_NODE_TYPES = { + "COMPANY": {"BRANCH": "总公司下只能新增分公司"}, + "BRANCH": {"DEPARTMENT": "分公司下只能新增部门"}, + "DEPARTMENT": {"GROUP": "部门下只能新增小组"}, + "GROUP": {}, +} +ORG_NODE_TYPE_LABELS = { + "COMPANY": "总公司", + "BRANCH": "分公司", + "DEPARTMENT": "部门", + "GROUP": "小组", +} + + +def _now() -> datetime: + return datetime.now() + + +def _normalize_phone(value: str | None) -> str: + return re.sub(r"\s+", "", str(value or "").strip()) + + +def _employee_name_sort_key(employee: Employee) -> tuple[str, int]: + surname_order = { + "王": "wang", + "李": "li", + "张": "zhang", + "刘": "liu", + "陈": "chen", + "杨": "yang", + "赵": "zhao", + "黄": "huang", + "周": "zhou", + "吴": "wu", + "徐": "xu", + "孙": "sun", + "胡": "hu", + "朱": "zhu", + "高": "gao", + "林": "lin", + "何": "he", + "郭": "guo", + "马": "ma", + "罗": "luo", + } + name = employee.employee_name or "" + return (f"{surname_order.get(name[:1], name[:1])}{name[1:]}", employee.id) + + +def _menu_node(raw: tuple[str, str, list]) -> MenuPermissionNode: + code, name, children = raw + return MenuPermissionNode( + permission_code=code, + permission_name=name, + children=[_menu_node(child) for child in children], + ) + + +def list_menu_permission_tree() -> list[MenuPermissionNode]: + return [_menu_node(item) for item in MENU_PERMISSION_TREE] + + +def list_permissions(db: Session) -> list[PermissionRead]: + rows = db.scalars(select(Permission).order_by(Permission.module_code, Permission.id)).all() + return [ + PermissionRead( + permission_id=row.id, + permission_code=row.permission_code, + permission_name=row.permission_name, + module_code=row.module_code, + action_code=row.action_code, + status=row.status, + ) + for row in rows + ] + + +def _employee_role_map(db: Session, employee_ids: list[int]) -> tuple[dict[int, str], dict[int, list[str]]]: + if not employee_ids: + return {}, {} + 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() + username_map: dict[int, str] = {} + role_map: dict[int, list[str]] = {} + for employee_id, username, role_name in rows: + if employee_id is None: + continue + username_map.setdefault(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 username_map, role_map + + +def _ensure_org_root(db: Session) -> Department: + root = db.scalar(select(Department).where(Department.dept_code == ORG_ROOT_CODE)) + if root: + return root + + now = _now() + root = Department( + dept_code=ORG_ROOT_CODE, + dept_name=ORG_ROOT_NAME, + parent_id=None, + org_node_type="COMPANY", + dept_type="ADMIN", + manager_name=None, + manager_employee_id=None, + status="ACTIVE", + sort_no=0, + remark="系统权限管理组织脑图根节点", + created_at=now, + updated_at=now, + ) + db.add(root) + db.commit() + db.refresh(root) + return root + + +def _managed_department_ids(root: Department, departments: list[Department]) -> set[int]: + children_by_parent: dict[int | None, list[Department]] = {} + for dept in departments: + children_by_parent.setdefault(dept.parent_id, []).append(dept) + + result: set[int] = set() + + def walk(node_id: int) -> None: + result.add(node_id) + for child in children_by_parent.get(node_id, []): + walk(child.id) + + walk(root.id) + return result + + +def _bound_employees_by_dept(db: Session, dept_ids: set[int]) -> dict[int, list[OrgEmployeeNode]]: + if not dept_ids: + return {} + rows = db.execute( + select(OrgEmployeeBinding, Employee) + .join(Employee, Employee.id == OrgEmployeeBinding.employee_id) + .where( + OrgEmployeeBinding.dept_id.in_(dept_ids), + OrgEmployeeBinding.status == "ACTIVE", + Employee.status == "ACTIVE", + ) + .order_by(OrgEmployeeBinding.dept_id, OrgEmployeeBinding.sort_no, OrgEmployeeBinding.id) + ).all() + employees = [employee for _, employee in rows] + username_map, role_map = _employee_role_map(db, [employee.id for employee in employees]) + employees_by_dept: dict[int, list[OrgEmployeeNode]] = {} + for binding, employee in rows: + employees_by_dept.setdefault(binding.dept_id, []).append( + OrgEmployeeNode( + employee_id=employee.id, + employee_name=employee.employee_name, + mobile=employee.mobile, + job_title=employee.job_title, + username=username_map.get(employee.id), + role_names=role_map.get(employee.id, []), + status=employee.status, + ) + ) + return employees_by_dept + + +def _active_employees_for_ids(db: Session, employee_ids: list[int]) -> list[Employee]: + unique_ids = list(dict.fromkeys(employee_ids)) + if not unique_ids: + return [] + rows = db.scalars(select(Employee).where(Employee.id.in_(unique_ids), Employee.status == "ACTIVE")).all() + employee_map = {employee.id: employee for employee in rows} + missing = [employee_id for employee_id in unique_ids if employee_id not in employee_map] + if missing: + raise HTTPException(status_code=400, detail=f"主管不存在或已停用:{missing}") + return [employee_map[employee_id] for employee_id in unique_ids] + + +def _manager_payload_ids(payload: OrgNodeCreate | OrgNodeUpdate) -> list[int]: + if payload.manager_employee_ids: + return list(dict.fromkeys(payload.manager_employee_ids)) + return [payload.manager_employee_id] if payload.manager_employee_id else [] + + +def _replace_org_managers(db: Session, dept: Department, manager_employee_ids: list[int]) -> None: + managers = _active_employees_for_ids(db, manager_employee_ids) + db.execute(delete(OrgManagerBinding).where(OrgManagerBinding.dept_id == dept.id)) + now = _now() + for index, manager in enumerate(managers, start=1): + db.add( + OrgManagerBinding( + dept_id=dept.id, + employee_id=manager.id, + sort_no=index, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + ) + dept.manager_employee_id = managers[0].id if managers else None + dept.manager_name = "、".join(manager.employee_name for manager in managers) if managers else None + + +def _manager_bindings_by_dept( + db: Session, + dept_ids: set[int], + departments: list[Department], +) -> dict[int, tuple[list[int], list[str]]]: + if not dept_ids: + return {} + rows = db.execute( + select(OrgManagerBinding, Employee) + .join(Employee, Employee.id == OrgManagerBinding.employee_id) + .where( + OrgManagerBinding.dept_id.in_(dept_ids), + OrgManagerBinding.status == "ACTIVE", + Employee.status == "ACTIVE", + ) + .order_by(OrgManagerBinding.dept_id, OrgManagerBinding.sort_no, OrgManagerBinding.id) + ).all() + result: dict[int, tuple[list[int], list[str]]] = {} + for binding, employee in rows: + manager_ids, manager_names = result.setdefault(binding.dept_id, ([], [])) + manager_ids.append(employee.id) + manager_names.append(employee.employee_name) + + legacy_ids = [dept.manager_employee_id for dept in departments if dept.id in dept_ids and dept.manager_employee_id] + legacy_employees = ( + {employee.id: employee for employee in db.scalars(select(Employee).where(Employee.id.in_(legacy_ids))).all()} + if legacy_ids + else {} + ) + for dept in departments: + if dept.id not in dept_ids or dept.id in result: + continue + if dept.manager_employee_id: + employee = legacy_employees.get(dept.manager_employee_id) + result[dept.id] = ( + [dept.manager_employee_id], + [employee.employee_name if employee else dept.manager_name or "未命名主管"], + ) + elif dept.manager_name: + result[dept.id] = ([], [dept.manager_name]) + return result + + +def build_org_tree(db: Session) -> list[OrgNodeRead]: + root = _ensure_org_root(db) + departments = db.scalars( + select(Department).order_by(Department.parent_id.is_not(None), Department.sort_no, Department.id) + ).all() + managed_ids = _managed_department_ids(root, departments) + departments = [dept for dept in departments if dept.id in managed_ids] + employees_by_dept = _bound_employees_by_dept(db, managed_ids) + managers_by_dept = _manager_bindings_by_dept(db, managed_ids, departments) + + nodes: dict[int, OrgNodeRead] = { + dept.id: OrgNodeRead( + node_id=dept.id, + node_type=getattr(dept, "org_node_type", None) or "DEPARTMENT", + node_label=dept.dept_name, + parent_id=dept.parent_id, + dept_code=dept.dept_code, + dept_type=dept.dept_type, + manager_employee_id=(managers_by_dept.get(dept.id, ([], []))[0] or [None])[0], + manager_name="、".join(managers_by_dept.get(dept.id, ([], []))[1]) or None, + manager_employee_ids=managers_by_dept.get(dept.id, ([], []))[0], + manager_names=managers_by_dept.get(dept.id, ([], []))[1], + status=dept.status, + sort_no=dept.sort_no, + remark=dept.remark, + employees=employees_by_dept.get(dept.id, []), + children=[], + ) + for dept in departments + } + roots: list[OrgNodeRead] = [] + for dept in departments: + node = nodes[dept.id] + if dept.parent_id and dept.parent_id in nodes: + nodes[dept.parent_id].children.append(node) + elif dept.id == root.id: + roots.append(node) + return roots + + +def _flatten_org_nodes(nodes: list[OrgNodeRead]) -> list[OrgNodeRead]: + result: list[OrgNodeRead] = [] + for node in nodes: + result.append(node) + result.extend(_flatten_org_nodes(node.children)) + return result + + +def _next_department_code(db: Session, node_type: str) -> str: + prefix = {"COMPANY": "COMP", "BRANCH": "BR", "DEPARTMENT": "DEPT", "GROUP": "GRP"}.get(node_type, "DEPT") + count = db.scalar(select(func.count(Department.id)).where(Department.dept_code.like(f"{prefix}-%"))) or 0 + return f"{prefix}-{int(count) + 1:03d}" + + +def _manager_name(db: Session, employee_id: int | None) -> str | None: + if not employee_id: + return None + employee = db.get(Employee, employee_id) + if not employee or employee.status != "ACTIVE": + raise HTTPException(status_code=400, detail="负责人不存在或已停用") + return employee.employee_name + + +def create_org_node(db: Session, payload: OrgNodeCreate) -> OrgNodeRead: + if not payload.parent_id: + if payload.node_type != "COMPANY": + raise HTTPException(status_code=400, detail="请选择上级组织节点") + parent = db.get(Department, payload.parent_id) if payload.parent_id else None + if payload.parent_id and not parent: + raise HTTPException(status_code=404, detail="上级组织节点不存在") + if parent: + parent_type = getattr(parent, "org_node_type", None) or "DEPARTMENT" + allowed = VALID_CHILD_NODE_TYPES.get(parent_type, {}) + if payload.node_type not in allowed: + message = next(iter(allowed.values()), f"{ORG_NODE_TYPE_LABELS.get(parent_type, '当前节点')}下不能新增组织节点") + raise HTTPException(status_code=400, detail=message) + dept_code = payload.dept_code or _next_department_code(db, payload.node_type) + if db.scalar(select(Department).where(Department.dept_code == dept_code)): + raise HTTPException(status_code=400, detail="组织编码已存在") + now = _now() + row = Department( + dept_code=dept_code, + dept_name=payload.node_label, + parent_id=payload.parent_id, + org_node_type=payload.node_type, + dept_type=payload.dept_type, + manager_name=None, + manager_employee_id=None, + status=payload.status, + sort_no=payload.sort_no, + remark=payload.remark, + created_at=now, + updated_at=now, + ) + db.add(row) + db.flush() + _replace_org_managers(db, row, _manager_payload_ids(payload)) + db.add(row) + db.commit() + return next(node for node in _flatten_org_nodes(build_org_tree(db)) if node.node_id == row.id) + + +def update_org_node(db: Session, node_id: int, payload: OrgNodeUpdate) -> OrgNodeRead: + row = db.get(Department, node_id) + if not row: + raise HTTPException(status_code=404, detail="组织节点不存在") + row.dept_name = payload.node_label + row.dept_type = payload.dept_type + _replace_org_managers(db, row, _manager_payload_ids(payload)) + row.sort_no = payload.sort_no + row.remark = payload.remark + row.status = payload.status + row.updated_at = _now() + db.add(row) + db.commit() + return next(node for node in _flatten_org_nodes(build_org_tree(db)) if node.node_id == row.id) + + +def delete_org_node(db: Session, node_id: int) -> None: + row = db.get(Department, node_id) + if not row: + raise HTTPException(status_code=404, detail="组织节点不存在") + child_count = db.scalar(select(func.count(Department.id)).where(Department.parent_id == node_id)) or 0 + if int(child_count) > 0: + raise HTTPException(status_code=400, detail="该节点存在下级节点,请先从底层开始删除") + employee_count = ( + db.scalar( + select(func.count(OrgEmployeeBinding.id)).where( + OrgEmployeeBinding.dept_id == node_id, + OrgEmployeeBinding.status == "ACTIVE", + ) + ) + or 0 + ) + if int(employee_count) > 0: + raise HTTPException(status_code=400, detail="该节点存在人员节点,请先移除人员") + if row.dept_code == ORG_ROOT_CODE: + raise HTTPException(status_code=400, detail="总公司根节点不能删除") + db.execute(delete(OrgManagerBinding).where(OrgManagerBinding.dept_id == node_id)) + db.delete(row) + db.commit() + + +def bind_org_employee(db: Session, node_id: int, payload: OrgEmployeeBind) -> OrgNodeRead: + node = db.get(Department, node_id) + if not node: + raise HTTPException(status_code=404, detail="组织节点不存在") + node_type = getattr(node, "org_node_type", None) or "DEPARTMENT" + if node_type not in {"DEPARTMENT", "GROUP"}: + raise HTTPException(status_code=400, detail="只有部门或小组节点可以新增人员") + employee = db.get(Employee, payload.employee_id) + if not employee or employee.status != "ACTIVE": + raise HTTPException(status_code=400, detail="人员不存在或已停用") + + existing = db.scalar( + select(OrgEmployeeBinding).where( + OrgEmployeeBinding.dept_id == node_id, + OrgEmployeeBinding.employee_id == payload.employee_id, + ) + ) + now = _now() + if existing: + existing.status = "ACTIVE" + existing.sort_no = payload.sort_no + existing.updated_at = now + db.add(existing) + else: + db.add( + OrgEmployeeBinding( + dept_id=node_id, + employee_id=payload.employee_id, + sort_no=payload.sort_no, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + ) + db.commit() + return next(node for node in _flatten_org_nodes(build_org_tree(db)) if node.node_id == node_id) + + +def unbind_org_employee(db: Session, node_id: int, employee_id: int) -> OrgNodeRead: + binding = db.scalar( + select(OrgEmployeeBinding).where( + OrgEmployeeBinding.dept_id == node_id, + OrgEmployeeBinding.employee_id == employee_id, + OrgEmployeeBinding.status == "ACTIVE", + ) + ) + if not binding: + raise HTTPException(status_code=404, detail="人员节点不存在") + db.delete(binding) + db.commit() + return next(node for node in _flatten_org_nodes(build_org_tree(db)) if node.node_id == node_id) + + +def list_roles(db: Session) -> list[RoleRead]: + roles = db.scalars(select(Role).order_by(Role.id)).all() + if not roles: + return [] + role_ids = [role.id for role in roles] + permission_rows = db.execute( + select(RolePermission.role_id, Permission.permission_code) + .join(Permission, Permission.id == RolePermission.permission_id) + .where(RolePermission.role_id.in_(role_ids)) + .order_by(RolePermission.role_id, Permission.permission_code) + ).all() + user_counts = dict( + db.execute( + select(UserRole.role_id, func.count(UserRole.user_id)) + .where(UserRole.role_id.in_(role_ids)) + .group_by(UserRole.role_id) + ).all() + ) + permission_map: dict[int, list[str]] = {} + for role_id, code in permission_rows: + permission_map.setdefault(role_id, []).append(code) + return [ + RoleRead( + role_id=role.id, + role_code=role.role_code, + role_name=role.role_name, + role_scope=role.role_scope, + status=role.status, + remark=role.remark, + permission_codes=permission_map.get(role.id, []), + user_count=int(user_counts.get(role.id, 0)), + created_at=role.created_at, + updated_at=role.updated_at, + ) + for role in roles + ] + + +def _replace_role_permissions(db: Session, role_id: int, permission_codes: list[str]) -> None: + permissions = _permissions_for_codes(db, permission_codes) + db.execute(delete(RolePermission).where(RolePermission.role_id == role_id)) + now = _now() + for permission in permissions: + db.add(RolePermission(role_id=role_id, permission_id=permission.id, created_at=now)) + + +def _permissions_for_codes(db: Session, permission_codes: list[str]) -> list[Permission]: + unique_codes = list(dict.fromkeys(permission_codes)) + permissions = ( + db.scalars(select(Permission).where(Permission.permission_code.in_(unique_codes))).all() + if unique_codes + else [] + ) + missing = sorted(set(unique_codes) - {row.permission_code for row in permissions}) + if missing: + raise HTTPException(status_code=400, detail=f"权限不存在:{'、'.join(missing)}") + return permissions + + +def create_role(db: Session, payload: RoleCreate) -> RoleRead: + if db.scalar(select(Role).where(Role.role_code == payload.role_code)): + raise HTTPException(status_code=400, detail="角色编码已存在") + _permissions_for_codes(db, payload.permission_codes) + now = _now() + row = Role( + role_code=payload.role_code, + role_name=payload.role_name, + role_scope=payload.role_scope, + status=payload.status, + remark=payload.remark, + created_at=now, + updated_at=now, + ) + db.add(row) + db.flush() + _replace_role_permissions(db, row.id, payload.permission_codes) + db.commit() + return next(role for role in list_roles(db) if role.role_id == row.id) + + +def update_role(db: Session, role_id: int, payload: RoleUpdate) -> RoleRead: + row = db.get(Role, role_id) + if not row: + raise HTTPException(status_code=404, detail="角色不存在") + _permissions_for_codes(db, payload.permission_codes) + row.role_name = payload.role_name + row.role_scope = payload.role_scope + row.status = payload.status + row.remark = payload.remark + row.updated_at = _now() + db.add(row) + _replace_role_permissions(db, row.id, payload.permission_codes) + db.commit() + return next(role for role in list_roles(db) if role.role_id == row.id) + + +def _system_user_read(db: Session, user: User) -> SystemUserRead: + employee = db.get(Employee, user.employee_id) + department = db.get(Department, user.dept_id) + role_rows = db.execute( + select(Role.id, Role.role_name) + .join(UserRole, UserRole.role_id == Role.id) + .where(UserRole.user_id == user.id) + .order_by(Role.id) + ).all() + return SystemUserRead( + user_id=user.id, + username=user.username, + employee_id=user.employee_id, + employee_name=employee.employee_name if employee else user.nickname or user.username, + dept_id=user.dept_id, + dept_name=department.dept_name if department else None, + mobile=employee.mobile if employee else None, + nickname=user.nickname, + is_super_admin=bool(user.is_super_admin), + status=user.status, + role_ids=[row[0] for row in role_rows], + role_names=[row[1] for row in role_rows], + last_login_at=user.last_login_at, + created_at=user.created_at, + updated_at=user.updated_at, + ) + + +def list_system_users(db: Session) -> list[SystemUserRead]: + rows = db.scalars(select(User).order_by(User.status, User.id)).all() + return [_system_user_read(db, row) for row in rows] + + +def _permission_by_code(db: Session, permission_code: str) -> Permission: + permission = db.scalar( + select(Permission).where( + Permission.permission_code == permission_code, + Permission.status == "ACTIVE", + ) + ) + if not permission: + raise HTTPException(status_code=400, detail=f"权限码不存在或已停用:{permission_code}") + return permission + + +def _employee_role_permission_maps( + db: Session, + employee_ids: list[int], +) -> tuple[dict[int, User], dict[int, list[str]], dict[int, list[str]]]: + if not employee_ids: + return {}, {}, {} + users = db.scalars( + select(User) + .where( + User.employee_id.in_(employee_ids), + User.status == "ACTIVE", + ) + .order_by(User.id) + ).all() + user_by_employee_id = {user.employee_id: user for user in users} + if not users: + return {}, {}, {} + rows = db.execute( + select(User.employee_id, Role.role_name, Permission.permission_code) + .join(UserRole, UserRole.user_id == User.id) + .join(Role, Role.id == UserRole.role_id) + .outerjoin(RolePermission, RolePermission.role_id == Role.id) + .outerjoin( + Permission, + and_(Permission.id == RolePermission.permission_id, Permission.status == "ACTIVE"), + ) + .where( + User.id.in_([user.id for user in users]), + Role.status == "ACTIVE", + ) + .order_by(User.employee_id, Role.id, Permission.permission_code) + ).all() + role_map: dict[int, list[str]] = {} + permission_map: dict[int, list[str]] = {} + for employee_id, role_name, permission_code in rows: + if role_name and role_name not in role_map.setdefault(employee_id, []): + role_map[employee_id].append(role_name) + if permission_code and permission_code not in permission_map.setdefault(employee_id, []): + permission_map[employee_id].append(permission_code) + return user_by_employee_id, role_map, permission_map + + +def employee_has_permission(db: Session, employee_id: int, permission_code: str) -> bool: + permission = _permission_by_code(db, permission_code) + user = db.scalar( + select(User).where( + User.employee_id == employee_id, + User.status == "ACTIVE", + ) + ) + if not user: + return False + employee = db.get(Employee, employee_id) + if not employee or employee.status != "ACTIVE": + return False + if bool(user.is_super_admin): + return True + return bool( + db.scalar( + select( + exists() + .where(UserRole.user_id == user.id) + .where(UserRole.role_id == Role.id) + .where(Role.status == "ACTIVE") + .where(RolePermission.role_id == Role.id) + .where(RolePermission.permission_id == permission.id) + ) + ) + ) + + +def ensure_employee_has_permission( + db: Session, + employee_id: int | None, + permission_code: str, + field_label: str, + *, + required: bool = False, +) -> None: + if employee_id is None: + if required: + raise HTTPException(status_code=400, detail=f"{field_label}必填") + return + if employee_id <= 0: + raise HTTPException(status_code=400, detail=f"{field_label}不存在或已停用") + permission = _permission_by_code(db, permission_code) + employee = db.get(Employee, employee_id) + if not employee or employee.status != "ACTIVE": + raise HTTPException(status_code=400, detail=f"{field_label}不存在或已停用") + if not employee_has_permission(db, employee_id, permission_code): + raise HTTPException( + status_code=400, + detail=f"{field_label}必须选择有“{permission.permission_name}”权限的人员", + ) + + +def list_employee_options( + db: Session, + permission_code: str | None = None, + limit: int = 500, +) -> list[EmployeeOption]: + permission = _permission_by_code(db, permission_code) if permission_code else None + stmt = select(Employee).where(Employee.status == "ACTIVE") + if permission: + permitted_user_exists = ( + exists() + .where(User.employee_id == Employee.id) + .where(User.status == "ACTIVE") + .where( + or_( + User.is_super_admin == 1, + exists() + .where(UserRole.user_id == User.id) + .where(UserRole.role_id == Role.id) + .where(Role.status == "ACTIVE") + .where(RolePermission.role_id == Role.id) + .where(RolePermission.permission_id == permission.id), + ) + ) + ) + stmt = stmt.where(permitted_user_exists) + employees = sorted( + db.scalars(stmt.order_by(Employee.id)).all(), + key=_employee_name_sort_key, + )[:limit] + dept_map = {row.id: row.dept_name for row in db.scalars(select(Department)).all()} + employee_ids = [employee.id for employee in employees] + user_map, role_map, permission_map = _employee_role_permission_maps(db, employee_ids) + return [ + EmployeeOption( + employee_id=employee.id, + employee_name=employee.employee_name, + dept_id=employee.dept_id, + dept_name=dept_map.get(employee.dept_id), + mobile=employee.mobile, + job_title=employee.job_title, + username=user_map[employee.id].username if employee.id in user_map else None, + role_names=role_map.get(employee.id, []), + permission_codes=permission_map.get(employee.id, []), + has_system_user=employee.id in user_map, + ) + for employee in employees + ] + + +def _replace_user_roles(db: Session, user_id: int, role_ids: list[int]) -> None: + roles = _roles_for_ids(db, role_ids) + db.execute(delete(UserRole).where(UserRole.user_id == user_id)) + now = _now() + for role in roles: + db.add(UserRole(user_id=user_id, role_id=role.id, created_at=now)) + + +def _roles_for_ids(db: Session, role_ids: list[int]) -> list[Role]: + unique_role_ids = list(dict.fromkeys(role_ids)) + roles = db.scalars(select(Role).where(Role.id.in_(unique_role_ids), Role.status == "ACTIVE")).all() + missing = sorted(set(unique_role_ids) - {row.id for row in roles}) + if missing: + raise HTTPException(status_code=400, detail=f"角色不存在或已停用:{missing}") + return roles + + +def create_system_user(db: Session, payload: SystemUserCreate) -> SystemUserRead: + employee = db.get(Employee, payload.employee_id) + if not employee or employee.status != "ACTIVE": + raise HTTPException(status_code=400, detail="人员不存在或已停用") + username = _normalize_phone(employee.mobile) + if not username: + raise HTTPException(status_code=400, detail="人员没有电话号码,不能创建系统账号") + if db.scalar(select(User).where(User.employee_id == employee.id)): + raise HTTPException(status_code=400, detail="该人员已经有系统账号") + if db.scalar(select(User).where(User.username == username)): + raise HTTPException(status_code=400, detail="该电话号码已经作为系统账号存在") + _roles_for_ids(db, payload.role_ids) + now = _now() + user = User( + username=username, + password_hash=hash_password(payload.password), + employee_id=employee.id, + dept_id=employee.dept_id, + nickname=employee.employee_name, + email=None, + is_super_admin=0, + last_login_at=None, + status=payload.status, + created_at=now, + updated_at=now, + ) + db.add(user) + db.flush() + _replace_user_roles(db, user.id, payload.role_ids) + db.commit() + return _system_user_read(db, user) + + +def update_system_user_roles(db: Session, user_id: int, role_ids: list[int]) -> SystemUserRead: + user = db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="系统用户不存在") + _replace_user_roles(db, user.id, role_ids) + user.updated_at = _now() + db.add(user) + db.commit() + return _system_user_read(db, user) + + +def update_system_user_status(db: Session, user_id: int, status: str, current_user_id: int) -> SystemUserRead: + user = db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="系统用户不存在") + if user.id == current_user_id and status != "ACTIVE": + raise HTTPException(status_code=400, detail="不能停用当前登录账号") + user.status = status + user.updated_at = _now() + db.add(user) + db.commit() + return _system_user_read(db, user) + + +def reset_system_user_password(db: Session, user_id: int, password: str) -> SystemUserRead: + user = db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="系统用户不存在") + user.password_hash = hash_password(password) + user.updated_at = _now() + db.add(user) + db.commit() + return _system_user_read(db, user) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..285486a --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,10 @@ +fastapi>=0.115,<1.0 +httpx>=0.27,<1.0 +uvicorn[standard]>=0.30,<1.0 +sqlalchemy>=2.0,<3.0 +pymysql>=1.1,<2.0 +pydantic-settings>=2.0,<3.0 +cryptography>=42.0,<46.0 +openpyxl>=3.1,<4.0 +python-multipart>=0.0.9,<1.0 +reportlab>=4.2,<5.0 diff --git a/backend/sql/add_document_archives_patch.sql b/backend/sql/add_document_archives_patch.sql new file mode 100644 index 0000000..2405c9c --- /dev/null +++ b/backend/sql/add_document_archives_patch.sql @@ -0,0 +1,26 @@ +USE jiaheng_erp; + +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS document_archives ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '归档记录ID', + document_type VARCHAR(50) NOT NULL COMMENT '单据类型:销售订单/采购订单', + business_id BIGINT NOT NULL COMMENT '业务单据ID', + document_no VARCHAR(100) NOT NULL COMMENT '业务单据编号', + archive_version INT NOT NULL COMMENT '归档版本号', + template_version VARCHAR(50) NOT NULL COMMENT '模板版本', + file_format VARCHAR(20) NOT NULL COMMENT '文件格式', + file_name VARCHAR(255) NOT NULL COMMENT '文件名', + file_path VARCHAR(500) NOT NULL COMMENT '文件路径', + file_hash VARCHAR(128) NULL COMMENT '文件哈希', + status VARCHAR(32) NOT NULL COMMENT '归档状态:未生成/已归档/归档失败', + error_message TEXT NULL COMMENT '归档失败原因', + created_by BIGINT NULL COMMENT '创建人用户ID', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_document_archives_version (document_type, business_id, archive_version, file_format), + KEY idx_document_archives_business_latest (document_type, business_id, archive_version), + KEY idx_document_archives_document_no (document_no), + KEY idx_document_archives_status (status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='单据PDF归档记录'; diff --git a/backend/sql/add_equipment_occupied_area_patch.sql b/backend/sql/add_equipment_occupied_area_patch.sql new file mode 100644 index 0000000..0d5fcc1 --- /dev/null +++ b/backend/sql/add_equipment_occupied_area_patch.sql @@ -0,0 +1,32 @@ +SET @equipment_area_column_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'em_equipment' + AND COLUMN_NAME = 'occupied_area_sqm' +); + +SET @add_equipment_area_column_sql := IF( + @equipment_area_column_exists = 0, + 'ALTER TABLE em_equipment ADD COLUMN occupied_area_sqm DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT ''占用场地面积(平方米)'' AFTER rated_power_kw', + 'SELECT ''em_equipment.occupied_area_sqm already exists''' +); + +PREPARE add_equipment_area_column_stmt FROM @add_equipment_area_column_sql; +EXECUTE add_equipment_area_column_stmt; +DEALLOCATE PREPARE add_equipment_area_column_stmt; + +ALTER TABLE fi_overhead_entry + MODIFY COLUMN allocation_basis VARCHAR(32) NOT NULL DEFAULT 'WORK_HOURS' COMMENT '分摊依据:WORK_HOURS/MACHINE_HOURS/QTY/OCCUPIED_AREA'; + +UPDATE em_equipment +SET occupied_area_sqm = CASE equipment_code + WHEN 'EQ-KP-01' THEN 12.50 + WHEN 'EQ-CY-01' THEN 16.00 + WHEN 'EQ-MC-01' THEN 8.00 + WHEN 'EQ-ZW-01' THEN 14.00 + WHEN 'EQ-HJ-01' THEN 6.00 + WHEN 'EQ-QA-01' THEN 4.00 + ELSE occupied_area_sqm +END +WHERE equipment_code IN ('EQ-KP-01', 'EQ-CY-01', 'EQ-MC-01', 'EQ-ZW-01', 'EQ-HJ-01', 'EQ-QA-01'); diff --git a/backend/sql/add_logistics_fields_patch.sql b/backend/sql/add_logistics_fields_patch.sql new file mode 100644 index 0000000..b231385 --- /dev/null +++ b/backend/sql/add_logistics_fields_patch.sql @@ -0,0 +1,80 @@ +SET @sql := IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wh_stock_lot' AND COLUMN_NAME = 'logistics_waybill_no') = 0, + 'ALTER TABLE wh_stock_lot ADD COLUMN logistics_waybill_no VARCHAR(100) NULL COMMENT ''运单号'' AFTER source_material_summary', + 'SELECT ''wh_stock_lot.logistics_waybill_no already exists''' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql := IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wh_stock_lot' AND COLUMN_NAME = 'logistics_freight_amount') = 0, + 'ALTER TABLE wh_stock_lot ADD COLUMN logistics_freight_amount DECIMAL(18,2) NULL COMMENT ''运费'' AFTER logistics_waybill_no', + 'SELECT ''wh_stock_lot.logistics_freight_amount already exists''' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql := IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wh_stock_lot' AND COLUMN_NAME = 'logistics_photo_url') = 0, + 'ALTER TABLE wh_stock_lot ADD COLUMN logistics_photo_url VARCHAR(255) NULL COMMENT ''订单照片路径'' AFTER logistics_freight_amount', + 'SELECT ''wh_stock_lot.logistics_photo_url already exists''' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql := IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wh_inventory_txn' AND COLUMN_NAME = 'logistics_waybill_no') = 0, + 'ALTER TABLE wh_inventory_txn ADD COLUMN logistics_waybill_no VARCHAR(100) NULL COMMENT ''运单号'' AFTER source_line_id', + 'SELECT ''wh_inventory_txn.logistics_waybill_no already exists''' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql := IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wh_inventory_txn' AND COLUMN_NAME = 'logistics_freight_amount') = 0, + 'ALTER TABLE wh_inventory_txn ADD COLUMN logistics_freight_amount DECIMAL(18,2) NULL COMMENT ''运费'' AFTER logistics_waybill_no', + 'SELECT ''wh_inventory_txn.logistics_freight_amount already exists''' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql := IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wh_inventory_txn' AND COLUMN_NAME = 'logistics_photo_url') = 0, + 'ALTER TABLE wh_inventory_txn ADD COLUMN logistics_photo_url VARCHAR(255) NULL COMMENT ''订单照片路径'' AFTER logistics_freight_amount', + 'SELECT ''wh_inventory_txn.logistics_photo_url already exists''' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql := IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'so_delivery' AND COLUMN_NAME = 'logistics_waybill_no') = 0, + 'ALTER TABLE so_delivery ADD COLUMN logistics_waybill_no VARCHAR(100) NULL COMMENT ''运单号'' AFTER delivery_address', + 'SELECT ''so_delivery.logistics_waybill_no already exists''' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql := IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'so_delivery' AND COLUMN_NAME = 'logistics_freight_amount') = 0, + 'ALTER TABLE so_delivery ADD COLUMN logistics_freight_amount DECIMAL(18,2) NULL COMMENT ''运费'' AFTER logistics_waybill_no', + 'SELECT ''so_delivery.logistics_freight_amount already exists''' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql := IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'so_delivery' AND COLUMN_NAME = 'logistics_photo_url') = 0, + 'ALTER TABLE so_delivery ADD COLUMN logistics_photo_url VARCHAR(255) NULL COMMENT ''订单照片路径'' AFTER logistics_freight_amount', + 'SELECT ''so_delivery.logistics_photo_url already exists''' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/backend/sql/add_material_driven_flow_patch.sql b/backend/sql/add_material_driven_flow_patch.sql new file mode 100644 index 0000000..701d184 --- /dev/null +++ b/backend/sql/add_material_driven_flow_patch.sql @@ -0,0 +1,335 @@ +SET @po_receipt_item_merge_column_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'po_receipt_item' + AND COLUMN_NAME = 'merge_to_lot_id' +); +SET @po_receipt_item_merge_sql := IF( + @po_receipt_item_merge_column_exists = 0, + 'ALTER TABLE po_receipt_item ADD COLUMN merge_to_lot_id BIGINT NULL COMMENT ''合并入库目标库存批次ID;为空表示新建独立批次'' AFTER lot_no', + 'SELECT ''po_receipt_item.merge_to_lot_id already exists''' +); +PREPARE po_receipt_item_merge_stmt FROM @po_receipt_item_merge_sql; +EXECUTE po_receipt_item_merge_stmt; +DEALLOCATE PREPARE po_receipt_item_merge_stmt; + +SET @stock_lot_parent_column_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'wh_stock_lot' + AND COLUMN_NAME = 'parent_lot_id' +); +SET @stock_lot_parent_sql := IF( + @stock_lot_parent_column_exists = 0, + 'ALTER TABLE wh_stock_lot ADD COLUMN parent_lot_id BIGINT NULL COMMENT ''父批次ID;用于合并批次、退货批次或成品来源追溯'' AFTER lot_no', + 'SELECT ''wh_stock_lot.parent_lot_id already exists''' +); +PREPARE stock_lot_parent_stmt FROM @stock_lot_parent_sql; +EXECUTE stock_lot_parent_stmt; +DEALLOCATE PREPARE stock_lot_parent_stmt; + +SET @stock_lot_role_column_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'wh_stock_lot' + AND COLUMN_NAME = 'lot_role' +); +SET @stock_lot_role_sql := IF( + @stock_lot_role_column_exists = 0, + 'ALTER TABLE wh_stock_lot ADD COLUMN lot_role VARCHAR(32) NOT NULL DEFAULT ''INVENTORY'' COMMENT ''批次角色:INBOUND_RAW/MERGED_RAW/FINISHED_FROM_RAW_BATCH/RETURNED_FINISHED/INVENTORY'' AFTER parent_lot_id', + 'SELECT ''wh_stock_lot.lot_role already exists''' +); +PREPARE stock_lot_role_stmt FROM @stock_lot_role_sql; +EXECUTE stock_lot_role_stmt; +DEALLOCATE PREPARE stock_lot_role_stmt; + +SET @stock_lot_sub_batch_column_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'wh_stock_lot' + AND COLUMN_NAME = 'material_sub_batch_no' +); +SET @stock_lot_sub_batch_sql := IF( + @stock_lot_sub_batch_column_exists = 0, + 'ALTER TABLE wh_stock_lot ADD COLUMN material_sub_batch_no VARCHAR(80) NULL COMMENT ''原材料生产分批号,格式JH_材料号_0001'' AFTER lot_role', + 'SELECT ''wh_stock_lot.material_sub_batch_no already exists''' +); +PREPARE stock_lot_sub_batch_stmt FROM @stock_lot_sub_batch_sql; +EXECUTE stock_lot_sub_batch_stmt; +DEALLOCATE PREPARE stock_lot_sub_batch_stmt; + +SET @stock_lot_source_material_lot_column_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'wh_stock_lot' + AND COLUMN_NAME = 'source_material_lot_id' +); +SET @stock_lot_source_material_lot_sql := IF( + @stock_lot_source_material_lot_column_exists = 0, + 'ALTER TABLE wh_stock_lot ADD COLUMN source_material_lot_id BIGINT NULL COMMENT ''成品批次来源原材料库存批次ID'' AFTER source_line_id', + 'SELECT ''wh_stock_lot.source_material_lot_id already exists''' +); +PREPARE stock_lot_source_material_lot_stmt FROM @stock_lot_source_material_lot_sql; +EXECUTE stock_lot_source_material_lot_stmt; +DEALLOCATE PREPARE stock_lot_source_material_lot_stmt; + +SET @stock_lot_source_material_sub_column_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'wh_stock_lot' + AND COLUMN_NAME = 'source_material_sub_batch_no' +); +SET @stock_lot_source_material_sub_sql := IF( + @stock_lot_source_material_sub_column_exists = 0, + 'ALTER TABLE wh_stock_lot ADD COLUMN source_material_sub_batch_no VARCHAR(80) NULL COMMENT ''成品批次来源原材料生产分批号'' AFTER source_material_lot_id', + 'SELECT ''wh_stock_lot.source_material_sub_batch_no already exists''' +); +PREPARE stock_lot_source_material_sub_stmt FROM @stock_lot_source_material_sub_sql; +EXECUTE stock_lot_source_material_sub_stmt; +DEALLOCATE PREPARE stock_lot_source_material_sub_stmt; + +SET @stock_lot_source_material_summary_column_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'wh_stock_lot' + AND COLUMN_NAME = 'source_material_summary' +); +SET @stock_lot_source_material_summary_sql := IF( + @stock_lot_source_material_summary_column_exists = 0, + 'ALTER TABLE wh_stock_lot ADD COLUMN source_material_summary VARCHAR(1000) NULL COMMENT ''成品批次来源原材料分批摘要'' AFTER source_material_sub_batch_no', + 'SELECT ''wh_stock_lot.source_material_summary already exists''' +); +PREPARE stock_lot_source_material_summary_stmt FROM @stock_lot_source_material_summary_sql; +EXECUTE stock_lot_source_material_summary_stmt; +DEALLOCATE PREPARE stock_lot_source_material_summary_stmt; + +SET @completion_source_material_lot_column_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'pp_completion_receipt_item' + AND COLUMN_NAME = 'source_material_lot_id' +); +SET @completion_source_material_lot_sql := IF( + @completion_source_material_lot_column_exists = 0, + 'ALTER TABLE pp_completion_receipt_item ADD COLUMN source_material_lot_id BIGINT NULL COMMENT ''成品来源原材料库存批次ID'' AFTER lot_no', + 'SELECT ''pp_completion_receipt_item.source_material_lot_id already exists''' +); +PREPARE completion_source_material_lot_stmt FROM @completion_source_material_lot_sql; +EXECUTE completion_source_material_lot_stmt; +DEALLOCATE PREPARE completion_source_material_lot_stmt; + +SET @completion_source_material_sub_column_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'pp_completion_receipt_item' + AND COLUMN_NAME = 'source_material_sub_batch_no' +); +SET @completion_source_material_sub_sql := IF( + @completion_source_material_sub_column_exists = 0, + 'ALTER TABLE pp_completion_receipt_item ADD COLUMN source_material_sub_batch_no VARCHAR(80) NULL COMMENT ''成品来源原材料生产分批号'' AFTER source_material_lot_id', + 'SELECT ''pp_completion_receipt_item.source_material_sub_batch_no already exists''' +); +PREPARE completion_source_material_sub_stmt FROM @completion_source_material_sub_sql; +EXECUTE completion_source_material_sub_stmt; +DEALLOCATE PREPARE completion_source_material_sub_stmt; + +SET @completion_source_material_summary_column_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'pp_completion_receipt_item' + AND COLUMN_NAME = 'source_material_summary' +); +SET @completion_source_material_summary_sql := IF( + @completion_source_material_summary_column_exists = 0, + 'ALTER TABLE pp_completion_receipt_item ADD COLUMN source_material_summary VARCHAR(1000) NULL COMMENT ''成品来源原材料分批摘要'' AFTER source_material_sub_batch_no', + 'SELECT ''pp_completion_receipt_item.source_material_summary already exists''' +); +PREPARE completion_source_material_summary_stmt FROM @completion_source_material_summary_sql; +EXECUTE completion_source_material_summary_stmt; +DEALLOCATE PREPARE completion_source_material_summary_stmt; + +CREATE TABLE IF NOT EXISTS pp_work_order_material_issue ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + work_order_material_id BIGINT NOT NULL COMMENT '工单用料ID', + work_order_id BIGINT NOT NULL COMMENT '生产工单ID', + material_item_id BIGINT NOT NULL COMMENT '原材料物料ID', + source_lot_id BIGINT NOT NULL COMMENT '来源原材料库存批次ID', + material_sub_batch_no VARCHAR(80) NOT NULL COMMENT '兼容字段:来源库存批次号', + issued_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '领用数量', + issued_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '领用重量(kg)', + unit_cost DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '领用单价', + amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '领用金额', + issue_time DATETIME NOT NULL COMMENT '领用时间', + operator_user_id BIGINT NULL COMMENT '操作人用户ID', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + INDEX idx_material_issue_work_order (work_order_id), + INDEX idx_material_issue_lot (source_lot_id), + INDEX idx_material_issue_material (material_item_id), + FOREIGN KEY (work_order_material_id) REFERENCES pp_work_order_material(id), + FOREIGN KEY (work_order_id) REFERENCES pp_work_order(id), + FOREIGN KEY (material_item_id) REFERENCES md_item(id), + FOREIGN KEY (source_lot_id) REFERENCES wh_stock_lot(id) +) ENGINE=InnoDB COMMENT='工单原材料领用分批记录'; + +SET @idx_po_receipt_item_merge_exists := ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'po_receipt_item' + AND INDEX_NAME = 'idx_receipt_item_merge_to_lot' +); +SET @idx_po_receipt_item_merge_sql := IF( + @idx_po_receipt_item_merge_exists = 0, + 'ALTER TABLE po_receipt_item ADD INDEX idx_receipt_item_merge_to_lot (merge_to_lot_id)', + 'SELECT ''idx_receipt_item_merge_to_lot already exists''' +); +PREPARE idx_po_receipt_item_merge_stmt FROM @idx_po_receipt_item_merge_sql; +EXECUTE idx_po_receipt_item_merge_stmt; +DEALLOCATE PREPARE idx_po_receipt_item_merge_stmt; + +SET @idx_stock_lot_source_material_exists := ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'wh_stock_lot' + AND INDEX_NAME = 'idx_stock_lot_source_material' +); +SET @idx_stock_lot_source_material_sql := IF( + @idx_stock_lot_source_material_exists = 0, + 'ALTER TABLE wh_stock_lot ADD INDEX idx_stock_lot_source_material (source_material_lot_id, source_material_sub_batch_no)', + 'SELECT ''idx_stock_lot_source_material already exists''' +); +PREPARE idx_stock_lot_source_material_stmt FROM @idx_stock_lot_source_material_sql; +EXECUTE idx_stock_lot_source_material_stmt; +DEALLOCATE PREPARE idx_stock_lot_source_material_stmt; + +SET @idx_completion_source_material_exists := ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'pp_completion_receipt_item' + AND INDEX_NAME = 'idx_completion_source_material' +); +SET @idx_completion_source_material_sql := IF( + @idx_completion_source_material_exists = 0, + 'ALTER TABLE pp_completion_receipt_item ADD INDEX idx_completion_source_material (source_material_lot_id, source_material_sub_batch_no)', + 'SELECT ''idx_completion_source_material already exists''' +); +PREPARE idx_completion_source_material_stmt FROM @idx_completion_source_material_sql; +EXECUTE idx_completion_source_material_stmt; +DEALLOCATE PREPARE idx_completion_source_material_stmt; + +UPDATE wh_stock_lot lot +JOIN md_item item ON item.id = lot.item_id +SET lot.lot_role = CASE + WHEN item.item_type = 'RAW_MATERIAL' AND lot.source_doc_type = 'PURCHASE_RECEIPT' THEN 'INBOUND_RAW' + WHEN item.item_type = 'FINISHED_GOOD' AND lot.source_doc_type = 'FG_RECEIPT' THEN 'FINISHED_GOOD' + ELSE lot.lot_role +END +WHERE lot.lot_role = 'INVENTORY'; + +INSERT INTO pp_work_order_material_issue ( + work_order_material_id, + work_order_id, + material_item_id, + source_lot_id, + material_sub_batch_no, + issued_qty, + issued_weight_kg, + unit_cost, + amount, + issue_time, + operator_user_id, + remark, + created_at, + updated_at +) +SELECT + source_rows.work_order_material_id, + source_rows.work_order_id, + source_rows.material_item_id, + source_rows.source_lot_id, + CONCAT('JH_', source_rows.material_code, '_', LPAD(source_rows.seq_no, 4, '0')) AS material_sub_batch_no, + source_rows.issued_qty, + source_rows.issued_weight_kg, + source_rows.unit_cost, + source_rows.amount, + source_rows.issue_time, + source_rows.operator_user_id, + source_rows.remark, + NOW(), + NOW() +FROM ( + SELECT + wom.id AS work_order_material_id, + wom.work_order_id AS work_order_id, + txn.item_id AS material_item_id, + txn.lot_id AS source_lot_id, + item.item_code AS material_code, + ROW_NUMBER() OVER (PARTITION BY txn.item_id ORDER BY txn.biz_time, txn.id) AS seq_no, + ABS(txn.qty_change) AS issued_qty, + ABS(txn.weight_change_kg) AS issued_weight_kg, + txn.unit_cost AS unit_cost, + ABS(txn.amount) AS amount, + txn.biz_time AS issue_time, + txn.operator_user_id AS operator_user_id, + txn.remark AS remark + FROM wh_inventory_txn txn + JOIN pp_work_order_material wom + ON wom.id = txn.source_line_id + AND wom.work_order_id = txn.source_doc_id + JOIN md_item item ON item.id = txn.item_id + WHERE txn.txn_type = 'MATERIAL_ISSUE' + AND txn.source_doc_type = 'WORK_ORDER' + AND txn.lot_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM pp_work_order_material_issue) +) source_rows; + +UPDATE pp_completion_receipt_item item +JOIN pp_completion_receipt receipt ON receipt.id = item.completion_receipt_id +JOIN ( + SELECT + issue.work_order_id, + MIN(issue.source_lot_id) AS source_material_lot_id, + SUBSTRING_INDEX(GROUP_CONCAT(issue.material_sub_batch_no ORDER BY issue.id SEPARATOR ','), ',', 1) AS source_material_sub_batch_no, + LEFT( + GROUP_CONCAT( + CONCAT(issue.material_sub_batch_no, ' / ', material.item_name, ' / ', issue.issued_weight_kg, 'kg / ¥', issue.amount) + ORDER BY issue.id + SEPARATOR ';' + ), + 1000 + ) AS source_material_summary + FROM pp_work_order_material_issue issue + JOIN md_item material ON material.id = issue.material_item_id + GROUP BY issue.work_order_id +) source_map ON source_map.work_order_id = receipt.work_order_id +SET + item.source_material_lot_id = COALESCE(item.source_material_lot_id, source_map.source_material_lot_id), + item.source_material_sub_batch_no = COALESCE(item.source_material_sub_batch_no, source_map.source_material_sub_batch_no), + item.source_material_summary = COALESCE(item.source_material_summary, source_map.source_material_summary) +WHERE item.source_material_lot_id IS NULL; + +UPDATE wh_stock_lot lot +JOIN pp_completion_receipt_item item + ON lot.source_doc_type = 'FG_RECEIPT' + AND lot.source_line_id = item.id +SET + lot.parent_lot_id = COALESCE(lot.parent_lot_id, item.source_material_lot_id), + lot.lot_role = 'FINISHED_FROM_RAW_BATCH', + lot.source_material_lot_id = COALESCE(lot.source_material_lot_id, item.source_material_lot_id), + lot.source_material_sub_batch_no = COALESCE(lot.source_material_sub_batch_no, item.source_material_sub_batch_no), + lot.source_material_summary = COALESCE(lot.source_material_summary, item.source_material_summary) +WHERE item.source_material_lot_id IS NOT NULL; diff --git a/backend/sql/add_production_batch_ledger_patch.sql b/backend/sql/add_production_batch_ledger_patch.sql new file mode 100644 index 0000000..e0acdf0 --- /dev/null +++ b/backend/sql/add_production_batch_ledger_patch.sql @@ -0,0 +1,101 @@ +CREATE TABLE IF NOT EXISTS pp_production_batch_ledger ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '生产台账ID', + material_lot_id BIGINT NOT NULL COMMENT '材料库存批次ID', + material_lot_no VARCHAR(128) NOT NULL COMMENT '材料库存批次号', + material_item_id BIGINT NOT NULL COMMENT '原材料物料ID', + product_item_id BIGINT NOT NULL COMMENT '产品物料ID', + status VARCHAR(32) NOT NULL DEFAULT '在生产' COMMENT '状态:在生产/锁单', + miniapp_selectable TINYINT(1) NOT NULL DEFAULT 1 COMMENT '小程序是否可选', + total_issued_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '累计领料重量', + outside_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '库外未闭环重量', + finished_inbound_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '累计成品入库数量', + surplus_inbound_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '累计余料入库重量', + scrap_inbound_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '累计废料入库重量', + writeoff_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '结单核销重量', + first_issue_time DATETIME NULL COMMENT '首次生产出库时间', + last_issue_time DATETIME NULL COMMENT '最近生产出库时间', + locked_at DATETIME NULL COMMENT '最近锁单时间', + reopened_at DATETIME NULL COMMENT '最近重新开工时间', + lock_count INT NOT NULL DEFAULT 0 COMMENT '锁单次数', + remark VARCHAR(500) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_production_batch_ledger_lot_product (material_lot_id, product_item_id), + KEY idx_production_batch_ledger_status (status), + KEY idx_production_batch_ledger_lot_no (material_lot_no), + KEY idx_production_batch_ledger_material (material_item_id), + KEY idx_production_batch_ledger_product (product_item_id), + CONSTRAINT fk_production_batch_ledger_lot FOREIGN KEY (material_lot_id) REFERENCES wh_stock_lot(id), + CONSTRAINT fk_production_batch_ledger_material FOREIGN KEY (material_item_id) REFERENCES md_item(id), + CONSTRAINT fk_production_batch_ledger_product FOREIGN KEY (product_item_id) REFERENCES md_item(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='生产台账:材料库存批次号+产品'; + +CREATE TABLE IF NOT EXISTS pp_production_batch_ledger_txn ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '生产台账流水ID', + production_ledger_id BIGINT NOT NULL COMMENT '生产台账ID', + txn_type VARCHAR(32) NOT NULL COMMENT '流水类型:生产出库/成品入库/生产余料入库/生产废料入库/该批材料结单/重新开工/结单核销', + qty_delta DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '数量变化', + weight_delta_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '重量变化kg', + outside_weight_after_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '变更后库外未闭环重量', + source_doc_type VARCHAR(64) NULL COMMENT '来源单据类型', + source_doc_id BIGINT NULL COMMENT '来源单据ID', + source_line_id BIGINT NULL COMMENT '来源明细ID', + biz_time DATETIME NOT NULL COMMENT '业务时间', + operator_user_id BIGINT NULL COMMENT '操作人用户ID', + remark VARCHAR(500) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + KEY idx_production_batch_ledger_txn_ledger (production_ledger_id), + KEY idx_production_batch_ledger_txn_type (txn_type), + KEY idx_production_batch_ledger_txn_source (source_doc_type, source_doc_id, source_line_id), + CONSTRAINT fk_production_batch_ledger_txn_ledger FOREIGN KEY (production_ledger_id) REFERENCES pp_production_batch_ledger(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='生产台账流水'; + +SET @has_operation_report_ledger_id := ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'pp_operation_report' + AND COLUMN_NAME = 'production_ledger_id' +); + +SET @alter_operation_report_ledger_id := IF( + @has_operation_report_ledger_id = 0, + 'ALTER TABLE pp_operation_report ADD COLUMN production_ledger_id BIGINT NULL COMMENT ''生产台账ID'' AFTER work_order_operation_id', + 'SELECT 1' +); + +PREPARE stmt FROM @alter_operation_report_ledger_id; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +ALTER TABLE pp_operation_report + MODIFY COLUMN work_order_id BIGINT NULL COMMENT '生产工单ID(旧工单/返工使用,正常生产可为空)', + MODIFY COLUMN work_order_operation_id BIGINT NULL COMMENT '生产工序ID(旧工单/返工使用,正常生产可为空)'; + +CREATE TABLE IF NOT EXISTS sys_system_config ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + config_code VARCHAR(100) NOT NULL COMMENT '配置编码', + config_name VARCHAR(100) NOT NULL COMMENT '配置名称', + config_value VARCHAR(500) NOT NULL COMMENT '配置值', + remark VARCHAR(500) NULL COMMENT '备注', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/INACTIVE', + created_by BIGINT NULL COMMENT '创建用户ID', + updated_by BIGINT NULL COMMENT '更新用户ID', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_system_config_code (config_code), + KEY idx_system_config_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置'; + +INSERT INTO sys_system_config (config_code, config_name, config_value, remark, status) +SELECT + 'SMART_OPERATION_REPORT_ENABLED', + '对接智能报工小程序', + '开启', + '开启后显示工序报工并按小程序报工数据计算生产进度;关闭后按ERP入库数据反推生产进度', + 'ACTIVE' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_system_config WHERE config_code = 'SMART_OPERATION_REPORT_ENABLED' +); diff --git a/backend/sql/add_purchase_order_target_warehouse_type_patch.sql b/backend/sql/add_purchase_order_target_warehouse_type_patch.sql new file mode 100644 index 0000000..adeec4d --- /dev/null +++ b/backend/sql/add_purchase_order_target_warehouse_type_patch.sql @@ -0,0 +1,2 @@ +ALTER TABLE po_purchase_order + ADD COLUMN target_warehouse_type VARCHAR(32) NOT NULL DEFAULT 'RAW' COMMENT '目标仓库类型:RAW原材料库/AUX辅料库' AFTER purchaser_employee_id; diff --git a/backend/sql/add_purchase_return_outbound_patch.sql b/backend/sql/add_purchase_return_outbound_patch.sql new file mode 100644 index 0000000..cc31bcb --- /dev/null +++ b/backend/sql/add_purchase_return_outbound_patch.sql @@ -0,0 +1,48 @@ +CREATE TABLE IF NOT EXISTS po_purchase_return ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + return_no VARCHAR(50) NOT NULL, + purchase_order_id BIGINT NOT NULL, + warehouse_id BIGINT NOT NULL, + return_date DATETIME NOT NULL, + operator_user_id BIGINT NULL, + logistics_waybill_no VARCHAR(100) NULL, + logistics_freight_amount DECIMAL(18, 2) NULL, + logistics_photo_url VARCHAR(255) NULL, + reason VARCHAR(255) NULL, + status VARCHAR(32) NOT NULL DEFAULT 'CONFIRMED', + remark VARCHAR(255) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_po_purchase_return_no (return_no), + KEY idx_po_purchase_return_order (purchase_order_id), + KEY idx_po_purchase_return_warehouse (warehouse_id), + KEY idx_po_purchase_return_operator_user (operator_user_id), + CONSTRAINT fk_po_purchase_return_order FOREIGN KEY (purchase_order_id) REFERENCES po_purchase_order (id), + CONSTRAINT fk_po_purchase_return_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), + CONSTRAINT fk_po_purchase_return_operator_user FOREIGN KEY (operator_user_id) REFERENCES sys_user (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购退货出库单'; + +CREATE TABLE IF NOT EXISTS po_purchase_return_item ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + purchase_return_id BIGINT NOT NULL, + line_no INT NOT NULL, + purchase_order_item_id BIGINT NOT NULL, + material_item_id BIGINT NOT NULL, + lot_id BIGINT NOT NULL, + return_weight_kg DECIMAL(18, 6) NOT NULL DEFAULT 0, + unit_cost DECIMAL(18, 4) NOT NULL DEFAULT 0, + amount DECIMAL(18, 2) NOT NULL DEFAULT 0, + reason VARCHAR(255) NULL, + status VARCHAR(32) NOT NULL DEFAULT 'CONFIRMED', + remark VARCHAR(255) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_po_purchase_return_item_line (purchase_return_id, line_no), + KEY idx_po_purchase_return_item_return (purchase_return_id), + KEY idx_po_purchase_return_item_po_item (purchase_order_item_id), + KEY idx_po_purchase_return_item_lot (lot_id), + CONSTRAINT fk_po_purchase_return_item_return FOREIGN KEY (purchase_return_id) REFERENCES po_purchase_return (id), + CONSTRAINT fk_po_purchase_return_item_po_item FOREIGN KEY (purchase_order_item_id) REFERENCES po_purchase_order_item (id), + CONSTRAINT fk_po_purchase_return_item_material FOREIGN KEY (material_item_id) REFERENCES md_item (id), + CONSTRAINT fk_po_purchase_return_item_lot FOREIGN KEY (lot_id) REFERENCES wh_stock_lot (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购退货出库明细'; diff --git a/backend/sql/add_raw_lot_work_order_inbound_patch.sql b/backend/sql/add_raw_lot_work_order_inbound_patch.sql new file mode 100644 index 0000000..eb2842b --- /dev/null +++ b/backend/sql/add_raw_lot_work_order_inbound_patch.sql @@ -0,0 +1,26 @@ +CREATE TABLE IF NOT EXISTS sys_system_config ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + config_code VARCHAR(100) NOT NULL COMMENT '配置编码', + config_name VARCHAR(100) NOT NULL COMMENT '配置名称', + config_value VARCHAR(500) NOT NULL COMMENT '配置值', + remark VARCHAR(500) NULL COMMENT '备注', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/INACTIVE', + created_by BIGINT NULL COMMENT '创建用户ID', + updated_by BIGINT NULL COMMENT '更新用户ID', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_system_config_code (config_code), + KEY idx_system_config_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置'; + +INSERT INTO sys_system_config (config_code, config_name, config_value, remark, status) +SELECT + 'RAW_MATERIAL_LOT_PREFIX', + '原材料库存批次号前缀', + 'YL', + '原材料库存批次号默认前缀,例如 YL0001', + 'ACTIVE' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_system_config WHERE config_code = 'RAW_MATERIAL_LOT_PREFIX' +); diff --git a/backend/sql/add_return_warehouse_patch.sql b/backend/sql/add_return_warehouse_patch.sql new file mode 100644 index 0000000..fb8c21e --- /dev/null +++ b/backend/sql/add_return_warehouse_patch.sql @@ -0,0 +1,260 @@ +SET @return_keep_id := ( + SELECT id + FROM wh_warehouse + WHERE warehouse_code = 'WH-RETURN-01' + ORDER BY id + LIMIT 1 +); + +SET @return_keep_id := COALESCE( + @return_keep_id, + ( + SELECT id + FROM wh_warehouse + WHERE warehouse_type = 'RETURN' + ORDER BY id + LIMIT 1 + ) +); + +INSERT INTO wh_warehouse (warehouse_code, warehouse_name, warehouse_type, manager_employee_id, status, remark) +SELECT 'WH-RETURN-01', '退货库', 'RETURN', NULL, 'ACTIVE', '客户退回且可返工产品的待处理库存' +WHERE @return_keep_id IS NULL; + +SET @return_keep_id := COALESCE( + @return_keep_id, + ( + SELECT id + FROM wh_warehouse + WHERE warehouse_code = 'WH-RETURN-01' + ORDER BY id + LIMIT 1 + ) +); + +UPDATE wh_warehouse +SET warehouse_name = '退货库', + warehouse_code = 'WH-RETURN-01', + warehouse_type = 'RETURN', + status = 'ACTIVE', + remark = '客户退回且可返工产品的待处理库存' +WHERE id = @return_keep_id; + +INSERT INTO wh_location ( + warehouse_id, + location_code, + location_name, + zone_name, + is_default, + is_locked, + status, + remark +) +SELECT + @return_keep_id, + 'RTN-F-01', + '退货暂存位', + 'F区', + 1, + 0, + 'ACTIVE', + '退货库默认库位' +WHERE NOT EXISTS ( + SELECT 1 + FROM wh_location + WHERE warehouse_id = @return_keep_id + AND location_code = 'RTN-F-01' +); + +UPDATE wh_location +SET location_name = '退货暂存位', + zone_name = 'F区', + is_default = 1, + is_locked = 0, + status = 'ACTIVE', + remark = '退货库默认库位' +WHERE warehouse_id = @return_keep_id + AND location_code = 'RTN-F-01'; + +SET @return_default_location_id := ( + SELECT id + FROM wh_location + WHERE warehouse_id = @return_keep_id + AND location_code = 'RTN-F-01' + ORDER BY id + LIMIT 1 +); + +UPDATE wh_location duplicate_location +JOIN wh_warehouse duplicate_warehouse ON duplicate_warehouse.id = duplicate_location.warehouse_id +SET duplicate_location.location_code = CONCAT('RTN-M', duplicate_warehouse.id, '-', duplicate_location.id), + duplicate_location.location_name = CONCAT(LEFT(duplicate_location.location_name, 80), '(合并)'), + duplicate_location.remark = LEFT(CONCAT(COALESCE(duplicate_location.remark, ''), ';重复退货库合并时重命名库位编码'), 255) +WHERE duplicate_warehouse.warehouse_type = 'RETURN' + AND duplicate_warehouse.id <> @return_keep_id; + +UPDATE wh_location +SET warehouse_id = @return_keep_id +WHERE warehouse_id IN ( + SELECT duplicate_return_warehouse.id + FROM ( + SELECT id + FROM wh_warehouse + WHERE warehouse_type = 'RETURN' + AND id <> @return_keep_id + ) AS duplicate_return_warehouse +); + +UPDATE wh_location +SET is_default = CASE WHEN id = @return_default_location_id THEN 1 ELSE 0 END +WHERE warehouse_id = @return_keep_id; + +UPDATE wh_stock_lot +SET warehouse_id = @return_keep_id, + location_id = COALESCE(location_id, @return_default_location_id) +WHERE warehouse_id IN ( + SELECT duplicate_return_warehouse.id + FROM ( + SELECT id + FROM wh_warehouse + WHERE warehouse_type = 'RETURN' + AND id <> @return_keep_id + ) AS duplicate_return_warehouse +); + +DROP TEMPORARY TABLE IF EXISTS tmp_return_balance_merge; + +CREATE TEMPORARY TABLE tmp_return_balance_merge AS +SELECT + sb.id AS old_balance_id, + sb.item_id AS item_id, + @return_keep_id AS target_warehouse_id, + COALESCE(sb.location_id, @return_default_location_id) AS target_location_id, + sb.qty_on_hand AS qty_on_hand, + sb.weight_on_hand_kg AS weight_on_hand_kg, + sb.qty_available AS qty_available, + sb.weight_available_kg AS weight_available_kg, + sb.qty_allocated AS qty_allocated, + sb.weight_allocated_kg AS weight_allocated_kg, + sb.avg_unit_cost AS avg_unit_cost +FROM wh_stock_balance sb +JOIN wh_warehouse duplicate_warehouse ON duplicate_warehouse.id = sb.warehouse_id +WHERE duplicate_warehouse.warehouse_type = 'RETURN' + AND duplicate_warehouse.id <> @return_keep_id; + +INSERT INTO wh_stock_balance ( + item_id, + warehouse_id, + location_id, + qty_on_hand, + weight_on_hand_kg, + qty_available, + weight_available_kg, + qty_allocated, + weight_allocated_kg, + avg_unit_cost +) +SELECT + item_id, + target_warehouse_id, + target_location_id, + SUM(qty_on_hand), + SUM(weight_on_hand_kg), + SUM(qty_available), + SUM(weight_available_kg), + SUM(qty_allocated), + SUM(weight_allocated_kg), + CASE + WHEN SUM(weight_on_hand_kg) > 0 THEN SUM(avg_unit_cost * weight_on_hand_kg) / SUM(weight_on_hand_kg) + ELSE MAX(avg_unit_cost) + END +FROM tmp_return_balance_merge +GROUP BY item_id, target_warehouse_id, target_location_id +ON DUPLICATE KEY UPDATE + qty_on_hand = wh_stock_balance.qty_on_hand + VALUES(qty_on_hand), + weight_on_hand_kg = wh_stock_balance.weight_on_hand_kg + VALUES(weight_on_hand_kg), + qty_available = wh_stock_balance.qty_available + VALUES(qty_available), + weight_available_kg = wh_stock_balance.weight_available_kg + VALUES(weight_available_kg), + qty_allocated = wh_stock_balance.qty_allocated + VALUES(qty_allocated), + weight_allocated_kg = wh_stock_balance.weight_allocated_kg + VALUES(weight_allocated_kg), + avg_unit_cost = CASE + WHEN wh_stock_balance.weight_on_hand_kg + VALUES(weight_on_hand_kg) > 0 + THEN ( + wh_stock_balance.avg_unit_cost * wh_stock_balance.weight_on_hand_kg + + VALUES(avg_unit_cost) * VALUES(weight_on_hand_kg) + ) / (wh_stock_balance.weight_on_hand_kg + VALUES(weight_on_hand_kg)) + ELSE VALUES(avg_unit_cost) + END; + +DELETE FROM wh_stock_balance +WHERE id IN (SELECT old_balance_id FROM tmp_return_balance_merge); + +DROP TEMPORARY TABLE IF EXISTS tmp_return_balance_merge; + +UPDATE wh_inventory_txn +SET warehouse_id = @return_keep_id, + location_id = COALESCE(location_id, @return_default_location_id) +WHERE warehouse_id IN ( + SELECT duplicate_return_warehouse.id + FROM ( + SELECT id + FROM wh_warehouse + WHERE warehouse_type = 'RETURN' + AND id <> @return_keep_id + ) AS duplicate_return_warehouse +); + +UPDATE wh_warehouse +SET warehouse_type = 'RETURN_MERGED', + status = 'INACTIVE', + remark = LEFT(CONCAT(COALESCE(remark, ''), ';已由退货库补丁合并到WH-RETURN-01'), 255) +WHERE warehouse_type = 'RETURN' + AND id <> @return_keep_id; + +UPDATE wh_location +SET location_name = '退货暂存位', + zone_name = 'F区', + is_default = 1, + is_locked = 0, + status = 'ACTIVE', + remark = '退货库默认库位' +WHERE warehouse_id = (SELECT id FROM wh_warehouse WHERE warehouse_code = 'WH-RETURN-01') + AND location_code = 'RTN-F-01'; + +UPDATE wh_location +SET is_default = CASE WHEN location_code = 'RTN-F-01' THEN 1 ELSE 0 END +WHERE warehouse_id = @return_keep_id; + +SET @has_delivery_item_id := ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'rt_return_item' + AND COLUMN_NAME = 'delivery_item_id' +); + +SET @add_delivery_item_id_sql := IF( + @has_delivery_item_id = 0, + 'ALTER TABLE rt_return_item ADD COLUMN delivery_item_id BIGINT NULL AFTER sales_order_item_id', + 'SELECT 1' +); +PREPARE add_delivery_item_id_stmt FROM @add_delivery_item_id_sql; +EXECUTE add_delivery_item_id_stmt; +DEALLOCATE PREPARE add_delivery_item_id_stmt; + +SET @has_return_delivery_item_idx := ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'rt_return_item' + AND INDEX_NAME = 'idx_rt_return_item_delivery_item' +); + +SET @add_return_delivery_item_idx_sql := IF( + @has_return_delivery_item_idx = 0, + 'ALTER TABLE rt_return_item ADD KEY idx_rt_return_item_delivery_item (delivery_item_id)', + 'SELECT 1' +); +PREPARE add_return_delivery_item_idx_stmt FROM @add_return_delivery_item_idx_sql; +EXECUTE add_return_delivery_item_idx_stmt; +DEALLOCATE PREPARE add_return_delivery_item_idx_stmt; diff --git a/backend/sql/add_scrap_warehouse_patch.sql b/backend/sql/add_scrap_warehouse_patch.sql new file mode 100644 index 0000000..d571027 --- /dev/null +++ b/backend/sql/add_scrap_warehouse_patch.sql @@ -0,0 +1,46 @@ +INSERT INTO wh_warehouse (warehouse_code, warehouse_name, warehouse_type, manager_employee_id, status, remark) +SELECT 'WH-SCRAP-01', '废料库', 'SCRAP', NULL, 'ACTIVE', '生产废料、边角料、委外废料、报废品等废料库存' +WHERE NOT EXISTS (SELECT 1 FROM wh_warehouse WHERE warehouse_code = 'WH-SCRAP-01'); + +UPDATE wh_warehouse +SET warehouse_name = '废料库', + warehouse_type = 'SCRAP', + status = 'ACTIVE', + remark = '生产废料、边角料、委外废料、报废品等废料库存' +WHERE warehouse_code = 'WH-SCRAP-01'; + +INSERT INTO wh_location ( + warehouse_id, + location_code, + location_name, + zone_name, + is_default, + is_locked, + status, + remark +) +SELECT + (SELECT id FROM wh_warehouse WHERE warehouse_code = 'WH-SCRAP-01'), + 'SCR-E-01', + '废料暂存位', + 'E区', + 1, + 0, + 'ACTIVE', + '废料库默认库位' +WHERE NOT EXISTS ( + SELECT 1 + FROM wh_location + WHERE warehouse_id = (SELECT id FROM wh_warehouse WHERE warehouse_code = 'WH-SCRAP-01') + AND location_code = 'SCR-E-01' +); + +UPDATE wh_location +SET location_name = '废料暂存位', + zone_name = 'E区', + is_default = 1, + is_locked = 0, + status = 'ACTIVE', + remark = '废料库默认库位' +WHERE warehouse_id = (SELECT id FROM wh_warehouse WHERE warehouse_code = 'WH-SCRAP-01') + AND location_code = 'SCR-E-01'; diff --git a/backend/sql/add_special_warehouse_adjustment_patch.sql b/backend/sql/add_special_warehouse_adjustment_patch.sql new file mode 100644 index 0000000..feeafec --- /dev/null +++ b/backend/sql/add_special_warehouse_adjustment_patch.sql @@ -0,0 +1,49 @@ +CREATE TABLE IF NOT EXISTS wh_special_adjustment ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + adjustment_no VARCHAR(50) NOT NULL, + warehouse_id BIGINT NOT NULL, + warehouse_type VARCHAR(32) NOT NULL, + direction VARCHAR(32) NOT NULL, + reason VARCHAR(1000) NOT NULL, + status VARCHAR(32) NOT NULL, + operator_user_id BIGINT NULL, + adjusted_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_wh_special_adjustment_no (adjustment_no), + KEY idx_wh_special_adjustment_warehouse (warehouse_id, adjusted_at), + KEY idx_wh_special_adjustment_direction (direction, adjusted_at), + CONSTRAINT fk_special_adjustment_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse(id), + CONSTRAINT fk_special_adjustment_operator FOREIGN KEY (operator_user_id) REFERENCES sys_user(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS wh_special_adjustment_line ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + adjustment_id BIGINT NOT NULL, + line_no INT NOT NULL, + item_id BIGINT NOT NULL, + warehouse_id BIGINT NOT NULL, + location_id BIGINT NULL, + lot_id BIGINT NOT NULL, + inventory_txn_id BIGINT NULL, + before_qty DECIMAL(18,6) NOT NULL DEFAULT 0, + change_qty DECIMAL(18,6) NOT NULL DEFAULT 0, + after_qty DECIMAL(18,6) NOT NULL DEFAULT 0, + before_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0, + change_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0, + after_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0, + unit_cost DECIMAL(18,4) NOT NULL DEFAULT 0, + line_reason VARCHAR(1000) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_wh_special_adjustment_line_adjustment (adjustment_id), + KEY idx_wh_special_adjustment_line_item (item_id), + KEY idx_wh_special_adjustment_line_lot (lot_id), + KEY idx_wh_special_adjustment_line_txn (inventory_txn_id), + CONSTRAINT fk_special_adjustment_line_adjustment FOREIGN KEY (adjustment_id) REFERENCES wh_special_adjustment(id), + CONSTRAINT fk_special_adjustment_line_item FOREIGN KEY (item_id) REFERENCES md_item(id), + CONSTRAINT fk_special_adjustment_line_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse(id), + CONSTRAINT fk_special_adjustment_line_location FOREIGN KEY (location_id) REFERENCES wh_location(id), + CONSTRAINT fk_special_adjustment_line_lot FOREIGN KEY (lot_id) REFERENCES wh_stock_lot(id), + CONSTRAINT fk_special_adjustment_line_inventory_txn FOREIGN KEY (inventory_txn_id) REFERENCES wh_inventory_txn(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/backend/sql/add_stocktake_tables.sql b/backend/sql/add_stocktake_tables.sql new file mode 100644 index 0000000..0e38521 --- /dev/null +++ b/backend/sql/add_stocktake_tables.sql @@ -0,0 +1,98 @@ +CREATE TABLE IF NOT EXISTS wh_stocktake ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stocktake_no VARCHAR(50) NOT NULL UNIQUE, + status VARCHAR(32) NOT NULL, + started_by_user_id BIGINT NULL, + imported_by_user_id BIGINT NULL, + confirmed_by_user_id BIGINT NULL, + canceled_by_user_id BIGINT NULL, + started_at DATETIME NOT NULL, + exported_at DATETIME NULL, + imported_at DATETIME NULL, + confirmed_at DATETIME NULL, + canceled_at DATETIME NULL, + remark VARCHAR(255) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_wh_stocktake_status (status), + INDEX idx_wh_stocktake_started_at (started_at) +); + +CREATE TABLE IF NOT EXISTS wh_stocktake_warehouse ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stocktake_id BIGINT NOT NULL, + warehouse_id BIGINT NOT NULL, + warehouse_type VARCHAR(32) NOT NULL, + warehouse_name VARCHAR(100) NOT NULL, + status VARCHAR(32) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_wh_stocktake_warehouse_stocktake (stocktake_id), + INDEX idx_wh_stocktake_warehouse_lock (warehouse_id, status), + CONSTRAINT fk_stocktake_warehouse_stocktake FOREIGN KEY (stocktake_id) REFERENCES wh_stocktake(id), + CONSTRAINT fk_stocktake_warehouse_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse(id) +); + +CREATE TABLE IF NOT EXISTS wh_stocktake_line ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stocktake_id BIGINT NOT NULL, + stocktake_warehouse_id BIGINT NULL, + line_no INT NOT NULL, + warehouse_id BIGINT NOT NULL, + warehouse_name VARCHAR(100) NOT NULL, + warehouse_type VARCHAR(32) NOT NULL, + location_id BIGINT NULL, + location_name VARCHAR(100) NULL, + item_id BIGINT NOT NULL, + item_code VARCHAR(50) NOT NULL, + item_name VARCHAR(200) NOT NULL, + item_type VARCHAR(32) NOT NULL, + lot_id BIGINT NULL, + lot_no VARCHAR(80) NULL, + source_material_sub_batch_no VARCHAR(80) NULL, + snapshot_qty DECIMAL(18,6) NOT NULL DEFAULT 0, + snapshot_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0, + snapshot_unit_cost DECIMAL(18,4) NOT NULL DEFAULT 0, + counted_qty DECIMAL(18,6) NULL, + counted_weight_kg DECIMAL(18,6) NULL, + diff_qty DECIMAL(18,6) NOT NULL DEFAULT 0, + diff_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0, + diff_amount DECIMAL(18,2) NOT NULL DEFAULT 0, + diff_type VARCHAR(32) NOT NULL, + row_status VARCHAR(32) NOT NULL, + error_message VARCHAR(500) NULL, + remark VARCHAR(255) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_wh_stocktake_line_stocktake (stocktake_id), + INDEX idx_wh_stocktake_line_lot (lot_id), + INDEX idx_wh_stocktake_line_diff (diff_type, row_status), + CONSTRAINT fk_stocktake_line_stocktake FOREIGN KEY (stocktake_id) REFERENCES wh_stocktake(id), + CONSTRAINT fk_stocktake_line_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse(id), + CONSTRAINT fk_stocktake_line_item FOREIGN KEY (item_id) REFERENCES md_item(id) +); + +CREATE TABLE IF NOT EXISTS wh_stocktake_adjustment ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stocktake_id BIGINT NOT NULL, + stocktake_line_id BIGINT NOT NULL, + warehouse_id BIGINT NOT NULL, + item_id BIGINT NOT NULL, + lot_id BIGINT NULL, + adjustment_lot_id BIGINT NULL, + inventory_txn_id BIGINT NULL, + adjustment_type VARCHAR(32) NOT NULL, + before_qty DECIMAL(18,6) NOT NULL DEFAULT 0, + after_qty DECIMAL(18,6) NOT NULL DEFAULT 0, + before_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0, + after_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0, + qty_change DECIMAL(18,6) NOT NULL DEFAULT 0, + weight_change_kg DECIMAL(18,6) NOT NULL DEFAULT 0, + unit_cost DECIMAL(18,4) NOT NULL DEFAULT 0, + amount DECIMAL(18,2) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_wh_stocktake_adjustment_stocktake (stocktake_id), + CONSTRAINT fk_stocktake_adjustment_stocktake FOREIGN KEY (stocktake_id) REFERENCES wh_stocktake(id), + CONSTRAINT fk_stocktake_adjustment_line FOREIGN KEY (stocktake_line_id) REFERENCES wh_stocktake_line(id) +); diff --git a/backend/sql/add_system_extension_patch.sql b/backend/sql/add_system_extension_patch.sql new file mode 100644 index 0000000..07da7c6 --- /dev/null +++ b/backend/sql/add_system_extension_patch.sql @@ -0,0 +1,51 @@ +CREATE TABLE IF NOT EXISTS sys_broadcast_message ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + title VARCHAR(100) NOT NULL COMMENT '广播标题', + content VARCHAR(500) NOT NULL COMMENT '广播内容', + tone VARCHAR(32) NOT NULL DEFAULT 'INFO' COMMENT '提示类型:INFO/SUCCESS/WARNING/DANGER', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/PAUSED', + starts_at DATETIME NULL COMMENT '开始展示时间', + ends_at DATETIME NULL COMMENT '结束展示时间', + sort_no INT NOT NULL DEFAULT 0 COMMENT '排序号,越大越优先', + created_by BIGINT NULL COMMENT '创建用户ID', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + KEY idx_broadcast_status_time (status, starts_at, ends_at), + CONSTRAINT fk_broadcast_created_by FOREIGN KEY (created_by) REFERENCES sys_user(id) +) ENGINE=InnoDB COMMENT='系统广播消息'; + +CREATE TABLE IF NOT EXISTS sys_ai_assistant_config ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + assistant_name VARCHAR(100) NOT NULL DEFAULT '嘉恒工艺助手' COMMENT '助手名称', + provider_name VARCHAR(100) NULL COMMENT 'LLM供应商名称', + api_base_url VARCHAR(255) NULL COMMENT 'LLM API基础地址', + api_key VARCHAR(512) NULL COMMENT 'LLM API密钥', + model_name VARCHAR(100) NULL COMMENT '模型名称', + temperature DECIMAL(6,3) NOT NULL DEFAULT 0.300 COMMENT '温度参数', + system_prompt TEXT NULL COMMENT '新建对话系统提示词', + enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用真实LLM', + created_by BIGINT NULL COMMENT '创建用户ID', + updated_by BIGINT NULL COMMENT '更新用户ID', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + CONSTRAINT fk_ai_config_created_by FOREIGN KEY (created_by) REFERENCES sys_user(id), + CONSTRAINT fk_ai_config_updated_by FOREIGN KEY (updated_by) REFERENCES sys_user(id) +) ENGINE=InnoDB COMMENT='AI助手配置'; + +INSERT INTO sys_permission (permission_code, permission_name, module_code, action_code, status) +SELECT 'MENU_SYSTEM_EXTENSION', '系统拓展菜单', 'SYSTEM_EXTENSION', 'VIEW', 'ACTIVE' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_permission WHERE permission_code = 'MENU_SYSTEM_EXTENSION'); + +INSERT INTO sys_role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM sys_role r +JOIN sys_permission p ON p.permission_code = 'MENU_SYSTEM_EXTENSION' +LEFT JOIN sys_role_permission existing ON existing.role_id = r.id AND existing.permission_id = p.id +WHERE r.role_code = 'ADMIN' + AND existing.id IS NULL; + +INSERT INTO sys_broadcast_message (title, content, tone, status, sort_no) +SELECT '系统提示', '系统拓展模块已启用,后续通知会在右上角喇叭横幅展示。', 'INFO', 'ACTIVE', 10 +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_broadcast_message); diff --git a/backend/sql/add_system_permission_management_patch.sql b/backend/sql/add_system_permission_management_patch.sql new file mode 100644 index 0000000..12a31d2 --- /dev/null +++ b/backend/sql/add_system_permission_management_patch.sql @@ -0,0 +1,88 @@ +USE jiaheng_erp; + +SET NAMES utf8mb4; + +START TRANSACTION; + +SET @schema_name := DATABASE(); + +SET @sql := ( + SELECT IF( + EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @schema_name + AND table_name = 'sys_department' + AND column_name = 'org_node_type' + ), + 'SELECT 1', + 'ALTER TABLE sys_department ADD COLUMN org_node_type VARCHAR(32) NOT NULL DEFAULT ''DEPARTMENT'' COMMENT ''组织节点类型:COMPANY/BRANCH/DEPARTMENT'' AFTER parent_id' + ) +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql := ( + SELECT IF( + EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = @schema_name + AND table_name = 'sys_department' + AND column_name = 'manager_employee_id' + ), + 'SELECT 1', + 'ALTER TABLE sys_department ADD COLUMN manager_employee_id BIGINT NULL COMMENT ''负责人员工ID'' AFTER manager_name' + ) +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql := ( + SELECT IF( + EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = @schema_name + AND table_name = 'sys_department' + AND index_name = 'idx_department_parent_sort' + ), + 'SELECT 1', + 'CREATE INDEX idx_department_parent_sort ON sys_department(parent_id, sort_no, id)' + ) +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +INSERT INTO sys_permission (permission_code, permission_name, module_code, action_code, status) +SELECT 'MENU_SYSTEM_PERMISSION', '系统权限管理菜单', 'SYSTEM_PERMISSION', 'VIEW', 'ACTIVE' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_permission WHERE permission_code = 'MENU_SYSTEM_PERMISSION'); + +UPDATE sys_role +SET role_name = '超级管理员', remark = '系统全局权限、角色、人员账号维护' +WHERE role_code = 'ADMIN'; + +UPDATE sys_role +SET role_name = '采购专员', remark = '供应商、采购订单、到货和采购相关库存查询' +WHERE role_code = 'PURCHASER'; + +UPDATE sys_role +SET role_name = '仓库管理人员', remark = '到货、质检、嘉恒仓库、发货台账相关作业' +WHERE role_code = 'WAREHOUSE'; + +UPDATE sys_role +SET role_name = '销售人员', remark = '客户、销售订单、发货台账查询与协同' +WHERE role_code = 'SALES'; + +INSERT INTO sys_role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM sys_role r +JOIN sys_permission p ON p.permission_code IN ('MENU_SYSTEM_PERMISSION', 'MENU_SYSTEM_EXTENSION') +LEFT JOIN sys_role_permission existing ON existing.role_id = r.id AND existing.permission_id = p.id +WHERE r.role_code = 'ADMIN' AND existing.id IS NULL; + +COMMIT; diff --git a/backend/sql/add_system_permission_multi_managers_patch.sql b/backend/sql/add_system_permission_multi_managers_patch.sql new file mode 100644 index 0000000..98ace7f --- /dev/null +++ b/backend/sql/add_system_permission_multi_managers_patch.sql @@ -0,0 +1,43 @@ +USE jiaheng_erp; + +SET NAMES utf8mb4; + +START TRANSACTION; + +CREATE TABLE IF NOT EXISTS sys_org_manager_binding ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + dept_id BIGINT NOT NULL COMMENT '组织脑图节点ID', + employee_id BIGINT NOT NULL COMMENT '主管人员ID', + sort_no INT NOT NULL DEFAULT 0 COMMENT '排序号', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_org_manager_binding (dept_id, employee_id), + KEY idx_org_manager_binding_dept (dept_id, sort_no, id), + KEY idx_org_manager_binding_employee (employee_id), + CONSTRAINT fk_org_manager_binding_dept FOREIGN KEY (dept_id) REFERENCES sys_department(id), + CONSTRAINT fk_org_manager_binding_employee FOREIGN KEY (employee_id) REFERENCES hr_employee(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统权限组织脑图主管绑定表'; + +INSERT INTO sys_org_manager_binding ( + dept_id, + employee_id, + sort_no, + status +) +SELECT + d.id, + d.manager_employee_id, + 1, + 'ACTIVE' +FROM sys_department d +WHERE d.manager_employee_id IS NOT NULL + AND EXISTS (SELECT 1 FROM hr_employee e WHERE e.id = d.manager_employee_id) + AND NOT EXISTS ( + SELECT 1 + FROM sys_org_manager_binding b + WHERE b.dept_id = d.id + AND b.employee_id = d.manager_employee_id + ); + +COMMIT; diff --git a/backend/sql/add_system_permission_org_mindmap_patch.sql b/backend/sql/add_system_permission_org_mindmap_patch.sql new file mode 100644 index 0000000..98c97cd --- /dev/null +++ b/backend/sql/add_system_permission_org_mindmap_patch.sql @@ -0,0 +1,50 @@ +USE jiaheng_erp; + +SET NAMES utf8mb4; + +START TRANSACTION; + +CREATE TABLE IF NOT EXISTS sys_org_employee_binding ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + dept_id BIGINT NOT NULL COMMENT '组织脑图节点ID', + employee_id BIGINT NOT NULL COMMENT '人员ID', + sort_no INT NOT NULL DEFAULT 0 COMMENT '排序号', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_org_employee_binding (dept_id, employee_id), + KEY idx_org_employee_binding_dept (dept_id, sort_no, id), + KEY idx_org_employee_binding_employee (employee_id), + CONSTRAINT fk_org_employee_binding_dept FOREIGN KEY (dept_id) REFERENCES sys_department(id), + CONSTRAINT fk_org_employee_binding_employee FOREIGN KEY (employee_id) REFERENCES hr_employee(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统权限组织脑图人员绑定表'; + +INSERT INTO sys_department ( + dept_code, + dept_name, + parent_id, + org_node_type, + dept_type, + manager_name, + manager_employee_id, + status, + sort_no, + remark +) +SELECT + 'ORG_ROOT', + '总公司', + NULL, + 'COMPANY', + 'ADMIN', + NULL, + NULL, + 'ACTIVE', + 0, + '系统权限管理组织脑图根节点' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_department WHERE dept_code = 'ORG_ROOT' +); + +COMMIT; diff --git a/backend/sql/fix_trash_pad_bom_route.sql b/backend/sql/fix_trash_pad_bom_route.sql new file mode 100644 index 0000000..8bff3f8 --- /dev/null +++ b/backend/sql/fix_trash_pad_bom_route.sql @@ -0,0 +1,223 @@ +USE jiaheng_erp; + +SET NAMES utf8mb4; + +START TRANSACTION; + +-- 垃圾桶垫默认 BOM +INSERT INTO md_bom ( + bom_code, product_item_id, version_no, is_default, status, effective_date, expire_date, remark +) +SELECT + 'BOM-BT-002', + (SELECT id FROM md_item WHERE item_code = 'FG-BT-002'), + 'V1.0', 1, 'ACTIVE', CURRENT_DATE(), NULL, + '垃圾桶垫默认BOM:单件净重1.6kg,按SAP440钢板毛重1.82kg计入MRP' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 + FROM md_bom + WHERE product_item_id = (SELECT id FROM md_item WHERE item_code = 'FG-BT-002') + AND is_default = 1 + AND status = 'ACTIVE' +); + +INSERT INTO md_bom_item ( + bom_id, seq_no, material_item_id, usage_type, qty_per_piece, net_weight_per_piece_kg, + gross_weight_per_piece_kg, loss_rate, issue_over_tolerance_rate, remark +) +SELECT + (SELECT id FROM md_bom WHERE product_item_id = (SELECT id FROM md_item WHERE item_code = 'FG-BT-002') AND is_default = 1 AND status = 'ACTIVE' ORDER BY id DESC LIMIT 1), + 10, + (SELECT id FROM md_item WHERE item_code = 'RM-SAP440-500600-2.5'), + 'WEIGHT', 1.000000, 1.600000, 1.820000, 0.1375, 0.0200, + '垃圾桶垫按SAP440钢板计料;MRP按1.82kg/件计算,采购仍按2吨倍数向上取整' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 + FROM md_bom_item + WHERE bom_id = ( + SELECT id FROM md_bom + WHERE product_item_id = (SELECT id FROM md_item WHERE item_code = 'FG-BT-002') + AND is_default = 1 + AND status = 'ACTIVE' + ORDER BY id DESC LIMIT 1 + ) + AND seq_no = 10 +); + +-- 垃圾桶垫默认工艺路线 +INSERT INTO md_process_route ( + route_code, route_name, product_item_id, version_no, is_default, status, effective_date, expire_date, remark +) +SELECT + 'ROUTE-BT-002', '垃圾桶垫标准工艺路线', + (SELECT id FROM md_item WHERE item_code = 'FG-BT-002'), + 'V1.0', 1, 'ACTIVE', CURRENT_DATE(), NULL, + '适用于垃圾桶垫的默认冲压件六道工序路线' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 + FROM md_process_route + WHERE product_item_id = (SELECT id FROM md_item WHERE item_code = 'FG-BT-002') + AND is_default = 1 + AND status = 'ACTIVE' +); + +INSERT INTO md_process_route_operation ( + route_id, seq_no, process_id, work_center_id, operation_name, + std_setup_minutes, std_cycle_seconds, std_labor_cost, std_machine_cost, std_outsource_cost, + allowed_scrap_rate, report_required, is_key_operation, status, remark +) +SELECT + (SELECT id FROM md_process_route WHERE product_item_id = (SELECT id FROM md_item WHERE item_code = 'FG-BT-002') AND is_default = 1 AND status = 'ACTIVE' ORDER BY id DESC LIMIT 1), + 10, + (SELECT id FROM md_process WHERE process_code = 'PROC-KP'), + (SELECT id FROM md_work_center WHERE center_code = 'WC-KP-01'), + '开平', + 12.00, 4.00, 0.08, 0.10, 0.00, 0.0030, 1, 0, 'ACTIVE', '垃圾桶垫板材整平与下料准备' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 + FROM md_process_route_operation + WHERE route_id = ( + SELECT id FROM md_process_route + WHERE product_item_id = (SELECT id FROM md_item WHERE item_code = 'FG-BT-002') + AND is_default = 1 + AND status = 'ACTIVE' + ORDER BY id DESC LIMIT 1 + ) + AND seq_no = 10 +); + +INSERT INTO md_process_route_operation ( + route_id, seq_no, process_id, work_center_id, operation_name, + std_setup_minutes, std_cycle_seconds, std_labor_cost, std_machine_cost, std_outsource_cost, + allowed_scrap_rate, report_required, is_key_operation, status, remark +) +SELECT + (SELECT id FROM md_process_route WHERE product_item_id = (SELECT id FROM md_item WHERE item_code = 'FG-BT-002') AND is_default = 1 AND status = 'ACTIVE' ORDER BY id DESC LIMIT 1), + 20, + (SELECT id FROM md_process WHERE process_code = 'PROC-CY'), + (SELECT id FROM md_work_center WHERE center_code = 'WC-CY-01'), + '冲压', + 20.00, 6.00, 0.18, 0.24, 0.00, 0.0150, 1, 1, 'ACTIVE', '垃圾桶垫主成形工序,重点监控毛边与裂纹' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 + FROM md_process_route_operation + WHERE route_id = ( + SELECT id FROM md_process_route + WHERE product_item_id = (SELECT id FROM md_item WHERE item_code = 'FG-BT-002') + AND is_default = 1 + AND status = 'ACTIVE' + ORDER BY id DESC LIMIT 1 + ) + AND seq_no = 20 +); + +INSERT INTO md_process_route_operation ( + route_id, seq_no, process_id, work_center_id, operation_name, + std_setup_minutes, std_cycle_seconds, std_labor_cost, std_machine_cost, std_outsource_cost, + allowed_scrap_rate, report_required, is_key_operation, status, remark +) +SELECT + (SELECT id FROM md_process_route WHERE product_item_id = (SELECT id FROM md_item WHERE item_code = 'FG-BT-002') AND is_default = 1 AND status = 'ACTIVE' ORDER BY id DESC LIMIT 1), + 30, + (SELECT id FROM md_process WHERE process_code = 'PROC-MC'), + (SELECT id FROM md_work_center WHERE center_code = 'WC-MC-01'), + '去毛刺', + 10.00, 4.00, 0.06, 0.05, 0.00, 0.0040, 1, 0, 'ACTIVE', '清理冲压毛边与锐边' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 + FROM md_process_route_operation + WHERE route_id = ( + SELECT id FROM md_process_route + WHERE product_item_id = (SELECT id FROM md_item WHERE item_code = 'FG-BT-002') + AND is_default = 1 + AND status = 'ACTIVE' + ORDER BY id DESC LIMIT 1 + ) + AND seq_no = 30 +); + +INSERT INTO md_process_route_operation ( + route_id, seq_no, process_id, work_center_id, operation_name, + std_setup_minutes, std_cycle_seconds, std_labor_cost, std_machine_cost, std_outsource_cost, + allowed_scrap_rate, report_required, is_key_operation, status, remark +) +SELECT + (SELECT id FROM md_process_route WHERE product_item_id = (SELECT id FROM md_item WHERE item_code = 'FG-BT-002') AND is_default = 1 AND status = 'ACTIVE' ORDER BY id DESC LIMIT 1), + 40, + (SELECT id FROM md_process WHERE process_code = 'PROC-ZW'), + (SELECT id FROM md_work_center WHERE center_code = 'WC-ZW-01'), + '折弯', + 14.00, 5.00, 0.12, 0.12, 0.00, 0.0060, 1, 1, 'ACTIVE', '局部折边成形,控制角度一致性' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 + FROM md_process_route_operation + WHERE route_id = ( + SELECT id FROM md_process_route + WHERE product_item_id = (SELECT id FROM md_item WHERE item_code = 'FG-BT-002') + AND is_default = 1 + AND status = 'ACTIVE' + ORDER BY id DESC LIMIT 1 + ) + AND seq_no = 40 +); + +INSERT INTO md_process_route_operation ( + route_id, seq_no, process_id, work_center_id, operation_name, + std_setup_minutes, std_cycle_seconds, std_labor_cost, std_machine_cost, std_outsource_cost, + allowed_scrap_rate, report_required, is_key_operation, status, remark +) +SELECT + (SELECT id FROM md_process_route WHERE product_item_id = (SELECT id FROM md_item WHERE item_code = 'FG-BT-002') AND is_default = 1 AND status = 'ACTIVE' ORDER BY id DESC LIMIT 1), + 50, + (SELECT id FROM md_process WHERE process_code = 'PROC-HJ'), + (SELECT id FROM md_work_center WHERE center_code = 'WC-HJ-01'), + '焊接', + 18.00, 8.00, 0.20, 0.10, 0.00, 0.0050, 1, 1, 'ACTIVE', '必要焊点补强与外观控制' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 + FROM md_process_route_operation + WHERE route_id = ( + SELECT id FROM md_process_route + WHERE product_item_id = (SELECT id FROM md_item WHERE item_code = 'FG-BT-002') + AND is_default = 1 + AND status = 'ACTIVE' + ORDER BY id DESC LIMIT 1 + ) + AND seq_no = 50 +); + +INSERT INTO md_process_route_operation ( + route_id, seq_no, process_id, work_center_id, operation_name, + std_setup_minutes, std_cycle_seconds, std_labor_cost, std_machine_cost, std_outsource_cost, + allowed_scrap_rate, report_required, is_key_operation, status, remark +) +SELECT + (SELECT id FROM md_process_route WHERE product_item_id = (SELECT id FROM md_item WHERE item_code = 'FG-BT-002') AND is_default = 1 AND status = 'ACTIVE' ORDER BY id DESC LIMIT 1), + 60, + (SELECT id FROM md_process WHERE process_code = 'PROC-QA'), + (SELECT id FROM md_work_center WHERE center_code = 'WC-QA-01'), + '检验包装', + 8.00, 6.00, 0.09, 0.02, 0.00, 0.0020, 1, 1, 'ACTIVE', '终检确认外观、尺寸、包装完整' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 + FROM md_process_route_operation + WHERE route_id = ( + SELECT id FROM md_process_route + WHERE product_item_id = (SELECT id FROM md_item WHERE item_code = 'FG-BT-002') + AND is_default = 1 + AND status = 'ACTIVE' + ORDER BY id DESC LIMIT 1 + ) + AND seq_no = 60 +); + +COMMIT; diff --git a/backend/sql/jiaheng_erp_column_comments_patch.sql b/backend/sql/jiaheng_erp_column_comments_patch.sql new file mode 100644 index 0000000..e45fb28 --- /dev/null +++ b/backend/sql/jiaheng_erp_column_comments_patch.sql @@ -0,0 +1,801 @@ +USE jiaheng_erp; + +SET NAMES utf8mb4; + +-- 已建库后补充字段注释脚本 +-- 执行前请先在测试库验证 + +ALTER TABLE sys_department + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN dept_code VARCHAR(50) NOT NULL COMMENT '部门编码', + MODIFY COLUMN dept_name VARCHAR(100) NOT NULL COMMENT '部门名称', + MODIFY COLUMN parent_id BIGINT NULL COMMENT '上级ID', + MODIFY COLUMN dept_type VARCHAR(32) NOT NULL COMMENT '部门类型:ADMIN/SALES/PURCHASE/WAREHOUSE/PRODUCTION/FINANCE/EQUIPMENT', + MODIFY COLUMN manager_name VARCHAR(100) NULL COMMENT '负责人姓名', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + MODIFY COLUMN sort_no INT NOT NULL DEFAULT 0 COMMENT '排序号', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE hr_employee + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN employee_code VARCHAR(50) NOT NULL COMMENT '员工编码', + MODIFY COLUMN employee_name VARCHAR(100) NOT NULL COMMENT '员工姓名', + MODIFY COLUMN dept_id BIGINT NOT NULL COMMENT '部门ID', + MODIFY COLUMN mobile VARCHAR(30) NULL COMMENT '手机号', + MODIFY COLUMN gender VARCHAR(16) NULL COMMENT '性别', + MODIFY COLUMN hire_date DATE NULL COMMENT '入职日期', + MODIFY COLUMN job_title VARCHAR(100) NULL COMMENT '岗位名称', + MODIFY COLUMN shift_code VARCHAR(32) NULL COMMENT '班次编码', + MODIFY COLUMN manager_employee_id BIGINT NULL COMMENT '上级员工ID', + MODIFY COLUMN is_operator TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否操作工', + MODIFY COLUMN is_workshop_staff TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否车间人员', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE sys_role + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN role_code VARCHAR(50) NOT NULL COMMENT '角色编码', + MODIFY COLUMN role_name VARCHAR(100) NOT NULL COMMENT '角色名称', + MODIFY COLUMN role_scope VARCHAR(32) NOT NULL DEFAULT 'SYSTEM' COMMENT '角色范围', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE sys_permission + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN permission_code VARCHAR(100) NOT NULL COMMENT '权限编码', + MODIFY COLUMN permission_name VARCHAR(100) NOT NULL COMMENT '权限名称', + MODIFY COLUMN module_code VARCHAR(50) NOT NULL COMMENT '模块编码', + MODIFY COLUMN action_code VARCHAR(50) NOT NULL COMMENT '操作编码', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE sys_role_permission + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN role_id BIGINT NOT NULL COMMENT '角色ID', + MODIFY COLUMN permission_id BIGINT NOT NULL COMMENT '权限ID', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'; + +ALTER TABLE sys_user + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN username VARCHAR(50) NOT NULL COMMENT '登录账号', + MODIFY COLUMN password_hash VARCHAR(255) NOT NULL COMMENT '密码哈希', + MODIFY COLUMN employee_id BIGINT NOT NULL COMMENT '员工ID', + MODIFY COLUMN dept_id BIGINT NOT NULL COMMENT '部门ID', + MODIFY COLUMN nickname VARCHAR(100) NULL COMMENT '昵称', + MODIFY COLUMN email VARCHAR(100) NULL COMMENT '邮箱', + MODIFY COLUMN is_super_admin TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否超级管理员', + MODIFY COLUMN last_login_at DATETIME NULL COMMENT '最后登录时间', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE sys_user_role + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN user_id BIGINT NOT NULL COMMENT '用户ID', + MODIFY COLUMN role_id BIGINT NOT NULL COMMENT '角色ID', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'; + +ALTER TABLE md_unit + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN unit_code VARCHAR(50) NOT NULL COMMENT '单位编码', + MODIFY COLUMN unit_name VARCHAR(50) NOT NULL COMMENT '单位名称', + MODIFY COLUMN precision_digits INT NOT NULL DEFAULT 3 COMMENT '小数精度位数', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE md_customer + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN customer_code VARCHAR(50) NOT NULL COMMENT '客户编码', + MODIFY COLUMN customer_name VARCHAR(200) NOT NULL COMMENT '客户名称', + MODIFY COLUMN short_name VARCHAR(100) NULL COMMENT '简称', + MODIFY COLUMN contact_name VARCHAR(100) NULL COMMENT '联系人', + MODIFY COLUMN contact_phone VARCHAR(50) NULL COMMENT '联系电话', + MODIFY COLUMN address VARCHAR(255) NULL COMMENT '地址', + MODIFY COLUMN tax_no VARCHAR(100) NULL COMMENT '税号', + MODIFY COLUMN credit_days INT NOT NULL DEFAULT 0 COMMENT '账期天数', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE md_supplier + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN supplier_code VARCHAR(50) NOT NULL COMMENT '供应商编码', + MODIFY COLUMN supplier_name VARCHAR(200) NOT NULL COMMENT '供应商名称', + MODIFY COLUMN short_name VARCHAR(100) NULL COMMENT '简称', + MODIFY COLUMN contact_name VARCHAR(100) NULL COMMENT '联系人', + MODIFY COLUMN contact_phone VARCHAR(50) NULL COMMENT '联系电话', + MODIFY COLUMN address VARCHAR(255) NULL COMMENT '地址', + MODIFY COLUMN lead_time_days INT NOT NULL DEFAULT 0 COMMENT '采购提前期天数', + MODIFY COLUMN default_tax_rate DECIMAL(8,4) NOT NULL DEFAULT 0 COMMENT '默认税率', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE md_item_category + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN category_code VARCHAR(50) NOT NULL COMMENT '分类编码', + MODIFY COLUMN category_name VARCHAR(100) NOT NULL COMMENT '分类名称', + MODIFY COLUMN parent_id BIGINT NULL COMMENT '上级ID', + MODIFY COLUMN item_type VARCHAR(32) NOT NULL COMMENT '物料类型', + MODIFY COLUMN sort_no INT NOT NULL DEFAULT 0 COMMENT '排序号', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE md_item + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN item_code VARCHAR(50) NOT NULL COMMENT '物料编码', + MODIFY COLUMN item_name VARCHAR(200) NOT NULL COMMENT '物料名称', + MODIFY COLUMN item_type VARCHAR(32) NOT NULL COMMENT '物料类型:RAW_MATERIAL/FINISHED_GOOD/SEMI_FINISHED/SCRAP/SPARE_PART', + MODIFY COLUMN category_id BIGINT NULL COMMENT '分类ID', + MODIFY COLUMN specification VARCHAR(200) NULL COMMENT '规格', + MODIFY COLUMN model_no VARCHAR(100) NULL COMMENT '型号', + MODIFY COLUMN material_type VARCHAR(100) NULL COMMENT '材料类型', + MODIFY COLUMN material_grade VARCHAR(100) NULL COMMENT '材质牌号', + MODIFY COLUMN length_mm DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '长度(mm)', + MODIFY COLUMN width_mm DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '宽度(mm)', + MODIFY COLUMN thickness_mm DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '厚度(mm)', + MODIFY COLUMN density_kg_m3 DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '密度(kg/m3)', + MODIFY COLUMN theoretical_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '理论重量(kg)', + MODIFY COLUMN unit_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '实际管理用单件或单批重量', + MODIFY COLUMN sales_unit_id BIGINT NULL COMMENT '销售单位ID', + MODIFY COLUMN stock_unit_id BIGINT NULL COMMENT '库存单位ID', + MODIFY COLUMN purchase_unit_id BIGINT NULL COMMENT '采购单位ID', + MODIFY COLUMN safety_stock_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '安全库存数量', + MODIFY COLUMN safety_stock_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '安全库存重量(kg)', + MODIFY COLUMN scrap_sale_price DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '废料销售单价', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE md_material + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN item_id BIGINT NOT NULL COMMENT '物料ID', + MODIFY COLUMN default_supplier_id BIGINT NULL COMMENT '默认供应商ID', + MODIFY COLUMN purchase_calc_mode VARCHAR(32) NOT NULL DEFAULT 'BY_WEIGHT' COMMENT '采购计算方式', + MODIFY COLUMN min_purchase_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '最小采购数量', + MODIFY COLUMN purchase_multiple_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '采购倍数数量', + MODIFY COLUMN purchase_multiple_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '采购倍数重量(kg)', + MODIFY COLUMN lead_time_days INT NOT NULL DEFAULT 0 COMMENT '采购提前期天数', + MODIFY COLUMN quality_rule VARCHAR(255) NULL COMMENT '质检规则', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE md_product + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN item_id BIGINT NOT NULL COMMENT '物料ID', + MODIFY COLUMN drawing_no VARCHAR(100) NULL COMMENT '图号', + MODIFY COLUMN product_family VARCHAR(100) NULL COMMENT '产品系列', + MODIFY COLUMN version_no VARCHAR(50) NOT NULL DEFAULT 'V1.0' COMMENT '版本号', + MODIFY COLUMN standard_output_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '标准产出数量', + MODIFY COLUMN allowed_scrap_rate DECIMAL(8,4) NOT NULL DEFAULT 0 COMMENT '允许报废率', + MODIFY COLUMN quality_standard VARCHAR(255) NULL COMMENT '质量标准', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE md_process + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN process_code VARCHAR(50) NOT NULL COMMENT '工序编码', + MODIFY COLUMN process_name VARCHAR(100) NOT NULL COMMENT '工序名称', + MODIFY COLUMN process_type VARCHAR(50) NOT NULL COMMENT '工序类型', + MODIFY COLUMN is_report_required TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否必须报工', + MODIFY COLUMN is_quality_gate TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否质量关卡', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE md_work_center + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN center_code VARCHAR(50) NOT NULL COMMENT '工作中心编码', + MODIFY COLUMN center_name VARCHAR(100) NOT NULL COMMENT '工作中心名称', + MODIFY COLUMN dept_id BIGINT NOT NULL COMMENT '部门ID', + MODIFY COLUMN center_type VARCHAR(50) NOT NULL COMMENT '工作中心类型', + MODIFY COLUMN shift_pattern VARCHAR(50) NULL COMMENT '班制', + MODIFY COLUMN capacity_hours_per_day DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '日产能工时', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE md_process_route + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN route_code VARCHAR(50) NOT NULL COMMENT '工艺路线编码', + MODIFY COLUMN route_name VARCHAR(100) NOT NULL COMMENT '工艺路线名称', + MODIFY COLUMN product_item_id BIGINT NOT NULL COMMENT '产品物料ID', + MODIFY COLUMN version_no VARCHAR(50) NOT NULL DEFAULT 'V1.0' COMMENT '版本号', + MODIFY COLUMN is_default TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否默认', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + MODIFY COLUMN effective_date DATE NULL COMMENT '生效日期', + MODIFY COLUMN expire_date DATE NULL COMMENT '失效日期', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE md_process_route_operation + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN route_id BIGINT NOT NULL COMMENT '工艺路线ID', + MODIFY COLUMN seq_no INT NOT NULL COMMENT '序号', + MODIFY COLUMN process_id BIGINT NOT NULL COMMENT '工序ID', + MODIFY COLUMN work_center_id BIGINT NOT NULL COMMENT '工作中心ID', + MODIFY COLUMN operation_name VARCHAR(100) NOT NULL COMMENT '工序名称', + MODIFY COLUMN std_setup_minutes DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '标准准备时长(分钟)', + MODIFY COLUMN std_cycle_seconds DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '标准节拍(秒/件)', + MODIFY COLUMN std_labor_cost DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '标准人工成本', + MODIFY COLUMN std_machine_cost DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '标准设备成本', + MODIFY COLUMN std_outsource_cost DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '标准委外成本', + MODIFY COLUMN allowed_scrap_rate DECIMAL(8,4) NOT NULL DEFAULT 0 COMMENT '允许报废率', + MODIFY COLUMN report_required TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否要求报工', + MODIFY COLUMN is_key_operation TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否关键工序', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE md_bom + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN bom_code VARCHAR(50) NOT NULL COMMENT 'BOM编码', + MODIFY COLUMN product_item_id BIGINT NOT NULL COMMENT '产品物料ID', + MODIFY COLUMN version_no VARCHAR(50) NOT NULL DEFAULT 'V1.0' COMMENT '版本号', + MODIFY COLUMN is_default TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否默认', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + MODIFY COLUMN effective_date DATE NULL COMMENT '生效日期', + MODIFY COLUMN expire_date DATE NULL COMMENT '失效日期', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE md_bom_item + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN bom_id BIGINT NOT NULL COMMENT 'BOM ID', + MODIFY COLUMN seq_no INT NOT NULL COMMENT '序号', + MODIFY COLUMN material_item_id BIGINT NOT NULL COMMENT '原材料物料ID', + MODIFY COLUMN usage_type VARCHAR(32) NOT NULL DEFAULT 'WEIGHT' COMMENT '用量类型:WEIGHT/FIXED_QTY', + MODIFY COLUMN qty_per_piece DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '单件用量', + MODIFY COLUMN net_weight_per_piece_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '单件净重(kg)', + MODIFY COLUMN gross_weight_per_piece_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '单件毛重(kg)', + MODIFY COLUMN loss_rate DECIMAL(8,4) NOT NULL DEFAULT 0 COMMENT '损耗率', + MODIFY COLUMN issue_over_tolerance_rate DECIMAL(8,4) NOT NULL DEFAULT 0 COMMENT '领料超发容差率', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE em_equipment + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN equipment_code VARCHAR(50) NOT NULL COMMENT '设备编码', + MODIFY COLUMN equipment_name VARCHAR(100) NOT NULL COMMENT '设备名称', + MODIFY COLUMN category_name VARCHAR(100) NULL COMMENT '分类名称', + MODIFY COLUMN work_center_id BIGINT NOT NULL COMMENT '工作中心ID', + MODIFY COLUMN manufacturer VARCHAR(100) NULL COMMENT '制造商', + MODIFY COLUMN model_no VARCHAR(100) NULL COMMENT '型号', + MODIFY COLUMN install_date DATE NULL COMMENT '安装日期', + MODIFY COLUMN rated_power_kw DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '额定功率(kW)', + MODIFY COLUMN occupied_area_sqm DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '占用场地面积(平方米)', + MODIFY COLUMN maintenance_cycle_hours DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '保养周期(小时)', + MODIFY COLUMN last_maintenance_at DATETIME NULL COMMENT '上次保养时间', + MODIFY COLUMN next_maintenance_at DATETIME NULL COMMENT '下次保养时间', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'IDLE' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE em_maintenance_plan + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN plan_code VARCHAR(50) NOT NULL COMMENT '计划编码', + MODIFY COLUMN equipment_id BIGINT NOT NULL COMMENT '设备ID', + MODIFY COLUMN plan_name VARCHAR(100) NOT NULL COMMENT '计划名称', + MODIFY COLUMN maintenance_type VARCHAR(32) NOT NULL COMMENT '保养类型', + MODIFY COLUMN cycle_hours DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '周期小时数', + MODIFY COLUMN cycle_days INT NOT NULL DEFAULT 0 COMMENT '周期天数', + MODIFY COLUMN last_execute_at DATETIME NULL COMMENT '上次执行时间', + MODIFY COLUMN next_execute_at DATETIME NULL COMMENT '下次执行时间', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE em_maintenance_order + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN order_no VARCHAR(50) NOT NULL COMMENT '单据号', + MODIFY COLUMN equipment_id BIGINT NOT NULL COMMENT '设备ID', + MODIFY COLUMN plan_id BIGINT NULL COMMENT '计划ID', + MODIFY COLUMN maintenance_type VARCHAR(32) NOT NULL COMMENT '保养类型', + MODIFY COLUMN start_time DATETIME NULL COMMENT '开始时间', + MODIFY COLUMN end_time DATETIME NULL COMMENT '结束时间', + MODIFY COLUMN downtime_minutes DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '停机时长(分钟)', + MODIFY COLUMN owner_employee_id BIGINT NULL COMMENT '负责人员工ID', + MODIFY COLUMN result_summary VARCHAR(255) NULL COMMENT '结果摘要', + MODIFY COLUMN cost_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '成本金额', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'CREATED' COMMENT '状态', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE so_sales_order + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN order_no VARCHAR(50) NOT NULL COMMENT '单据号', + MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT '客户ID', + MODIFY COLUMN order_date DATE NOT NULL COMMENT '订单日期', + MODIFY COLUMN promised_date DATE NULL COMMENT '承诺交期', + MODIFY COLUMN sales_employee_id BIGINT NULL COMMENT '销售员员工ID', + MODIFY COLUMN delivery_address VARCHAR(255) NULL COMMENT '送货地址', + MODIFY COLUMN tax_rate DECIMAL(8,4) NOT NULL DEFAULT 0 COMMENT '税率', + MODIFY COLUMN total_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '总金额', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE so_sales_order_item + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN sales_order_id BIGINT NOT NULL COMMENT '订单ID', + MODIFY COLUMN line_no INT NOT NULL COMMENT '行号', + MODIFY COLUMN product_item_id BIGINT NOT NULL COMMENT '产品物料ID', + MODIFY COLUMN customer_part_no VARCHAR(100) NULL COMMENT '客户料号', + MODIFY COLUMN order_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '订单数量', + MODIFY COLUMN delivered_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '已发货数量', + MODIFY COLUMN unit_price DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '单价', + MODIFY COLUMN line_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '行金额', + MODIFY COLUMN promised_date DATE NULL COMMENT '承诺交期', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'OPEN' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE mrp_material_demand + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN demand_no VARCHAR(50) NOT NULL COMMENT '需求单号', + MODIFY COLUMN sales_order_item_id BIGINT NOT NULL COMMENT '订单明细ID', + MODIFY COLUMN product_item_id BIGINT NOT NULL COMMENT '产品物料ID', + MODIFY COLUMN material_item_id BIGINT NOT NULL COMMENT '原材料物料ID', + MODIFY COLUMN bom_item_id BIGINT NOT NULL COMMENT 'BOM明细ID', + MODIFY COLUMN order_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '订单数量', + MODIFY COLUMN theoretical_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '理论重量(kg)', + MODIFY COLUMN planned_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '计划重量(kg)', + MODIFY COLUMN available_stock_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '可用库存重量(kg)', + MODIFY COLUMN shortage_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '缺口重量(kg)', + MODIFY COLUMN suggested_purchase_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '建议采购数量', + MODIFY COLUMN suggested_purchase_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '建议采购重量(kg)', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'NEW' COMMENT '状态', + MODIFY COLUMN generated_at DATETIME NULL COMMENT '生成时间', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE po_purchase_order + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN po_no VARCHAR(50) NOT NULL COMMENT '采购订单号', + MODIFY COLUMN supplier_id BIGINT NOT NULL COMMENT 'SupplierID', + MODIFY COLUMN order_date DATE NOT NULL COMMENT '订单日期', + MODIFY COLUMN expected_date DATE NULL COMMENT '预计到货日期', + MODIFY COLUMN purchaser_employee_id BIGINT NULL COMMENT '采购员员工ID', + MODIFY COLUMN target_warehouse_type VARCHAR(32) NOT NULL DEFAULT 'RAW' COMMENT '目标仓库类型:RAW原材料库/AUX辅料库', + MODIFY COLUMN tax_rate DECIMAL(8,4) NOT NULL DEFAULT 0 COMMENT '税率', + MODIFY COLUMN total_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '总金额', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE po_purchase_order_item + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN purchase_order_id BIGINT NOT NULL COMMENT '采购订单ID', + MODIFY COLUMN line_no INT NOT NULL COMMENT '行号', + MODIFY COLUMN material_item_id BIGINT NOT NULL COMMENT '原材料物料ID', + MODIFY COLUMN source_demand_id BIGINT NULL COMMENT '来源需求ID', + MODIFY COLUMN order_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '订单数量', + MODIFY COLUMN order_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '订单重量(kg)', + MODIFY COLUMN received_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '已收货数量', + MODIFY COLUMN received_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '已收货重量(kg)', + MODIFY COLUMN unit_price DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '单价', + MODIFY COLUMN line_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '行金额', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'OPEN' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE wh_warehouse + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN warehouse_code VARCHAR(50) NOT NULL COMMENT '仓库编码', + MODIFY COLUMN warehouse_name VARCHAR(100) NOT NULL COMMENT '仓库名称', + MODIFY COLUMN warehouse_type VARCHAR(32) NOT NULL COMMENT '仓库类型:RAW/SEMI/FINISHED/AUX/RETURN/SCRAP', + MODIFY COLUMN manager_employee_id BIGINT NULL COMMENT '上级员工ID', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE wh_location + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN warehouse_id BIGINT NOT NULL COMMENT '仓库ID', + MODIFY COLUMN location_code VARCHAR(50) NOT NULL COMMENT '库位编码', + MODIFY COLUMN location_name VARCHAR(100) NOT NULL COMMENT '库位名称', + MODIFY COLUMN zone_name VARCHAR(100) NULL COMMENT '库区名称', + MODIFY COLUMN is_default TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否默认', + MODIFY COLUMN is_locked TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否锁定', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE po_receipt + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN receipt_no VARCHAR(50) NOT NULL COMMENT '入库单号', + MODIFY COLUMN purchase_order_id BIGINT NOT NULL COMMENT '采购订单ID', + MODIFY COLUMN warehouse_id BIGINT NOT NULL COMMENT '仓库ID', + MODIFY COLUMN receipt_date DATETIME NOT NULL COMMENT '收货日期', + MODIFY COLUMN receiver_employee_id BIGINT NULL COMMENT '接收人员工ID', + MODIFY COLUMN supplier_delivery_no VARCHAR(100) NULL COMMENT '供应商送货单号', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE po_receipt_item + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN receipt_id BIGINT NOT NULL COMMENT '单据ID', + MODIFY COLUMN line_no INT NOT NULL COMMENT '行号', + MODIFY COLUMN purchase_order_item_id BIGINT NOT NULL COMMENT '采购订单明细ID', + MODIFY COLUMN material_item_id BIGINT NOT NULL COMMENT '原材料物料ID', + MODIFY COLUMN location_id BIGINT NULL COMMENT '库位ID', + MODIFY COLUMN lot_no VARCHAR(50) NOT NULL COMMENT '批次号', + MODIFY COLUMN received_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '已收货数量', + MODIFY COLUMN received_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '已收货重量(kg)', + MODIFY COLUMN accepted_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '合格数量', + MODIFY COLUMN accepted_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '合格重量(kg)', + MODIFY COLUMN unit_cost DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '单位成本', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'PENDING_QC' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE wh_stock_lot + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN lot_no VARCHAR(50) NOT NULL COMMENT '批次号', + MODIFY COLUMN item_id BIGINT NOT NULL COMMENT '物料ID', + MODIFY COLUMN warehouse_id BIGINT NOT NULL COMMENT '仓库ID', + MODIFY COLUMN location_id BIGINT NULL COMMENT '库位ID', + MODIFY COLUMN source_doc_type VARCHAR(32) NOT NULL COMMENT '来源单据类型', + MODIFY COLUMN source_doc_id BIGINT NOT NULL COMMENT '来源单据ID', + MODIFY COLUMN source_line_id BIGINT NULL COMMENT '来源单据行ID', + MODIFY COLUMN inbound_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '入库数量', + MODIFY COLUMN inbound_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '入库重量(kg)', + MODIFY COLUMN remaining_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '剩余数量', + MODIFY COLUMN remaining_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '剩余重量(kg)', + MODIFY COLUMN locked_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '锁定数量', + MODIFY COLUMN locked_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '锁定重量(kg)', + MODIFY COLUMN unit_cost DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '单位成本', + MODIFY COLUMN production_date DATE NULL COMMENT '生产日期', + MODIFY COLUMN expire_date DATE NULL COMMENT '失效日期', + MODIFY COLUMN quality_status VARCHAR(32) NOT NULL DEFAULT 'PASS' COMMENT '质量状态', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'AVAILABLE' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE wh_stock_balance + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN item_id BIGINT NOT NULL COMMENT '物料ID', + MODIFY COLUMN warehouse_id BIGINT NOT NULL COMMENT '仓库ID', + MODIFY COLUMN location_id BIGINT NULL COMMENT '库位ID', + MODIFY COLUMN qty_on_hand DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '现存数量', + MODIFY COLUMN weight_on_hand_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '现存重量(kg)', + MODIFY COLUMN qty_available DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '可用数量', + MODIFY COLUMN weight_available_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '可用重量(kg)', + MODIFY COLUMN qty_allocated DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '已分配数量', + MODIFY COLUMN weight_allocated_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '已分配重量(kg)', + MODIFY COLUMN avg_unit_cost DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '平均单位成本', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE wh_inventory_txn + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN txn_no VARCHAR(50) NOT NULL COMMENT '流水号', + MODIFY COLUMN txn_type VARCHAR(32) NOT NULL COMMENT '流水类型:PURCHASE_IN/MATERIAL_ISSUE/FG_IN/SALES_OUT/RETURN_IN/RETURN_OUT/SCRAP_OUT/PRODUCTION_SCRAP_IN/OUTSOURCING_SCRAP_IN/SCRAP_SALE_OUT/ADJUST', + MODIFY COLUMN item_id BIGINT NOT NULL COMMENT '物料ID', + MODIFY COLUMN warehouse_id BIGINT NOT NULL COMMENT '仓库ID', + MODIFY COLUMN location_id BIGINT NULL COMMENT '库位ID', + MODIFY COLUMN lot_id BIGINT NULL COMMENT '批次ID', + MODIFY COLUMN qty_change DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '数量变动', + MODIFY COLUMN weight_change_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '重量变动(kg)', + MODIFY COLUMN unit_cost DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '单位成本', + MODIFY COLUMN amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '金额', + MODIFY COLUMN source_doc_type VARCHAR(32) NOT NULL COMMENT '来源单据类型', + MODIFY COLUMN source_doc_id BIGINT NOT NULL COMMENT '来源单据ID', + MODIFY COLUMN source_line_id BIGINT NULL COMMENT '来源单据行ID', + MODIFY COLUMN biz_time DATETIME NOT NULL COMMENT '业务时间', + MODIFY COLUMN operator_user_id BIGINT NULL COMMENT '操作人用户ID', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE pp_work_order + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN work_order_no VARCHAR(50) NOT NULL COMMENT '工单号', + MODIFY COLUMN work_order_type VARCHAR(32) NOT NULL DEFAULT 'NORMAL' COMMENT '工单类型:NORMAL/REWORK', + MODIFY COLUMN source_sales_order_item_id BIGINT NULL COMMENT '来源订单明细ID', + MODIFY COLUMN product_item_id BIGINT NOT NULL COMMENT '产品物料ID', + MODIFY COLUMN route_id BIGINT NOT NULL COMMENT '工艺路线ID', + MODIFY COLUMN bom_id BIGINT NOT NULL COMMENT 'BOM ID', + MODIFY COLUMN planned_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '计划数量', + MODIFY COLUMN released_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '下达数量', + MODIFY COLUMN finished_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '完工数量', + MODIFY COLUMN scrap_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '报废数量', + MODIFY COLUMN planned_start_time DATETIME NULL COMMENT '计划开始时间', + MODIFY COLUMN planned_end_time DATETIME NULL COMMENT '计划结束时间', + MODIFY COLUMN actual_start_time DATETIME NULL COMMENT '实际开始时间', + MODIFY COLUMN actual_end_time DATETIME NULL COMMENT '实际结束时间', + MODIFY COLUMN priority_level INT NOT NULL DEFAULT 3 COMMENT '优先级', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'CREATED' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE pp_work_order_material + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN work_order_id BIGINT NOT NULL COMMENT '工单ID', + MODIFY COLUMN bom_item_id BIGINT NOT NULL COMMENT 'BOM明细ID', + MODIFY COLUMN material_item_id BIGINT NOT NULL COMMENT '原材料物料ID', + MODIFY COLUMN required_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '需求数量', + MODIFY COLUMN required_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '需求重量(kg)', + MODIFY COLUMN issued_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '已领料数量', + MODIFY COLUMN issued_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '已领料重量(kg)', + MODIFY COLUMN returned_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '已退料数量', + MODIFY COLUMN returned_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '已退料重量(kg)', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'OPEN' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE pp_work_order_operation + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN work_order_id BIGINT NOT NULL COMMENT '工单ID', + MODIFY COLUMN route_operation_id BIGINT NOT NULL COMMENT '工艺路线工序ID', + MODIFY COLUMN seq_no INT NOT NULL COMMENT '序号', + MODIFY COLUMN operation_name VARCHAR(100) NOT NULL COMMENT '工序名称', + MODIFY COLUMN work_center_id BIGINT NOT NULL COMMENT '工作中心ID', + MODIFY COLUMN planned_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '计划数量', + MODIFY COLUMN report_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '报工数量', + MODIFY COLUMN good_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '合格数量', + MODIFY COLUMN scrap_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '报废数量', + MODIFY COLUMN rework_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '返工数量', + MODIFY COLUMN std_labor_cost DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '标准人工成本', + MODIFY COLUMN std_machine_cost DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '标准设备成本', + MODIFY COLUMN std_outsource_cost DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '标准委外成本', + MODIFY COLUMN allowed_scrap_rate DECIMAL(8,4) NOT NULL DEFAULT 0 COMMENT '允许报废率', + MODIFY COLUMN is_abnormal TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否异常', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE pp_material_issue + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN issue_no VARCHAR(50) NOT NULL COMMENT '领料单号', + MODIFY COLUMN work_order_id BIGINT NOT NULL COMMENT '工单ID', + MODIFY COLUMN warehouse_id BIGINT NOT NULL COMMENT '仓库ID', + MODIFY COLUMN issue_time DATETIME NOT NULL COMMENT '领料时间', + MODIFY COLUMN issuer_employee_id BIGINT NULL COMMENT '发料人员工ID', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'CREATED' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE pp_material_issue_item + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN material_issue_id BIGINT NOT NULL COMMENT '领料单ID', + MODIFY COLUMN work_order_material_id BIGINT NOT NULL COMMENT '工单用料ID', + MODIFY COLUMN material_item_id BIGINT NOT NULL COMMENT '原材料物料ID', + MODIFY COLUMN lot_id BIGINT NOT NULL COMMENT '批次ID', + MODIFY COLUMN location_id BIGINT NULL COMMENT '库位ID', + MODIFY COLUMN issue_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT 'Issue数量', + MODIFY COLUMN issue_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT 'issue_weight_kg', + MODIFY COLUMN unit_cost DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '单位成本', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'POSTED' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE pp_operation_report + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN report_no VARCHAR(50) NOT NULL COMMENT '报工单号', + MODIFY COLUMN work_order_id BIGINT NOT NULL COMMENT '工单ID', + MODIFY COLUMN work_order_operation_id BIGINT NOT NULL COMMENT '工单工序ID', + MODIFY COLUMN employee_id BIGINT NOT NULL COMMENT '员工ID', + MODIFY COLUMN equipment_id BIGINT NULL COMMENT '设备ID', + MODIFY COLUMN report_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '报工来源:MANUAL/MINI_APP/IMPORT', + MODIFY COLUMN shift_code VARCHAR(32) NULL COMMENT '班次编码', + MODIFY COLUMN start_time DATETIME NOT NULL COMMENT '开始时间', + MODIFY COLUMN end_time DATETIME NOT NULL COMMENT '结束时间', + MODIFY COLUMN report_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '报工数量', + MODIFY COLUMN good_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '合格数量', + MODIFY COLUMN scrap_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '报废数量', + MODIFY COLUMN rework_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '返工数量', + MODIFY COLUMN scrap_reason VARCHAR(255) NULL COMMENT '报废原因', + MODIFY COLUMN downtime_minutes DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '停机时长(分钟)', + MODIFY COLUMN extra_cost_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '额外成本金额', + MODIFY COLUMN is_abnormal TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否异常', + MODIFY COLUMN approved_by BIGINT NULL COMMENT '审批人用户ID', + MODIFY COLUMN approved_at DATETIME NULL COMMENT '审批时间', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE pp_scrap_record + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN scrap_no VARCHAR(50) NOT NULL COMMENT '报废单号', + MODIFY COLUMN report_id BIGINT NOT NULL COMMENT '报工记录ID', + MODIFY COLUMN work_order_id BIGINT NOT NULL COMMENT '工单ID', + MODIFY COLUMN work_order_operation_id BIGINT NOT NULL COMMENT '工单工序ID', + MODIFY COLUMN item_id BIGINT NOT NULL COMMENT '物料ID', + MODIFY COLUMN scrap_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '报废数量', + MODIFY COLUMN scrap_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '报废重量(kg)', + MODIFY COLUMN scrap_reason VARCHAR(255) NULL COMMENT '报废原因', + MODIFY COLUMN disposal_type VARCHAR(32) NOT NULL DEFAULT 'SCRAP' COMMENT '处置类型:SCRAP/REWORK', + MODIFY COLUMN saleable_scrap_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '可售废料重量(kg)', + MODIFY COLUMN scrap_cost_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '报废成本金额', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'CONFIRMED' COMMENT '状态', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE pp_completion_receipt + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN receipt_no VARCHAR(50) NOT NULL COMMENT '入库单号', + MODIFY COLUMN work_order_id BIGINT NOT NULL COMMENT '工单ID', + MODIFY COLUMN warehouse_id BIGINT NOT NULL COMMENT '仓库ID', + MODIFY COLUMN receipt_time DATETIME NOT NULL COMMENT '入库时间', + MODIFY COLUMN receiver_employee_id BIGINT NULL COMMENT '接收人员工ID', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'CREATED' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE pp_completion_receipt_item + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN completion_receipt_id BIGINT NOT NULL COMMENT '完工入库单ID', + MODIFY COLUMN line_no INT NOT NULL COMMENT '行号', + MODIFY COLUMN product_item_id BIGINT NOT NULL COMMENT '产品物料ID', + MODIFY COLUMN location_id BIGINT NULL COMMENT '库位ID', + MODIFY COLUMN lot_no VARCHAR(50) NOT NULL COMMENT '批次号', + MODIFY COLUMN receipt_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT 'Receipt数量', + MODIFY COLUMN receipt_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT 'receipt_weight_kg', + MODIFY COLUMN unit_cost DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '单位成本', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'POSTED' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE so_delivery + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN delivery_no VARCHAR(50) NOT NULL COMMENT '发货单号', + MODIFY COLUMN sales_order_id BIGINT NOT NULL COMMENT '订单ID', + MODIFY COLUMN warehouse_id BIGINT NOT NULL COMMENT '仓库ID', + MODIFY COLUMN delivery_date DATETIME NOT NULL COMMENT '发货时间', + MODIFY COLUMN shipper_employee_id BIGINT NULL COMMENT '发货人员工ID', + MODIFY COLUMN consignee_name VARCHAR(100) NULL COMMENT '收货人姓名', + MODIFY COLUMN consignee_phone VARCHAR(50) NULL COMMENT '收货人电话', + MODIFY COLUMN delivery_address VARCHAR(255) NULL COMMENT '送货地址', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'CREATED' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE so_delivery_item + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN delivery_id BIGINT NOT NULL COMMENT '发货单ID', + MODIFY COLUMN line_no INT NOT NULL COMMENT '行号', + MODIFY COLUMN sales_order_item_id BIGINT NOT NULL COMMENT '订单明细ID', + MODIFY COLUMN product_item_id BIGINT NOT NULL COMMENT '产品物料ID', + MODIFY COLUMN lot_id BIGINT NULL COMMENT '批次ID', + MODIFY COLUMN delivery_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '发货数量', + MODIFY COLUMN delivery_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '发货重量(kg)', + MODIFY COLUMN unit_price DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '单价', + MODIFY COLUMN line_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '行金额', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'POSTED' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE rt_return_order + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN return_no VARCHAR(50) NOT NULL COMMENT '退货单号', + MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT '客户ID', + MODIFY COLUMN sales_order_id BIGINT NULL COMMENT '订单ID', + MODIFY COLUMN delivery_id BIGINT NULL COMMENT '发货单ID', + MODIFY COLUMN return_date DATETIME NOT NULL COMMENT '退货时间', + MODIFY COLUMN return_reason VARCHAR(255) NULL COMMENT '退货原因', + MODIFY COLUMN handler_employee_id BIGINT NULL COMMENT '处理人员工ID', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'CREATED' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE rt_return_item + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN return_order_id BIGINT NOT NULL COMMENT '退货单ID', + MODIFY COLUMN line_no INT NOT NULL COMMENT '行号', + MODIFY COLUMN sales_order_item_id BIGINT NULL COMMENT '订单明细ID', + MODIFY COLUMN product_item_id BIGINT NOT NULL COMMENT '产品物料ID', + MODIFY COLUMN source_lot_id BIGINT NULL COMMENT '来源批次ID', + MODIFY COLUMN return_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '退货数量', + MODIFY COLUMN return_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '退货重量(kg)', + MODIFY COLUMN disposition_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '处置状态', + MODIFY COLUMN responsibility_type VARCHAR(32) NULL COMMENT '责任类型', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE rt_return_disposition + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN disposition_no VARCHAR(50) NOT NULL COMMENT '处置单号', + MODIFY COLUMN return_item_id BIGINT NOT NULL COMMENT '退货明细ID', + MODIFY COLUMN disposition_type VARCHAR(32) NOT NULL COMMENT '处置方式:REWORK/SCRAP', + MODIFY COLUMN rework_work_order_id BIGINT NULL COMMENT '返工工单ID', + MODIFY COLUMN scrap_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '报废重量(kg)', + MODIFY COLUMN extra_cost_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '额外成本金额', + MODIFY COLUMN scrap_sale_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '废料销售金额', + MODIFY COLUMN closed_at DATETIME NULL COMMENT '关闭时间', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'OPEN' COMMENT '状态', + MODIFY COLUMN remark VARCHAR(255) NULL COMMENT '备注', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE fi_accounting_period + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN period_code VARCHAR(20) NOT NULL COMMENT '期间编码', + MODIFY COLUMN year_no INT NOT NULL COMMENT '年度', + MODIFY COLUMN month_no INT NOT NULL COMMENT '月份', + MODIFY COLUMN start_date DATE NOT NULL COMMENT '开始日期', + MODIFY COLUMN end_date DATE NOT NULL COMMENT '结束日期', + MODIFY COLUMN close_status VARCHAR(32) NOT NULL DEFAULT 'OPEN' COMMENT '关账状态', + MODIFY COLUMN closed_at DATETIME NULL COMMENT '关闭时间', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE fi_overhead_entry + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN period_id BIGINT NOT NULL COMMENT '期间ID', + MODIFY COLUMN overhead_type VARCHAR(32) NOT NULL COMMENT '制造费用类型:RENT/UTILITY/LOGISTICS/ADMIN/DEPRECIATION/OTHER', + MODIFY COLUMN dept_id BIGINT NULL COMMENT '部门ID', + MODIFY COLUMN amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '金额', + MODIFY COLUMN allocation_basis VARCHAR(32) NOT NULL DEFAULT 'WORK_HOURS' COMMENT '分摊依据:WORK_HOURS/MACHINE_HOURS/QTY/OCCUPIED_AREA', + MODIFY COLUMN description VARCHAR(255) NULL COMMENT '说明', + MODIFY COLUMN created_by BIGINT NULL COMMENT '创建人用户ID', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE fi_cost_allocation + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN period_id BIGINT NOT NULL COMMENT '期间ID', + MODIFY COLUMN work_order_id BIGINT NOT NULL COMMENT '工单ID', + MODIFY COLUMN product_item_id BIGINT NOT NULL COMMENT '产品物料ID', + MODIFY COLUMN allocation_basis_value DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '分摊依据值', + MODIFY COLUMN overhead_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '制造费用金额', + MODIFY COLUMN material_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '材料金额', + MODIFY COLUMN labor_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '人工金额', + MODIFY COLUMN machine_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '设备金额', + MODIFY COLUMN abnormal_loss_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '异常损失金额', + MODIFY COLUMN total_cost_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '总成本金额', + MODIFY COLUMN unit_cost DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '单位成本', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; + +ALTER TABLE fi_statement_snapshot + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + MODIFY COLUMN snapshot_no VARCHAR(50) NOT NULL COMMENT '快照编号', + MODIFY COLUMN period_id BIGINT NOT NULL COMMENT '期间ID', + MODIFY COLUMN statement_type VARCHAR(32) NOT NULL COMMENT '报表类型:WEEKLY/MONTHLY/INVENTORY/COSTING', + MODIFY COLUMN report_date DATE NOT NULL COMMENT '报表日期', + MODIFY COLUMN summary_json JSON NOT NULL COMMENT '汇总数据(JSON)', + MODIFY COLUMN created_by BIGINT NULL COMMENT '创建人用户ID', + MODIFY COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; diff --git a/backend/sql/jiaheng_erp_schema.sql b/backend/sql/jiaheng_erp_schema.sql new file mode 100644 index 0000000..c60d0e7 --- /dev/null +++ b/backend/sql/jiaheng_erp_schema.sql @@ -0,0 +1,1093 @@ +CREATE DATABASE IF NOT EXISTS jiaheng_erp + DEFAULT CHARACTER SET utf8mb4 + DEFAULT COLLATE utf8mb4_unicode_ci; + +USE jiaheng_erp; + +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS sys_department ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + dept_code VARCHAR(50) NOT NULL UNIQUE COMMENT '部门编码', + dept_name VARCHAR(100) NOT NULL COMMENT '部门名称', + parent_id BIGINT NULL COMMENT '上级ID', + org_node_type VARCHAR(32) NOT NULL DEFAULT 'DEPARTMENT' COMMENT '组织节点类型:COMPANY/BRANCH/DEPARTMENT/GROUP', + dept_type VARCHAR(32) NOT NULL COMMENT '部门类型:ADMIN/SALES/PURCHASE/WAREHOUSE/PRODUCTION/FINANCE/EQUIPMENT', + manager_name VARCHAR(100) NULL COMMENT '负责人姓名', + manager_employee_id BIGINT NULL COMMENT '负责人员工ID', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + sort_no INT NOT NULL DEFAULT 0 COMMENT '排序号', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + KEY idx_department_parent_sort (parent_id, sort_no, id), + FOREIGN KEY (parent_id) REFERENCES sys_department(id) +) ENGINE=InnoDB COMMENT='部门表'; + +CREATE TABLE IF NOT EXISTS hr_employee ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + employee_code VARCHAR(50) NOT NULL UNIQUE COMMENT '员工编码', + employee_name VARCHAR(100) NOT NULL COMMENT '员工姓名', + dept_id BIGINT NOT NULL COMMENT '部门ID', + mobile VARCHAR(30) NULL COMMENT '手机号', + gender VARCHAR(16) NULL COMMENT '性别', + hire_date DATE NULL COMMENT '入职日期', + job_title VARCHAR(100) NULL COMMENT '岗位名称', + shift_code VARCHAR(32) NULL COMMENT '班次编码', + manager_employee_id BIGINT NULL COMMENT '上级员工ID', + is_operator TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否操作工', + is_workshop_staff TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否车间人员', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (dept_id) REFERENCES sys_department(id), + FOREIGN KEY (manager_employee_id) REFERENCES hr_employee(id) +) ENGINE=InnoDB COMMENT='员工表'; + +CREATE TABLE IF NOT EXISTS sys_role ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + role_code VARCHAR(50) NOT NULL UNIQUE COMMENT '角色编码', + role_name VARCHAR(100) NOT NULL COMMENT '角色名称', + role_scope VARCHAR(32) NOT NULL DEFAULT 'SYSTEM' COMMENT '角色范围', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' +) ENGINE=InnoDB COMMENT='角色表'; + +CREATE TABLE IF NOT EXISTS sys_permission ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + permission_code VARCHAR(100) NOT NULL UNIQUE COMMENT '权限编码', + permission_name VARCHAR(100) NOT NULL COMMENT '权限名称', + module_code VARCHAR(50) NOT NULL COMMENT '模块编码', + action_code VARCHAR(50) NOT NULL COMMENT '操作编码', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' +) ENGINE=InnoDB COMMENT='权限点表'; + +CREATE TABLE IF NOT EXISTS sys_role_permission ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + role_id BIGINT NOT NULL COMMENT '角色ID', + permission_id BIGINT NOT NULL COMMENT '权限ID', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + UNIQUE KEY uk_role_permission (role_id, permission_id), + FOREIGN KEY (role_id) REFERENCES sys_role(id), + FOREIGN KEY (permission_id) REFERENCES sys_permission(id) +) ENGINE=InnoDB COMMENT='角色权限关系表'; + +CREATE TABLE IF NOT EXISTS sys_user ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录账号', + password_hash VARCHAR(255) NOT NULL COMMENT '密码哈希', + employee_id BIGINT NOT NULL COMMENT '员工ID', + dept_id BIGINT NOT NULL COMMENT '部门ID', + nickname VARCHAR(100) NULL COMMENT '昵称', + email VARCHAR(100) NULL COMMENT '邮箱', + is_super_admin TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否超级管理员', + last_login_at DATETIME NULL COMMENT '最后登录时间', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (employee_id) REFERENCES hr_employee(id), + FOREIGN KEY (dept_id) REFERENCES sys_department(id) +) ENGINE=InnoDB COMMENT='系统用户表'; + +CREATE TABLE IF NOT EXISTS sys_user_role ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + user_id BIGINT NOT NULL COMMENT '用户ID', + role_id BIGINT NOT NULL COMMENT '角色ID', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + UNIQUE KEY uk_user_role (user_id, role_id), + FOREIGN KEY (user_id) REFERENCES sys_user(id), + FOREIGN KEY (role_id) REFERENCES sys_role(id) +) ENGINE=InnoDB COMMENT='用户角色关系表'; + +CREATE TABLE IF NOT EXISTS sys_org_employee_binding ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + dept_id BIGINT NOT NULL COMMENT '组织脑图节点ID', + employee_id BIGINT NOT NULL COMMENT '人员ID', + sort_no INT NOT NULL DEFAULT 0 COMMENT '排序号', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_org_employee_binding (dept_id, employee_id), + KEY idx_org_employee_binding_dept (dept_id, sort_no, id), + KEY idx_org_employee_binding_employee (employee_id), + FOREIGN KEY (dept_id) REFERENCES sys_department(id), + FOREIGN KEY (employee_id) REFERENCES hr_employee(id) +) ENGINE=InnoDB COMMENT='系统权限组织脑图人员绑定表'; + +CREATE TABLE IF NOT EXISTS sys_org_manager_binding ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + dept_id BIGINT NOT NULL COMMENT '组织脑图节点ID', + employee_id BIGINT NOT NULL COMMENT '主管人员ID', + sort_no INT NOT NULL DEFAULT 0 COMMENT '排序号', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_org_manager_binding (dept_id, employee_id), + KEY idx_org_manager_binding_dept (dept_id, sort_no, id), + KEY idx_org_manager_binding_employee (employee_id), + FOREIGN KEY (dept_id) REFERENCES sys_department(id), + FOREIGN KEY (employee_id) REFERENCES hr_employee(id) +) ENGINE=InnoDB COMMENT='系统权限组织脑图主管绑定表'; + +CREATE TABLE IF NOT EXISTS sys_broadcast_message ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + title VARCHAR(100) NOT NULL COMMENT '广播标题', + content VARCHAR(500) NOT NULL COMMENT '广播内容', + tone VARCHAR(32) NOT NULL DEFAULT 'INFO' COMMENT '提示类型:INFO/SUCCESS/WARNING/DANGER', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/PAUSED', + starts_at DATETIME NULL COMMENT '开始展示时间', + ends_at DATETIME NULL COMMENT '结束展示时间', + sort_no INT NOT NULL DEFAULT 0 COMMENT '排序号,越大越优先', + created_by BIGINT NULL COMMENT '创建用户ID', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + KEY idx_broadcast_status_time (status, starts_at, ends_at), + FOREIGN KEY (created_by) REFERENCES sys_user(id) +) ENGINE=InnoDB COMMENT='系统广播消息'; + +CREATE TABLE IF NOT EXISTS sys_ai_assistant_config ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + assistant_name VARCHAR(100) NOT NULL DEFAULT '嘉恒工艺助手' COMMENT '助手名称', + provider_name VARCHAR(100) NULL COMMENT 'LLM供应商名称', + api_base_url VARCHAR(255) NULL COMMENT 'LLM API基础地址', + api_key VARCHAR(512) NULL COMMENT 'LLM API密钥', + model_name VARCHAR(100) NULL COMMENT '模型名称', + temperature DECIMAL(6,3) NOT NULL DEFAULT 0.300 COMMENT '温度参数', + system_prompt TEXT NULL COMMENT '新建对话系统提示词', + enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用真实LLM', + created_by BIGINT NULL COMMENT '创建用户ID', + updated_by BIGINT NULL COMMENT '更新用户ID', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (created_by) REFERENCES sys_user(id), + FOREIGN KEY (updated_by) REFERENCES sys_user(id) +) ENGINE=InnoDB COMMENT='AI助手配置'; + +CREATE TABLE IF NOT EXISTS md_unit ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + unit_code VARCHAR(50) NOT NULL UNIQUE COMMENT '单位编码', + unit_name VARCHAR(50) NOT NULL COMMENT '单位名称', + precision_digits INT NOT NULL DEFAULT 3 COMMENT '小数精度位数', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' +) ENGINE=InnoDB COMMENT='计量单位'; + +CREATE TABLE IF NOT EXISTS md_customer ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + customer_code VARCHAR(50) NOT NULL UNIQUE COMMENT '客户编码', + customer_name VARCHAR(200) NOT NULL COMMENT '客户名称', + short_name VARCHAR(100) NULL COMMENT '简称', + contact_name VARCHAR(100) NULL COMMENT '联系人', + contact_phone VARCHAR(50) NULL COMMENT '联系电话', + address VARCHAR(255) NULL COMMENT '地址', + tax_no VARCHAR(100) NULL COMMENT '税号', + credit_days INT NOT NULL DEFAULT 0 COMMENT '账期天数', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' +) ENGINE=InnoDB COMMENT='客户主档'; + +CREATE TABLE IF NOT EXISTS md_supplier ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + supplier_code VARCHAR(50) NOT NULL UNIQUE COMMENT '供应商编码', + supplier_name VARCHAR(200) NOT NULL COMMENT '供应商名称', + short_name VARCHAR(100) NULL COMMENT '简称', + contact_name VARCHAR(100) NULL COMMENT '联系人', + contact_phone VARCHAR(50) NULL COMMENT '联系电话', + address VARCHAR(255) NULL COMMENT '地址', + lead_time_days INT NOT NULL DEFAULT 0 COMMENT '采购提前期天数', + default_tax_rate DECIMAL(8,4) NOT NULL DEFAULT 0 COMMENT '默认税率', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' +) ENGINE=InnoDB COMMENT='供应商主档'; + +CREATE TABLE IF NOT EXISTS md_item_category ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + category_code VARCHAR(50) NOT NULL UNIQUE COMMENT '分类编码', + category_name VARCHAR(100) NOT NULL COMMENT '分类名称', + parent_id BIGINT NULL COMMENT '上级ID', + item_type VARCHAR(32) NOT NULL COMMENT '物料类型', + sort_no INT NOT NULL DEFAULT 0 COMMENT '排序号', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (parent_id) REFERENCES md_item_category(id) +) ENGINE=InnoDB COMMENT='物料分类'; + +CREATE TABLE IF NOT EXISTS md_item ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + item_code VARCHAR(50) NOT NULL COMMENT '物料编码', + item_name VARCHAR(200) NOT NULL COMMENT '物料名称', + item_type VARCHAR(32) NOT NULL COMMENT '物料类型:RAW_MATERIAL/FINISHED_GOOD/SEMI_FINISHED/SCRAP/SPARE_PART', + category_id BIGINT NULL COMMENT '分类ID', + specification VARCHAR(200) NULL COMMENT '规格', + model_no VARCHAR(100) NULL COMMENT '型号', + material_type VARCHAR(100) NULL COMMENT '材料类型', + material_grade VARCHAR(100) NULL COMMENT '材质牌号', + length_mm DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '长度(mm)', + width_mm DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '宽度(mm)', + thickness_mm DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '厚度(mm)', + density_kg_m3 DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '密度(kg/m3)', + theoretical_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '理论重量(kg)', + unit_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '实际管理用单件或单批重量', + sales_unit_id BIGINT NULL COMMENT '销售单位ID', + stock_unit_id BIGINT NULL COMMENT '库存单位ID', + purchase_unit_id BIGINT NULL COMMENT '采购单位ID', + safety_stock_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '安全库存数量', + safety_stock_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '安全库存重量(kg)', + scrap_sale_price DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '废料销售单价', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (category_id) REFERENCES md_item_category(id), + FOREIGN KEY (sales_unit_id) REFERENCES md_unit(id), + FOREIGN KEY (stock_unit_id) REFERENCES md_unit(id), + FOREIGN KEY (purchase_unit_id) REFERENCES md_unit(id), + INDEX idx_md_item_code (item_code), + INDEX idx_md_item_type (item_type), + INDEX idx_md_item_category (category_id) +) ENGINE=InnoDB COMMENT='统一物料主档'; + +CREATE TABLE IF NOT EXISTS md_material ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + item_id BIGINT NOT NULL UNIQUE COMMENT '物料ID', + default_supplier_id BIGINT NULL COMMENT '默认供应商ID', + purchase_calc_mode VARCHAR(32) NOT NULL DEFAULT 'BY_WEIGHT' COMMENT '采购计算方式', + min_purchase_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '最小采购数量', + purchase_multiple_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '采购倍数数量', + purchase_multiple_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '采购倍数重量(kg)', + lead_time_days INT NOT NULL DEFAULT 0 COMMENT '采购提前期天数', + quality_rule VARCHAR(255) NULL COMMENT '质检规则', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (item_id) REFERENCES md_item(id), + FOREIGN KEY (default_supplier_id) REFERENCES md_supplier(id) +) ENGINE=InnoDB COMMENT='原材料扩展信息'; + +CREATE TABLE IF NOT EXISTS md_product ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + item_id BIGINT NOT NULL UNIQUE COMMENT '物料ID', + drawing_no VARCHAR(100) NULL COMMENT '图号', + product_family VARCHAR(100) NULL COMMENT '产品系列', + version_no VARCHAR(50) NOT NULL DEFAULT 'V1.0' COMMENT '版本号', + standard_output_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '标准产出数量', + allowed_scrap_rate DECIMAL(8,4) NOT NULL DEFAULT 0 COMMENT '允许报废率', + quality_standard VARCHAR(255) NULL COMMENT '质量标准', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (item_id) REFERENCES md_item(id) +) ENGINE=InnoDB COMMENT='产品扩展信息'; + +CREATE TABLE IF NOT EXISTS md_process ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + process_code VARCHAR(50) NOT NULL UNIQUE COMMENT '工序编码', + process_name VARCHAR(100) NOT NULL COMMENT '工序名称', + process_type VARCHAR(50) NOT NULL COMMENT '工序类型', + is_report_required TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否必须报工', + is_quality_gate TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否质量关卡', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' +) ENGINE=InnoDB COMMENT='标准工序'; + +CREATE TABLE IF NOT EXISTS md_work_center ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + center_code VARCHAR(50) NOT NULL UNIQUE COMMENT '工作中心编码', + center_name VARCHAR(100) NOT NULL COMMENT '工作中心名称', + dept_id BIGINT NOT NULL COMMENT '部门ID', + center_type VARCHAR(50) NOT NULL COMMENT '工作中心类型', + shift_pattern VARCHAR(50) NULL COMMENT '班制', + capacity_hours_per_day DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '日产能工时', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (dept_id) REFERENCES sys_department(id) +) ENGINE=InnoDB COMMENT='工作中心'; + +CREATE TABLE IF NOT EXISTS md_process_route ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + route_code VARCHAR(50) NOT NULL COMMENT '工艺路线编码', + route_name VARCHAR(100) NOT NULL COMMENT '工艺路线名称', + product_item_id BIGINT NOT NULL COMMENT '产品物料ID', + version_no VARCHAR(50) NOT NULL DEFAULT 'V1.0' COMMENT '版本号', + is_default TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否默认', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + effective_date DATE NULL COMMENT '生效日期', + expire_date DATE NULL COMMENT '失效日期', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_route_code_version (route_code, version_no), + FOREIGN KEY (product_item_id) REFERENCES md_item(id), + INDEX idx_route_product (product_item_id) +) ENGINE=InnoDB COMMENT='产品工艺路线'; + +CREATE TABLE IF NOT EXISTS md_process_route_operation ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + route_id BIGINT NOT NULL COMMENT '工艺路线ID', + seq_no INT NOT NULL COMMENT '序号', + process_id BIGINT NOT NULL COMMENT '工序ID', + work_center_id BIGINT NOT NULL COMMENT '工作中心ID', + operation_name VARCHAR(100) NOT NULL COMMENT '工序名称', + std_setup_minutes DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '标准准备时长(分钟)', + std_cycle_seconds DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '标准节拍(秒/件)', + std_labor_cost DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '标准人工成本', + std_machine_cost DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '标准设备成本', + std_outsource_cost DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '标准委外成本', + allowed_scrap_rate DECIMAL(8,4) NOT NULL DEFAULT 0 COMMENT '允许报废率', + report_required TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否要求报工', + is_key_operation TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否关键工序', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_route_seq (route_id, seq_no), + FOREIGN KEY (route_id) REFERENCES md_process_route(id), + FOREIGN KEY (process_id) REFERENCES md_process(id), + FOREIGN KEY (work_center_id) REFERENCES md_work_center(id) +) ENGINE=InnoDB COMMENT='工艺路线工序明细'; + +CREATE TABLE IF NOT EXISTS md_bom ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + bom_code VARCHAR(50) NOT NULL COMMENT 'BOM编码', + product_item_id BIGINT NOT NULL COMMENT '产品物料ID', + version_no VARCHAR(50) NOT NULL DEFAULT 'V1.0' COMMENT '版本号', + is_default TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否默认', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + effective_date DATE NULL COMMENT '生效日期', + expire_date DATE NULL COMMENT '失效日期', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_bom_code_version (bom_code, version_no), + FOREIGN KEY (product_item_id) REFERENCES md_item(id), + INDEX idx_bom_product (product_item_id) +) ENGINE=InnoDB COMMENT='BOM 主表'; + +CREATE TABLE IF NOT EXISTS md_bom_item ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + bom_id BIGINT NOT NULL COMMENT 'BOM ID', + seq_no INT NOT NULL COMMENT '序号', + material_item_id BIGINT NOT NULL COMMENT '原材料物料ID', + usage_type VARCHAR(32) NOT NULL DEFAULT 'WEIGHT' COMMENT '用量类型:WEIGHT/FIXED_QTY', + qty_per_piece DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '单件用量', + net_weight_per_piece_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '单件净重(kg)', + gross_weight_per_piece_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '单件毛重(kg)', + loss_rate DECIMAL(8,4) NOT NULL DEFAULT 0 COMMENT '损耗率', + issue_over_tolerance_rate DECIMAL(8,4) NOT NULL DEFAULT 0 COMMENT '领料超发容差率', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_bom_seq (bom_id, seq_no), + FOREIGN KEY (bom_id) REFERENCES md_bom(id), + FOREIGN KEY (material_item_id) REFERENCES md_item(id) +) ENGINE=InnoDB COMMENT='BOM 明细'; + +CREATE TABLE IF NOT EXISTS em_equipment ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + equipment_code VARCHAR(50) NOT NULL UNIQUE COMMENT '设备编码', + equipment_name VARCHAR(100) NOT NULL COMMENT '设备名称', + category_name VARCHAR(100) NULL COMMENT '分类名称', + work_center_id BIGINT NOT NULL COMMENT '工作中心ID', + manufacturer VARCHAR(100) NULL COMMENT '制造商', + model_no VARCHAR(100) NULL COMMENT '型号', + install_date DATE NULL COMMENT '安装日期', + rated_power_kw DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '额定功率(kW)', + occupied_area_sqm DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '占用场地面积(平方米)', + maintenance_cycle_hours DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '保养周期(小时)', + last_maintenance_at DATETIME NULL COMMENT '上次保养时间', + next_maintenance_at DATETIME NULL COMMENT '下次保养时间', + status VARCHAR(32) NOT NULL DEFAULT 'IDLE' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (work_center_id) REFERENCES md_work_center(id) +) ENGINE=InnoDB COMMENT='设备台账'; + +CREATE TABLE IF NOT EXISTS em_maintenance_plan ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + plan_code VARCHAR(50) NOT NULL UNIQUE COMMENT '计划编码', + equipment_id BIGINT NOT NULL COMMENT '设备ID', + plan_name VARCHAR(100) NOT NULL COMMENT '计划名称', + maintenance_type VARCHAR(32) NOT NULL COMMENT '保养类型', + cycle_hours DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '周期小时数', + cycle_days INT NOT NULL DEFAULT 0 COMMENT '周期天数', + last_execute_at DATETIME NULL COMMENT '上次执行时间', + next_execute_at DATETIME NULL COMMENT '下次执行时间', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (equipment_id) REFERENCES em_equipment(id) +) ENGINE=InnoDB COMMENT='设备保养计划'; + +CREATE TABLE IF NOT EXISTS em_maintenance_order ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + order_no VARCHAR(50) NOT NULL UNIQUE COMMENT '单据号', + equipment_id BIGINT NOT NULL COMMENT '设备ID', + plan_id BIGINT NULL COMMENT '计划ID', + maintenance_type VARCHAR(32) NOT NULL COMMENT '保养类型', + start_time DATETIME NULL COMMENT '开始时间', + end_time DATETIME NULL COMMENT '结束时间', + downtime_minutes DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '停机时长(分钟)', + owner_employee_id BIGINT NULL COMMENT '负责人员工ID', + result_summary VARCHAR(255) NULL COMMENT '结果摘要', + cost_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '成本金额', + status VARCHAR(32) NOT NULL DEFAULT 'CREATED' COMMENT '状态', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (equipment_id) REFERENCES em_equipment(id), + FOREIGN KEY (plan_id) REFERENCES em_maintenance_plan(id), + FOREIGN KEY (owner_employee_id) REFERENCES hr_employee(id) +) ENGINE=InnoDB COMMENT='设备保养维修工单'; + +CREATE TABLE IF NOT EXISTS so_sales_order ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + order_no VARCHAR(50) NOT NULL UNIQUE COMMENT '单据号', + customer_id BIGINT NOT NULL COMMENT '客户ID', + order_date DATE NOT NULL COMMENT '订单日期', + promised_date DATE NULL COMMENT '承诺交期', + sales_employee_id BIGINT NULL COMMENT '销售员员工ID', + delivery_address VARCHAR(255) NULL COMMENT '送货地址', + tax_rate DECIMAL(8,4) NOT NULL DEFAULT 0 COMMENT '税率', + total_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '总金额', + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (customer_id) REFERENCES md_customer(id), + FOREIGN KEY (sales_employee_id) REFERENCES hr_employee(id), + INDEX idx_so_customer (customer_id), + INDEX idx_so_order_date (order_date) +) ENGINE=InnoDB COMMENT='订单头'; + +CREATE TABLE IF NOT EXISTS so_sales_order_item ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + sales_order_id BIGINT NOT NULL COMMENT '订单ID', + line_no INT NOT NULL COMMENT '行号', + product_item_id BIGINT NOT NULL COMMENT '产品物料ID', + customer_part_no VARCHAR(100) NULL COMMENT '客户料号', + order_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '订单数量', + delivered_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '已发货数量', + unit_price DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '单价', + line_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '行金额', + promised_date DATE NULL COMMENT '承诺交期', + status VARCHAR(32) NOT NULL DEFAULT 'OPEN' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_so_line (sales_order_id, line_no), + FOREIGN KEY (sales_order_id) REFERENCES so_sales_order(id), + FOREIGN KEY (product_item_id) REFERENCES md_item(id), + INDEX idx_so_item_product (product_item_id) +) ENGINE=InnoDB COMMENT='订单明细'; + +CREATE TABLE IF NOT EXISTS mrp_material_demand ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + demand_no VARCHAR(50) NOT NULL UNIQUE COMMENT '需求单号', + sales_order_item_id BIGINT NOT NULL COMMENT '订单明细ID', + product_item_id BIGINT NOT NULL COMMENT '产品物料ID', + material_item_id BIGINT NOT NULL COMMENT '原材料物料ID', + bom_item_id BIGINT NOT NULL COMMENT 'BOM明细ID', + order_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '订单数量', + theoretical_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '理论重量(kg)', + planned_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '计划重量(kg)', + available_stock_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '可用库存重量(kg)', + shortage_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '缺口重量(kg)', + suggested_purchase_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '建议采购数量', + suggested_purchase_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '建议采购重量(kg)', + status VARCHAR(32) NOT NULL DEFAULT 'NEW' COMMENT '状态', + generated_at DATETIME NULL COMMENT '生成时间', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (sales_order_item_id) REFERENCES so_sales_order_item(id), + FOREIGN KEY (product_item_id) REFERENCES md_item(id), + FOREIGN KEY (material_item_id) REFERENCES md_item(id), + FOREIGN KEY (bom_item_id) REFERENCES md_bom_item(id), + INDEX idx_mrp_material (material_item_id), + INDEX idx_mrp_status (status) +) ENGINE=InnoDB COMMENT='物料需求计划'; + +CREATE TABLE IF NOT EXISTS po_purchase_order ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + po_no VARCHAR(50) NOT NULL UNIQUE COMMENT '采购订单号', + supplier_id BIGINT NOT NULL COMMENT 'SupplierID', + order_date DATE NOT NULL COMMENT '订单日期', + expected_date DATE NULL COMMENT '预计到货日期', + purchaser_employee_id BIGINT NULL COMMENT '采购员员工ID', + target_warehouse_type VARCHAR(32) NOT NULL DEFAULT 'RAW' COMMENT '目标仓库类型:RAW原材料库/AUX辅料库', + tax_rate DECIMAL(8,4) NOT NULL DEFAULT 0 COMMENT '税率', + total_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '总金额', + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (supplier_id) REFERENCES md_supplier(id), + FOREIGN KEY (purchaser_employee_id) REFERENCES hr_employee(id), + INDEX idx_po_supplier (supplier_id) +) ENGINE=InnoDB COMMENT='采购订单头'; + +CREATE TABLE IF NOT EXISTS po_purchase_order_item ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + purchase_order_id BIGINT NOT NULL COMMENT '采购订单ID', + line_no INT NOT NULL COMMENT '行号', + material_item_id BIGINT NOT NULL COMMENT '原材料物料ID', + source_demand_id BIGINT NULL COMMENT '来源需求ID', + order_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '订单数量', + order_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '订单重量(kg)', + received_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '已收货数量', + received_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '已收货重量(kg)', + unit_price DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '单价', + line_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '行金额', + status VARCHAR(32) NOT NULL DEFAULT 'OPEN' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_po_line (purchase_order_id, line_no), + FOREIGN KEY (purchase_order_id) REFERENCES po_purchase_order(id), + FOREIGN KEY (material_item_id) REFERENCES md_item(id), + FOREIGN KEY (source_demand_id) REFERENCES mrp_material_demand(id), + INDEX idx_po_item_material (material_item_id) +) ENGINE=InnoDB COMMENT='采购订单明细'; + +CREATE TABLE IF NOT EXISTS wh_warehouse ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + warehouse_code VARCHAR(50) NOT NULL UNIQUE COMMENT '仓库编码', + warehouse_name VARCHAR(100) NOT NULL COMMENT '仓库名称', + warehouse_type VARCHAR(32) NOT NULL COMMENT '仓库类型:RAW/SEMI/FINISHED/AUX/RETURN/SCRAP', + manager_employee_id BIGINT NULL COMMENT '上级员工ID', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (manager_employee_id) REFERENCES hr_employee(id) +) ENGINE=InnoDB COMMENT='仓库主档'; + +CREATE TABLE IF NOT EXISTS wh_location ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + warehouse_id BIGINT NOT NULL COMMENT '仓库ID', + location_code VARCHAR(50) NOT NULL COMMENT '库位编码', + location_name VARCHAR(100) NOT NULL COMMENT '库位名称', + zone_name VARCHAR(100) NULL COMMENT '库区名称', + is_default TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否默认', + is_locked TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否锁定', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_warehouse_location (warehouse_id, location_code), + FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse(id) +) ENGINE=InnoDB COMMENT='库位表'; + +CREATE TABLE IF NOT EXISTS po_receipt ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + receipt_no VARCHAR(50) NOT NULL UNIQUE COMMENT '入库单号', + purchase_order_id BIGINT NOT NULL COMMENT '采购订单ID', + warehouse_id BIGINT NOT NULL COMMENT '仓库ID', + receipt_date DATETIME NOT NULL COMMENT '收货日期', + receiver_employee_id BIGINT NULL COMMENT '接收人员工ID', + supplier_delivery_no VARCHAR(100) NULL COMMENT '供应商送货单号', + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (purchase_order_id) REFERENCES po_purchase_order(id), + FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse(id), + FOREIGN KEY (receiver_employee_id) REFERENCES hr_employee(id) +) ENGINE=InnoDB COMMENT='采购收货单头'; + +CREATE TABLE IF NOT EXISTS po_receipt_item ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + receipt_id BIGINT NOT NULL COMMENT '单据ID', + line_no INT NOT NULL COMMENT '行号', + purchase_order_item_id BIGINT NOT NULL COMMENT '采购订单明细ID', + material_item_id BIGINT NOT NULL COMMENT '原材料物料ID', + location_id BIGINT NULL COMMENT '库位ID', + lot_no VARCHAR(50) NOT NULL COMMENT '批次号', + received_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '已收货数量', + received_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '已收货重量(kg)', + accepted_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '合格数量', + accepted_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '合格重量(kg)', + unit_cost DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '单位成本', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_QC' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_receipt_line (receipt_id, line_no), + FOREIGN KEY (receipt_id) REFERENCES po_receipt(id), + FOREIGN KEY (purchase_order_item_id) REFERENCES po_purchase_order_item(id), + FOREIGN KEY (material_item_id) REFERENCES md_item(id), + FOREIGN KEY (location_id) REFERENCES wh_location(id), + INDEX idx_receipt_item_lot (lot_no) +) ENGINE=InnoDB COMMENT='采购收货单明细'; + +CREATE TABLE IF NOT EXISTS wh_stock_lot ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + lot_no VARCHAR(50) NOT NULL UNIQUE COMMENT '批次号', + item_id BIGINT NOT NULL COMMENT '物料ID', + warehouse_id BIGINT NOT NULL COMMENT '仓库ID', + location_id BIGINT NULL COMMENT '库位ID', + source_doc_type VARCHAR(32) NOT NULL COMMENT '来源单据类型', + source_doc_id BIGINT NOT NULL COMMENT '来源单据ID', + source_line_id BIGINT NULL COMMENT '来源单据行ID', + logistics_waybill_no VARCHAR(100) NULL COMMENT '运单号', + logistics_freight_amount DECIMAL(18,2) NULL COMMENT '运费', + logistics_photo_url VARCHAR(255) NULL COMMENT '订单照片路径', + inbound_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '入库数量', + inbound_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '入库重量(kg)', + remaining_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '剩余数量', + remaining_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '剩余重量(kg)', + locked_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '锁定数量', + locked_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '锁定重量(kg)', + unit_cost DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '单位成本', + production_date DATE NULL COMMENT '生产日期', + expire_date DATE NULL COMMENT '失效日期', + quality_status VARCHAR(32) NOT NULL DEFAULT 'PASS' COMMENT '质量状态', + status VARCHAR(32) NOT NULL DEFAULT 'AVAILABLE' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (item_id) REFERENCES md_item(id), + FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse(id), + FOREIGN KEY (location_id) REFERENCES wh_location(id), + INDEX idx_stock_lot_item (item_id), + INDEX idx_stock_lot_wh (warehouse_id) +) ENGINE=InnoDB COMMENT='库存批次'; + +CREATE TABLE IF NOT EXISTS wh_stock_balance ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + item_id BIGINT NOT NULL COMMENT '物料ID', + warehouse_id BIGINT NOT NULL COMMENT '仓库ID', + location_id BIGINT NULL COMMENT '库位ID', + qty_on_hand DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '现存数量', + weight_on_hand_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '现存重量(kg)', + qty_available DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '可用数量', + weight_available_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '可用重量(kg)', + qty_allocated DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '已分配数量', + weight_allocated_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '已分配重量(kg)', + avg_unit_cost DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '平均单位成本', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_stock_balance (item_id, warehouse_id, location_id), + FOREIGN KEY (item_id) REFERENCES md_item(id), + FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse(id), + FOREIGN KEY (location_id) REFERENCES wh_location(id) +) ENGINE=InnoDB COMMENT='库存余额表'; + +CREATE TABLE IF NOT EXISTS wh_inventory_txn ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + txn_no VARCHAR(50) NOT NULL UNIQUE COMMENT '流水号', + txn_type VARCHAR(32) NOT NULL COMMENT '流水类型:PURCHASE_IN/MATERIAL_ISSUE/FG_IN/SALES_OUT/RETURN_IN/RETURN_OUT/SCRAP_OUT/PRODUCTION_SCRAP_IN/OUTSOURCING_SCRAP_IN/SCRAP_SALE_OUT/ADJUST', + item_id BIGINT NOT NULL COMMENT '物料ID', + warehouse_id BIGINT NOT NULL COMMENT '仓库ID', + location_id BIGINT NULL COMMENT '库位ID', + lot_id BIGINT NULL COMMENT '批次ID', + qty_change DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '数量变动', + weight_change_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '重量变动(kg)', + unit_cost DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '单位成本', + amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '金额', + source_doc_type VARCHAR(32) NOT NULL COMMENT '来源单据类型', + source_doc_id BIGINT NOT NULL COMMENT '来源单据ID', + source_line_id BIGINT NULL COMMENT '来源单据行ID', + logistics_waybill_no VARCHAR(100) NULL COMMENT '运单号', + logistics_freight_amount DECIMAL(18,2) NULL COMMENT '运费', + logistics_photo_url VARCHAR(255) NULL COMMENT '订单照片路径', + biz_time DATETIME NOT NULL COMMENT '业务时间', + operator_user_id BIGINT NULL COMMENT '操作人用户ID', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (item_id) REFERENCES md_item(id), + FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse(id), + FOREIGN KEY (location_id) REFERENCES wh_location(id), + FOREIGN KEY (lot_id) REFERENCES wh_stock_lot(id), + FOREIGN KEY (operator_user_id) REFERENCES sys_user(id), + INDEX idx_inventory_txn_item (item_id), + INDEX idx_inventory_txn_time (biz_time) +) ENGINE=InnoDB COMMENT='库存流水'; + +CREATE TABLE IF NOT EXISTS pp_work_order ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + work_order_no VARCHAR(50) NOT NULL UNIQUE COMMENT '工单号', + work_order_type VARCHAR(32) NOT NULL DEFAULT 'NORMAL' COMMENT '工单类型:NORMAL/REWORK', + source_sales_order_item_id BIGINT NULL COMMENT '来源订单明细ID', + product_item_id BIGINT NOT NULL COMMENT '产品物料ID', + route_id BIGINT NOT NULL COMMENT '工艺路线ID', + bom_id BIGINT NOT NULL COMMENT 'BOM ID', + planned_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '计划数量', + released_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '下达数量', + finished_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '完工数量', + scrap_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '报废数量', + planned_start_time DATETIME NULL COMMENT '计划开始时间', + planned_end_time DATETIME NULL COMMENT '计划结束时间', + actual_start_time DATETIME NULL COMMENT '实际开始时间', + actual_end_time DATETIME NULL COMMENT '实际结束时间', + priority_level INT NOT NULL DEFAULT 3 COMMENT '优先级', + status VARCHAR(32) NOT NULL DEFAULT 'CREATED' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (source_sales_order_item_id) REFERENCES so_sales_order_item(id), + FOREIGN KEY (product_item_id) REFERENCES md_item(id), + FOREIGN KEY (route_id) REFERENCES md_process_route(id), + FOREIGN KEY (bom_id) REFERENCES md_bom(id), + INDEX idx_work_order_status (status) +) ENGINE=InnoDB COMMENT='生产工单'; + +CREATE TABLE IF NOT EXISTS pp_work_order_material ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + work_order_id BIGINT NOT NULL COMMENT '工单ID', + bom_item_id BIGINT NOT NULL COMMENT 'BOM明细ID', + material_item_id BIGINT NOT NULL COMMENT '原材料物料ID', + required_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '需求数量', + required_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '需求重量(kg)', + issued_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '已领料数量', + issued_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '已领料重量(kg)', + returned_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '已退料数量', + returned_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '已退料重量(kg)', + status VARCHAR(32) NOT NULL DEFAULT 'OPEN' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_work_order_material (work_order_id, bom_item_id), + FOREIGN KEY (work_order_id) REFERENCES pp_work_order(id), + FOREIGN KEY (bom_item_id) REFERENCES md_bom_item(id), + FOREIGN KEY (material_item_id) REFERENCES md_item(id) +) ENGINE=InnoDB COMMENT='工单用料清单'; + +CREATE TABLE IF NOT EXISTS pp_work_order_operation ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + work_order_id BIGINT NOT NULL COMMENT '工单ID', + route_operation_id BIGINT NOT NULL COMMENT '工艺路线工序ID', + seq_no INT NOT NULL COMMENT '序号', + operation_name VARCHAR(100) NOT NULL COMMENT '工序名称', + work_center_id BIGINT NOT NULL COMMENT '工作中心ID', + planned_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '计划数量', + report_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '报工数量', + good_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '合格数量', + scrap_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '报废数量', + rework_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '返工数量', + std_labor_cost DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '标准人工成本', + std_machine_cost DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '标准设备成本', + std_outsource_cost DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '标准委外成本', + allowed_scrap_rate DECIMAL(8,4) NOT NULL DEFAULT 0 COMMENT '允许报废率', + is_abnormal TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否异常', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_work_order_operation (work_order_id, seq_no), + FOREIGN KEY (work_order_id) REFERENCES pp_work_order(id), + FOREIGN KEY (route_operation_id) REFERENCES md_process_route_operation(id), + FOREIGN KEY (work_center_id) REFERENCES md_work_center(id) +) ENGINE=InnoDB COMMENT='工单工序清单'; + +CREATE TABLE IF NOT EXISTS pp_material_issue ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + issue_no VARCHAR(50) NOT NULL UNIQUE COMMENT '领料单号', + work_order_id BIGINT NOT NULL COMMENT '工单ID', + warehouse_id BIGINT NOT NULL COMMENT '仓库ID', + issue_time DATETIME NOT NULL COMMENT '领料时间', + issuer_employee_id BIGINT NULL COMMENT '发料人员工ID', + status VARCHAR(32) NOT NULL DEFAULT 'CREATED' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (work_order_id) REFERENCES pp_work_order(id), + FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse(id), + FOREIGN KEY (issuer_employee_id) REFERENCES hr_employee(id) +) ENGINE=InnoDB COMMENT='生产领料单头'; + +CREATE TABLE IF NOT EXISTS pp_material_issue_item ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + material_issue_id BIGINT NOT NULL COMMENT '领料单ID', + work_order_material_id BIGINT NOT NULL COMMENT '工单用料ID', + material_item_id BIGINT NOT NULL COMMENT '原材料物料ID', + lot_id BIGINT NOT NULL COMMENT '批次ID', + location_id BIGINT NULL COMMENT '库位ID', + issue_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT 'Issue数量', + issue_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT 'issue_weight_kg', + unit_cost DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '单位成本', + status VARCHAR(32) NOT NULL DEFAULT 'POSTED' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (material_issue_id) REFERENCES pp_material_issue(id), + FOREIGN KEY (work_order_material_id) REFERENCES pp_work_order_material(id), + FOREIGN KEY (material_item_id) REFERENCES md_item(id), + FOREIGN KEY (lot_id) REFERENCES wh_stock_lot(id), + FOREIGN KEY (location_id) REFERENCES wh_location(id) +) ENGINE=InnoDB COMMENT='生产领料单明细'; + +CREATE TABLE IF NOT EXISTS pp_operation_report ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + report_no VARCHAR(50) NOT NULL UNIQUE COMMENT '报工单号', + work_order_id BIGINT NOT NULL COMMENT '工单ID', + work_order_operation_id BIGINT NOT NULL COMMENT '工单工序ID', + employee_id BIGINT NOT NULL COMMENT '员工ID', + equipment_id BIGINT NULL COMMENT '设备ID', + report_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '报工来源:MANUAL/MINI_APP/IMPORT', + shift_code VARCHAR(32) NULL COMMENT '班次编码', + start_time DATETIME NOT NULL COMMENT '开始时间', + end_time DATETIME NOT NULL COMMENT '结束时间', + report_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '报工数量', + good_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '合格数量', + scrap_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '报废数量', + rework_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '返工数量', + scrap_reason VARCHAR(255) NULL COMMENT '报废原因', + downtime_minutes DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '停机时长(分钟)', + extra_cost_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '额外成本金额', + is_abnormal TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否异常', + approved_by BIGINT NULL COMMENT '审批人用户ID', + approved_at DATETIME NULL COMMENT '审批时间', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (work_order_id) REFERENCES pp_work_order(id), + FOREIGN KEY (work_order_operation_id) REFERENCES pp_work_order_operation(id), + FOREIGN KEY (employee_id) REFERENCES hr_employee(id), + FOREIGN KEY (equipment_id) REFERENCES em_equipment(id), + FOREIGN KEY (approved_by) REFERENCES sys_user(id), + INDEX idx_operation_report_operation (work_order_operation_id), + INDEX idx_operation_report_employee (employee_id) +) ENGINE=InnoDB COMMENT='工序报工记录'; + +CREATE TABLE IF NOT EXISTS pp_scrap_record ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + scrap_no VARCHAR(50) NOT NULL UNIQUE COMMENT '报废单号', + report_id BIGINT NOT NULL COMMENT '报工记录ID', + work_order_id BIGINT NOT NULL COMMENT '工单ID', + work_order_operation_id BIGINT NOT NULL COMMENT '工单工序ID', + item_id BIGINT NOT NULL COMMENT '物料ID', + scrap_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '报废数量', + scrap_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '报废重量(kg)', + scrap_reason VARCHAR(255) NULL COMMENT '报废原因', + disposal_type VARCHAR(32) NOT NULL DEFAULT 'SCRAP' COMMENT '处置类型:SCRAP/REWORK', + saleable_scrap_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '可售废料重量(kg)', + scrap_cost_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '报废成本金额', + status VARCHAR(32) NOT NULL DEFAULT 'CONFIRMED' COMMENT '状态', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (report_id) REFERENCES pp_operation_report(id), + FOREIGN KEY (work_order_id) REFERENCES pp_work_order(id), + FOREIGN KEY (work_order_operation_id) REFERENCES pp_work_order_operation(id), + FOREIGN KEY (item_id) REFERENCES md_item(id) +) ENGINE=InnoDB COMMENT='报废与返工记录'; + +CREATE TABLE IF NOT EXISTS pp_completion_receipt ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + receipt_no VARCHAR(50) NOT NULL UNIQUE COMMENT '入库单号', + work_order_id BIGINT NOT NULL COMMENT '工单ID', + warehouse_id BIGINT NOT NULL COMMENT '仓库ID', + receipt_time DATETIME NOT NULL COMMENT '入库时间', + receiver_employee_id BIGINT NULL COMMENT '接收人员工ID', + status VARCHAR(32) NOT NULL DEFAULT 'CREATED' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (work_order_id) REFERENCES pp_work_order(id), + FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse(id), + FOREIGN KEY (receiver_employee_id) REFERENCES hr_employee(id) +) ENGINE=InnoDB COMMENT='完工入库单头'; + +CREATE TABLE IF NOT EXISTS pp_completion_receipt_item ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + completion_receipt_id BIGINT NOT NULL COMMENT '完工入库单ID', + line_no INT NOT NULL COMMENT '行号', + product_item_id BIGINT NOT NULL COMMENT '产品物料ID', + location_id BIGINT NULL COMMENT '库位ID', + lot_no VARCHAR(50) NOT NULL COMMENT '批次号', + receipt_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT 'Receipt数量', + receipt_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT 'receipt_weight_kg', + unit_cost DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '单位成本', + status VARCHAR(32) NOT NULL DEFAULT 'POSTED' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_completion_line (completion_receipt_id, line_no), + FOREIGN KEY (completion_receipt_id) REFERENCES pp_completion_receipt(id), + FOREIGN KEY (product_item_id) REFERENCES md_item(id), + FOREIGN KEY (location_id) REFERENCES wh_location(id), + INDEX idx_completion_lot (lot_no) +) ENGINE=InnoDB COMMENT='完工入库单明细'; + +CREATE TABLE IF NOT EXISTS so_delivery ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + delivery_no VARCHAR(50) NOT NULL UNIQUE COMMENT '发货单号', + sales_order_id BIGINT NOT NULL COMMENT '订单ID', + warehouse_id BIGINT NOT NULL COMMENT '仓库ID', + delivery_date DATETIME NOT NULL COMMENT '发货时间', + shipper_employee_id BIGINT NULL COMMENT '发货人员工ID', + consignee_name VARCHAR(100) NULL COMMENT '收货人姓名', + consignee_phone VARCHAR(50) NULL COMMENT '收货人电话', + delivery_address VARCHAR(255) NULL COMMENT '送货地址', + logistics_waybill_no VARCHAR(100) NULL COMMENT '运单号', + logistics_freight_amount DECIMAL(18,2) NULL COMMENT '运费', + logistics_photo_url VARCHAR(255) NULL COMMENT '订单照片路径', + status VARCHAR(32) NOT NULL DEFAULT 'CREATED' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (sales_order_id) REFERENCES so_sales_order(id), + FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse(id), + FOREIGN KEY (shipper_employee_id) REFERENCES hr_employee(id) +) ENGINE=InnoDB COMMENT='发货单头'; + +CREATE TABLE IF NOT EXISTS so_delivery_item ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + delivery_id BIGINT NOT NULL COMMENT '发货单ID', + line_no INT NOT NULL COMMENT '行号', + sales_order_item_id BIGINT NOT NULL COMMENT '订单明细ID', + product_item_id BIGINT NOT NULL COMMENT '产品物料ID', + lot_id BIGINT NULL COMMENT '批次ID', + delivery_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '发货数量', + delivery_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '发货重量(kg)', + unit_price DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '单价', + line_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '行金额', + status VARCHAR(32) NOT NULL DEFAULT 'POSTED' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_delivery_line (delivery_id, line_no), + FOREIGN KEY (delivery_id) REFERENCES so_delivery(id), + FOREIGN KEY (sales_order_item_id) REFERENCES so_sales_order_item(id), + FOREIGN KEY (product_item_id) REFERENCES md_item(id), + FOREIGN KEY (lot_id) REFERENCES wh_stock_lot(id) +) ENGINE=InnoDB COMMENT='发货单明细'; + +CREATE TABLE IF NOT EXISTS rt_return_order ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + return_no VARCHAR(50) NOT NULL UNIQUE COMMENT '退货单号', + customer_id BIGINT NOT NULL COMMENT '客户ID', + sales_order_id BIGINT NULL COMMENT '订单ID', + delivery_id BIGINT NULL COMMENT '发货单ID', + return_date DATETIME NOT NULL COMMENT '退货时间', + return_reason VARCHAR(255) NULL COMMENT '退货原因', + handler_employee_id BIGINT NULL COMMENT '处理人员工ID', + status VARCHAR(32) NOT NULL DEFAULT 'CREATED' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (customer_id) REFERENCES md_customer(id), + FOREIGN KEY (sales_order_id) REFERENCES so_sales_order(id), + FOREIGN KEY (delivery_id) REFERENCES so_delivery(id), + FOREIGN KEY (handler_employee_id) REFERENCES hr_employee(id) +) ENGINE=InnoDB COMMENT='退货单头'; + +CREATE TABLE IF NOT EXISTS rt_return_item ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + return_order_id BIGINT NOT NULL COMMENT '退货单ID', + line_no INT NOT NULL COMMENT '行号', + sales_order_item_id BIGINT NULL COMMENT '订单明细ID', + delivery_item_id BIGINT NULL COMMENT '发货明细ID', + product_item_id BIGINT NOT NULL COMMENT '产品物料ID', + source_lot_id BIGINT NULL COMMENT '来源批次ID', + return_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '退货数量', + return_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '退货重量(kg)', + disposition_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '处置状态', + responsibility_type VARCHAR(32) NULL COMMENT '责任类型', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_return_line (return_order_id, line_no), + INDEX idx_rt_return_item_delivery_item (delivery_item_id), + FOREIGN KEY (return_order_id) REFERENCES rt_return_order(id), + FOREIGN KEY (sales_order_item_id) REFERENCES so_sales_order_item(id), + FOREIGN KEY (delivery_item_id) REFERENCES so_delivery_item(id), + FOREIGN KEY (product_item_id) REFERENCES md_item(id), + FOREIGN KEY (source_lot_id) REFERENCES wh_stock_lot(id) +) ENGINE=InnoDB COMMENT='退货单明细'; + +CREATE TABLE IF NOT EXISTS rt_return_disposition ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + disposition_no VARCHAR(50) NOT NULL UNIQUE COMMENT '处置单号', + return_item_id BIGINT NOT NULL COMMENT '退货明细ID', + disposition_type VARCHAR(32) NOT NULL COMMENT '处置方式:REWORK/SCRAP', + rework_work_order_id BIGINT NULL COMMENT '返工工单ID', + scrap_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '报废重量(kg)', + extra_cost_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '额外成本金额', + scrap_sale_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '废料销售金额', + closed_at DATETIME NULL COMMENT '关闭时间', + status VARCHAR(32) NOT NULL DEFAULT 'OPEN' COMMENT '状态', + remark VARCHAR(255) NULL COMMENT '备注', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (return_item_id) REFERENCES rt_return_item(id), + FOREIGN KEY (rework_work_order_id) REFERENCES pp_work_order(id) +) ENGINE=InnoDB COMMENT='退货处置记录'; + +CREATE TABLE IF NOT EXISTS fi_accounting_period ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + period_code VARCHAR(20) NOT NULL UNIQUE COMMENT '期间编码', + year_no INT NOT NULL COMMENT '年度', + month_no INT NOT NULL COMMENT '月份', + start_date DATE NOT NULL COMMENT '开始日期', + end_date DATE NOT NULL COMMENT '结束日期', + close_status VARCHAR(32) NOT NULL DEFAULT 'OPEN' COMMENT '关账状态', + closed_at DATETIME NULL COMMENT '关闭时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' +) ENGINE=InnoDB COMMENT='财务期间'; + +CREATE TABLE IF NOT EXISTS fi_overhead_entry ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + period_id BIGINT NOT NULL COMMENT '期间ID', + overhead_type VARCHAR(32) NOT NULL COMMENT '制造费用类型:RENT/UTILITY/LOGISTICS/ADMIN/DEPRECIATION/OTHER', + dept_id BIGINT NULL COMMENT '部门ID', + amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '金额', + allocation_basis VARCHAR(32) NOT NULL DEFAULT 'WORK_HOURS' COMMENT '分摊依据:WORK_HOURS/MACHINE_HOURS/QTY/OCCUPIED_AREA', + description VARCHAR(255) NULL COMMENT '说明', + created_by BIGINT NULL COMMENT '创建人用户ID', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (period_id) REFERENCES fi_accounting_period(id), + FOREIGN KEY (dept_id) REFERENCES sys_department(id), + FOREIGN KEY (created_by) REFERENCES sys_user(id) +) ENGINE=InnoDB COMMENT='制造费用录入'; + +CREATE TABLE IF NOT EXISTS fi_cost_allocation ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + period_id BIGINT NOT NULL COMMENT '期间ID', + work_order_id BIGINT NOT NULL COMMENT '工单ID', + product_item_id BIGINT NOT NULL COMMENT '产品物料ID', + allocation_basis_value DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '分摊依据值', + overhead_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '制造费用金额', + material_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '材料金额', + labor_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '人工金额', + machine_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '设备金额', + abnormal_loss_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '异常损失金额', + total_cost_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '总成本金额', + unit_cost DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '单位成本', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_period_work_order (period_id, work_order_id), + FOREIGN KEY (period_id) REFERENCES fi_accounting_period(id), + FOREIGN KEY (work_order_id) REFERENCES pp_work_order(id), + FOREIGN KEY (product_item_id) REFERENCES md_item(id) +) ENGINE=InnoDB COMMENT='工单成本分摊结果'; + +CREATE TABLE IF NOT EXISTS fi_statement_snapshot ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', + snapshot_no VARCHAR(50) NOT NULL UNIQUE COMMENT '快照编号', + period_id BIGINT NOT NULL COMMENT '期间ID', + statement_type VARCHAR(32) NOT NULL COMMENT '报表类型:WEEKLY/MONTHLY/INVENTORY/COSTING', + report_date DATE NOT NULL COMMENT '报表日期', + summary_json JSON NOT NULL COMMENT '汇总数据(JSON)', + created_by BIGINT NULL COMMENT '创建人用户ID', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (period_id) REFERENCES fi_accounting_period(id), + FOREIGN KEY (created_by) REFERENCES sys_user(id) +) ENGINE=InnoDB COMMENT='财务报表快照'; diff --git a/backend/sql/lite_flow_patch.sql b/backend/sql/lite_flow_patch.sql new file mode 100644 index 0000000..93704fd --- /dev/null +++ b/backend/sql/lite_flow_patch.sql @@ -0,0 +1,12 @@ +-- Lite 化流程兼容补丁:原材料驱动、客供料入库、无订单发货。 +ALTER TABLE pp_work_order + MODIFY COLUMN work_order_no VARCHAR(80) NOT NULL COMMENT '生产跟踪号/历史生产工单号'; + +ALTER TABLE so_delivery + MODIFY COLUMN sales_order_id BIGINT NULL COMMENT '来源订单ID;lite模式下可为空', + ADD COLUMN customer_id BIGINT NULL COMMENT '收货客户ID;无订单发货时必填' AFTER sales_order_id; + +ALTER TABLE so_delivery_item + MODIFY COLUMN sales_order_item_id BIGINT NULL COMMENT '来源订单明细ID;lite模式下可为空'; + +CREATE INDEX idx_so_delivery_customer_id ON so_delivery (customer_id); diff --git a/backend/sql/localize_inventory_generated_remarks.sql b/backend/sql/localize_inventory_generated_remarks.sql new file mode 100644 index 0000000..4120803 --- /dev/null +++ b/backend/sql/localize_inventory_generated_remarks.sql @@ -0,0 +1,111 @@ +UPDATE wh_inventory_txn +SET remark = CONCAT('期初入库', SUBSTRING(remark, CHAR_LENGTH('OPENING') + 1)) +WHERE remark = 'OPENING' OR remark LIKE 'OPENING;%'; + +UPDATE wh_inventory_txn +SET remark = CONCAT('客料入库', SUBSTRING(remark, CHAR_LENGTH('CUSTOMER_SUPPLIED') + 1)) +WHERE remark = 'CUSTOMER_SUPPLIED' OR remark LIKE 'CUSTOMER_SUPPLIED;%'; + +UPDATE wh_inventory_txn +SET remark = CONCAT('生产余料入库', SUBSTRING(remark, CHAR_LENGTH('PRODUCTION_SURPLUS') + 1)) +WHERE remark = 'PRODUCTION_SURPLUS' OR remark LIKE 'PRODUCTION_SURPLUS;%'; + +UPDATE wh_inventory_txn +SET remark = CONCAT('委外余料入库', SUBSTRING(remark, CHAR_LENGTH('OUTSOURCING_SURPLUS') + 1)) +WHERE remark = 'OUTSOURCING_SURPLUS' OR remark LIKE 'OUTSOURCING_SURPLUS;%'; + +UPDATE wh_inventory_txn +SET remark = CONCAT('生产废料入库', SUBSTRING(remark, CHAR_LENGTH('PRODUCTION_SCRAP_IN') + 1)) +WHERE remark = 'PRODUCTION_SCRAP_IN' OR remark LIKE 'PRODUCTION_SCRAP_IN;%'; + +UPDATE wh_inventory_txn +SET remark = CONCAT('委外废料入库', SUBSTRING(remark, CHAR_LENGTH('OUTSOURCING_SCRAP_IN') + 1)) +WHERE remark = 'OUTSOURCING_SCRAP_IN' OR remark LIKE 'OUTSOURCING_SCRAP_IN;%'; + +UPDATE wh_inventory_txn +SET remark = CONCAT('产中入库', SUBSTRING(remark, CHAR_LENGTH('WIP_IN') + 1)) +WHERE remark = 'WIP_IN' OR remark LIKE 'WIP_IN;%'; + +UPDATE wh_inventory_txn +SET remark = CONCAT('委外入库', SUBSTRING(remark, CHAR_LENGTH('OUTSOURCING_IN') + 1)) +WHERE remark = 'OUTSOURCING_IN' OR remark LIKE 'OUTSOURCING_IN;%'; + +UPDATE wh_inventory_txn +SET remark = CONCAT('生产入库', SUBSTRING(remark, CHAR_LENGTH('PRODUCTION_FINISHED') + 1)) +WHERE remark = 'PRODUCTION_FINISHED' OR remark LIKE 'PRODUCTION_FINISHED;%'; + +UPDATE wh_inventory_txn +SET remark = CONCAT('生产出库', SUBSTRING(remark, CHAR_LENGTH('PRODUCTION_OUT') + 1)) +WHERE remark = 'PRODUCTION_OUT' OR remark LIKE 'PRODUCTION_OUT;%'; + +UPDATE wh_inventory_txn +SET remark = CONCAT('委外出库', SUBSTRING(remark, CHAR_LENGTH('OUTSOURCING_OUT') + 1)) +WHERE remark = 'OUTSOURCING_OUT' OR remark LIKE 'OUTSOURCING_OUT;%'; + +UPDATE wh_inventory_txn +SET remark = CONCAT('报废出库', SUBSTRING(remark, CHAR_LENGTH('SCRAP_OUT') + 1)) +WHERE remark = 'SCRAP_OUT' OR remark LIKE 'SCRAP_OUT;%'; + +UPDATE wh_inventory_txn +SET remark = CONCAT('售卖出库', SUBSTRING(remark, CHAR_LENGTH('SCRAP_SALE_OUT') + 1)) +WHERE remark = 'SCRAP_SALE_OUT' OR remark LIKE 'SCRAP_SALE_OUT;%'; + +UPDATE wh_inventory_txn +SET remark = CONCAT('产中出库', SUBSTRING(remark, CHAR_LENGTH('WIP_OUT') + 1)) +WHERE remark = 'WIP_OUT' OR remark LIKE 'WIP_OUT;%'; + +UPDATE wh_inventory_txn +SET remark = CONCAT('销售出库', SUBSTRING(remark, CHAR_LENGTH('SALES_OUT') + 1)) +WHERE remark = 'SALES_OUT' OR remark LIKE 'SALES_OUT;%'; + +UPDATE wh_inventory_txn +SET remark = CONCAT('退货出库', SUBSTRING(remark, CHAR_LENGTH('PURCHASE_RETURN_OUT') + 1)) +WHERE remark = 'PURCHASE_RETURN_OUT' OR remark LIKE 'PURCHASE_RETURN_OUT;%'; + +UPDATE wh_stock_lot +SET remark = CONCAT('期初入库', SUBSTRING(remark, CHAR_LENGTH('OPENING') + 1)) +WHERE remark = 'OPENING' OR remark LIKE 'OPENING;%'; + +UPDATE wh_stock_lot +SET remark = CONCAT('客料入库', SUBSTRING(remark, CHAR_LENGTH('CUSTOMER_SUPPLIED') + 1)) +WHERE remark = 'CUSTOMER_SUPPLIED' OR remark LIKE 'CUSTOMER_SUPPLIED;%'; + +UPDATE wh_stock_lot +SET remark = CONCAT('生产余料入库', SUBSTRING(remark, CHAR_LENGTH('PRODUCTION_SURPLUS') + 1)) +WHERE remark = 'PRODUCTION_SURPLUS' OR remark LIKE 'PRODUCTION_SURPLUS;%'; + +UPDATE wh_stock_lot +SET remark = CONCAT('委外余料入库', SUBSTRING(remark, CHAR_LENGTH('OUTSOURCING_SURPLUS') + 1)) +WHERE remark = 'OUTSOURCING_SURPLUS' OR remark LIKE 'OUTSOURCING_SURPLUS;%'; + +UPDATE wh_stock_lot +SET remark = CONCAT('生产废料入库', SUBSTRING(remark, CHAR_LENGTH('PRODUCTION_SCRAP_IN') + 1)) +WHERE remark = 'PRODUCTION_SCRAP_IN' OR remark LIKE 'PRODUCTION_SCRAP_IN;%'; + +UPDATE wh_stock_lot +SET remark = CONCAT('委外废料入库', SUBSTRING(remark, CHAR_LENGTH('OUTSOURCING_SCRAP_IN') + 1)) +WHERE remark = 'OUTSOURCING_SCRAP_IN' OR remark LIKE 'OUTSOURCING_SCRAP_IN;%'; + +UPDATE wh_stock_lot +SET source_material_summary = CONCAT('期初入库', SUBSTRING(source_material_summary, CHAR_LENGTH('OPENING') + 1)) +WHERE source_material_summary = 'OPENING' OR source_material_summary LIKE 'OPENING;%'; + +UPDATE wh_stock_lot +SET source_material_summary = CONCAT('客料入库', SUBSTRING(source_material_summary, CHAR_LENGTH('CUSTOMER_SUPPLIED') + 1)) +WHERE source_material_summary = 'CUSTOMER_SUPPLIED' OR source_material_summary LIKE 'CUSTOMER_SUPPLIED;%'; + +UPDATE wh_stock_lot +SET source_material_summary = CONCAT('生产余料入库', SUBSTRING(source_material_summary, CHAR_LENGTH('PRODUCTION_SURPLUS') + 1)) +WHERE source_material_summary = 'PRODUCTION_SURPLUS' OR source_material_summary LIKE 'PRODUCTION_SURPLUS;%'; + +UPDATE wh_stock_lot +SET source_material_summary = CONCAT('委外余料入库', SUBSTRING(source_material_summary, CHAR_LENGTH('OUTSOURCING_SURPLUS') + 1)) +WHERE source_material_summary = 'OUTSOURCING_SURPLUS' OR source_material_summary LIKE 'OUTSOURCING_SURPLUS;%'; + +UPDATE wh_stock_lot +SET source_material_summary = CONCAT('生产废料入库', SUBSTRING(source_material_summary, CHAR_LENGTH('PRODUCTION_SCRAP_IN') + 1)) +WHERE source_material_summary = 'PRODUCTION_SCRAP_IN' OR source_material_summary LIKE 'PRODUCTION_SCRAP_IN;%'; + +UPDATE wh_stock_lot +SET source_material_summary = CONCAT('委外废料入库', SUBSTRING(source_material_summary, CHAR_LENGTH('OUTSOURCING_SCRAP_IN') + 1)) +WHERE source_material_summary = 'OUTSOURCING_SCRAP_IN' OR source_material_summary LIKE 'OUTSOURCING_SCRAP_IN;%'; diff --git a/backend/sql/migrate_work_orders_to_production_batch_ledger.sql b/backend/sql/migrate_work_orders_to_production_batch_ledger.sql new file mode 100644 index 0000000..7bcce61 --- /dev/null +++ b/backend/sql/migrate_work_orders_to_production_batch_ledger.sql @@ -0,0 +1,111 @@ +SET NAMES utf8mb4; + +-- 目的:将历史正常生产工单的领料记录迁移到生产台账。 +-- 幂等性:生产台账按“材料库存批次 + 产品”唯一键更新,流水按历史工单领料明细防重复插入。 + +INSERT INTO pp_production_batch_ledger ( + material_lot_id, + material_lot_no, + material_item_id, + product_item_id, + status, + miniapp_selectable, + total_issued_weight_kg, + outside_weight_kg, + finished_inbound_qty, + surplus_inbound_weight_kg, + scrap_inbound_weight_kg, + writeoff_weight_kg, + first_issue_time, + last_issue_time, + remark +) +SELECT + issue.source_lot_id, + lot.lot_no, + issue.material_item_id, + wo.product_item_id, + CASE WHEN UPPER(COALESCE(wo.status, '')) = 'SETTLED' THEN '锁单' ELSE '在生产' END, + CASE WHEN UPPER(COALESCE(wo.status, '')) = 'SETTLED' THEN 0 ELSE 1 END, + SUM(COALESCE(issue.issued_weight_kg, 0)), + GREATEST(SUM(COALESCE(issue.issued_weight_kg, 0)) - COALESCE(MAX(wom.returned_weight_kg), 0), 0), + COALESCE(MAX(wo.finished_qty), 0), + COALESCE(MAX(wom.returned_weight_kg), 0), + 0, + 0, + MIN(issue.issue_time), + MAX(issue.issue_time), + '由历史生产工单迁移生成' +FROM pp_work_order wo +JOIN pp_work_order_material_issue issue ON issue.work_order_id = wo.id +JOIN wh_stock_lot lot ON lot.id = issue.source_lot_id +LEFT JOIN pp_work_order_material wom + ON wom.work_order_id = wo.id + AND wom.material_item_id = issue.material_item_id +WHERE wo.work_order_type = 'NORMAL' +GROUP BY + issue.source_lot_id, + lot.lot_no, + issue.material_item_id, + wo.product_item_id, + wo.status +ON DUPLICATE KEY UPDATE + material_lot_no = VALUES(material_lot_no), + material_item_id = VALUES(material_item_id), + status = VALUES(status), + miniapp_selectable = VALUES(miniapp_selectable), + total_issued_weight_kg = VALUES(total_issued_weight_kg), + outside_weight_kg = VALUES(outside_weight_kg), + finished_inbound_qty = VALUES(finished_inbound_qty), + surplus_inbound_weight_kg = VALUES(surplus_inbound_weight_kg), + scrap_inbound_weight_kg = VALUES(scrap_inbound_weight_kg), + writeoff_weight_kg = VALUES(writeoff_weight_kg), + first_issue_time = VALUES(first_issue_time), + last_issue_time = VALUES(last_issue_time), + remark = CASE + WHEN pp_production_batch_ledger.remark IS NULL OR pp_production_batch_ledger.remark = '' THEN '历史工单迁移已更新' + WHEN pp_production_batch_ledger.remark LIKE '%历史工单迁移已更新%' THEN pp_production_batch_ledger.remark + ELSE CONCAT(pp_production_batch_ledger.remark, ';历史工单迁移已更新') + END; + +-- 目的:为每条历史领料明细补充生产台账流水,保留原始工单与明细来源。 +INSERT INTO pp_production_batch_ledger_txn ( + production_ledger_id, + txn_type, + qty_delta, + weight_delta_kg, + outside_weight_after_kg, + source_doc_type, + source_doc_id, + source_line_id, + biz_time, + operator_user_id, + remark +) +SELECT + ledger.id, + '生产出库', + 0, + COALESCE(issue.issued_weight_kg, 0), + ledger.outside_weight_kg, + '历史生产工单', + issue.work_order_id, + issue.id, + issue.issue_time, + issue.operator_user_id, + '由历史生产工单领料记录迁移' +FROM pp_work_order_material_issue issue +JOIN pp_work_order wo ON wo.id = issue.work_order_id +JOIN pp_production_batch_ledger ledger + ON ledger.material_lot_id = issue.source_lot_id + AND ledger.product_item_id = wo.product_item_id +WHERE wo.work_order_type = 'NORMAL' + AND NOT EXISTS ( + SELECT 1 + FROM pp_production_batch_ledger_txn existing + WHERE existing.production_ledger_id = ledger.id + AND existing.source_doc_type = '历史生产工单' + AND existing.source_doc_id = issue.work_order_id + AND existing.source_line_id = issue.id + AND existing.txn_type = '生产出库' + ); diff --git a/backend/sql/optimize_miniapp_operation_report_indexes.sql b/backend/sql/optimize_miniapp_operation_report_indexes.sql new file mode 100644 index 0000000..75481ca --- /dev/null +++ b/backend/sql/optimize_miniapp_operation_report_indexes.sql @@ -0,0 +1,47 @@ +SET @idx_reports_report_date_id_exists := ( + SELECT COUNT(1) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'production_reports' + AND index_name = 'idx_reports_report_date_id' +); +SET @idx_reports_report_date_id_sql := IF( + @idx_reports_report_date_id_exists = 0, + 'ALTER TABLE production_reports ADD INDEX idx_reports_report_date_id (report_date, id)', + 'SELECT ''idx_reports_report_date_id already exists''' +); +PREPARE idx_reports_report_date_id_stmt FROM @idx_reports_report_date_id_sql; +EXECUTE idx_reports_report_date_id_stmt; +DEALLOCATE PREPARE idx_reports_report_date_id_stmt; + +SET @idx_reports_updated_at_id_exists := ( + SELECT COUNT(1) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'production_reports' + AND index_name = 'idx_reports_updated_at_id' +); +SET @idx_reports_updated_at_id_sql := IF( + @idx_reports_updated_at_id_exists = 0, + 'ALTER TABLE production_reports ADD INDEX idx_reports_updated_at_id (updated_at, id)', + 'SELECT ''idx_reports_updated_at_id already exists''' +); +PREPARE idx_reports_updated_at_id_stmt FROM @idx_reports_updated_at_id_sql; +EXECUTE idx_reports_updated_at_id_stmt; +DEALLOCATE PREPARE idx_reports_updated_at_id_stmt; + +SET @idx_report_items_report_id_id_exists := ( + SELECT COUNT(1) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'production_report_items' + AND index_name = 'idx_report_items_report_id_id' +); +SET @idx_report_items_report_id_id_sql := IF( + @idx_report_items_report_id_id_exists = 0, + 'ALTER TABLE production_report_items ADD INDEX idx_report_items_report_id_id (report_id, id)', + 'SELECT ''idx_report_items_report_id_id already exists''' +); +PREPARE idx_report_items_report_id_id_stmt FROM @idx_report_items_report_id_id_sql; +EXECUTE idx_report_items_report_id_id_stmt; +DEALLOCATE PREPARE idx_report_items_report_id_id_stmt; diff --git a/backend/sql/raw_material_weight_only_cleanup.sql b/backend/sql/raw_material_weight_only_cleanup.sql new file mode 100644 index 0000000..99710cb --- /dev/null +++ b/backend/sql/raw_material_weight_only_cleanup.sql @@ -0,0 +1,102 @@ +-- 原材料重量口径清理脚本 +-- 目标:原材料业务只保留重量,不再保留数量口径。 +-- 注意:成品、销售订单、发货、退货仍保留件数/PCS,不在本脚本处理范围内。 + +START TRANSACTION; + +UPDATE md_material material +JOIN md_item item ON item.id = material.item_id +SET + material.purchase_calc_mode = 'BY_WEIGHT', + material.purchase_multiple_qty = 0 +WHERE item.item_type = 'RAW_MATERIAL'; + +UPDATE md_bom_item bom_item +JOIN md_item item ON item.id = bom_item.material_item_id +SET + bom_item.usage_type = 'WEIGHT', + bom_item.qty_per_piece = 1 +WHERE item.item_type = 'RAW_MATERIAL'; + +UPDATE mrp_material_demand +SET suggested_purchase_qty = 0; + +UPDATE po_purchase_order_item po_item +JOIN md_item item ON item.id = po_item.material_item_id +SET + po_item.order_qty = 0, + po_item.received_qty = 0 +WHERE item.item_type = 'RAW_MATERIAL'; + +UPDATE po_receipt_item receipt_item +JOIN md_item item ON item.id = receipt_item.material_item_id +SET + receipt_item.received_qty = 0, + receipt_item.accepted_qty = 0 +WHERE item.item_type = 'RAW_MATERIAL'; + +UPDATE wh_stock_lot lot +JOIN md_item item ON item.id = lot.item_id +SET + lot.inbound_qty = 0, + lot.remaining_qty = 0, + lot.locked_qty = 0 +WHERE item.item_type = 'RAW_MATERIAL'; + +UPDATE wh_stock_balance balance +JOIN md_item item ON item.id = balance.item_id +SET + balance.qty_on_hand = 0, + balance.qty_available = 0, + balance.qty_allocated = 0 +WHERE item.item_type = 'RAW_MATERIAL'; + +UPDATE wh_inventory_txn txn +JOIN md_item item ON item.id = txn.item_id +SET txn.qty_change = 0 +WHERE item.item_type = 'RAW_MATERIAL'; + +UPDATE pp_work_order_material material +JOIN md_item item ON item.id = material.material_item_id +SET + material.required_qty = 0, + material.issued_qty = 0, + material.returned_qty = 0 +WHERE item.item_type = 'RAW_MATERIAL'; + +UPDATE pp_work_order_material_issue issue_row +JOIN md_item item ON item.id = issue_row.material_item_id +SET issue_row.issued_qty = 0 +WHERE item.item_type = 'RAW_MATERIAL'; + +UPDATE pp_work_order wo +JOIN ( + SELECT + wom.work_order_id, + FLOOR( + COALESCE(NULLIF(wom.issued_weight_kg, 0), NULLIF(wom.required_weight_kg, 0), 0) + / COALESCE(NULLIF(bi.gross_weight_per_piece_kg, 0), NULLIF(product.unit_weight_kg, 0)) + ) AS reference_qty + FROM pp_work_order_material wom + JOIN ( + SELECT work_order_id, MIN(id) AS first_material_row_id + FROM pp_work_order_material + GROUP BY work_order_id + ) first_row ON first_row.first_material_row_id = wom.id + JOIN pp_work_order wo_inner ON wo_inner.id = wom.work_order_id + JOIN md_item product ON product.id = wo_inner.product_item_id + JOIN md_bom_item bi ON bi.id = wom.bom_item_id + JOIN md_item material ON material.id = wom.material_item_id + WHERE material.item_type = 'RAW_MATERIAL' +) calc ON calc.work_order_id = wo.id +SET + wo.planned_qty = calc.reference_qty, + wo.released_qty = calc.reference_qty +WHERE calc.reference_qty > 0; + +UPDATE pp_work_order_operation operation +JOIN pp_work_order wo ON wo.id = operation.work_order_id +SET operation.planned_qty = wo.planned_qty +WHERE wo.planned_qty > 0; + +COMMIT; diff --git a/backend/sql/seed_auth_accounts.sql b/backend/sql/seed_auth_accounts.sql new file mode 100644 index 0000000..35be59b --- /dev/null +++ b/backend/sql/seed_auth_accounts.sql @@ -0,0 +1,616 @@ +USE jiaheng_erp; + +SET NAMES utf8mb4; + +START TRANSACTION; + +-- 菜单权限 +INSERT INTO sys_permission (permission_code, permission_name, module_code, action_code, status) +SELECT 'MENU_DASHBOARD', '经营驾驶舱菜单', 'DASHBOARD', 'VIEW', 'ACTIVE' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_permission WHERE permission_code = 'MENU_DASHBOARD'); + +INSERT INTO sys_permission (permission_code, permission_name, module_code, action_code, status) +SELECT 'MENU_SALES_PLANNING', '订单与计划中心菜单', 'SALES_PLANNING', 'VIEW', 'ACTIVE' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_permission WHERE permission_code = 'MENU_SALES_PLANNING'); + +INSERT INTO sys_permission (permission_code, permission_name, module_code, action_code, status) +SELECT 'MENU_PROCESS_CENTER', '流程与工序中心菜单', 'PROCESS_CENTER', 'VIEW', 'ACTIVE' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_permission WHERE permission_code = 'MENU_PROCESS_CENTER'); + +INSERT INTO sys_permission (permission_code, permission_name, module_code, action_code, status) +SELECT 'MENU_DATA_MODEL', '模块联动与数据设计菜单', 'DATA_MODEL', 'VIEW', 'ACTIVE' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_permission WHERE permission_code = 'MENU_DATA_MODEL'); + +-- 员工 +INSERT INTO hr_employee ( + employee_code, employee_name, dept_id, mobile, gender, hire_date, job_title, + shift_code, manager_employee_id, is_operator, is_workshop_staff, status, remark +) +SELECT + 'EMP-ADMIN-001', '系统管理员', + (SELECT id FROM sys_department WHERE dept_code = 'ADMIN'), + '13800000001', 'M', CURRENT_DATE(), 'ERP管理员', + 'DAY', NULL, 0, 0, 'ACTIVE', '系统初始化账号' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM hr_employee WHERE employee_code = 'EMP-ADMIN-001'); + +INSERT INTO hr_employee ( + employee_code, employee_name, dept_id, mobile, gender, hire_date, job_title, + shift_code, manager_employee_id, is_operator, is_workshop_staff, status, remark +) +SELECT + 'EMP-SALES-001', '销售专员', + (SELECT id FROM sys_department WHERE dept_code = 'SALES'), + '13800000002', 'F', CURRENT_DATE(), '销售跟单', + 'DAY', NULL, 0, 0, 'ACTIVE', '样例销售账号' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM hr_employee WHERE employee_code = 'EMP-SALES-001'); + +INSERT INTO hr_employee ( + employee_code, employee_name, dept_id, mobile, gender, hire_date, job_title, + shift_code, manager_employee_id, is_operator, is_workshop_staff, status, remark +) +SELECT + 'EMP-PLAN-001', '计划员', + (SELECT id FROM sys_department WHERE dept_code = 'PRODUCTION'), + '13800000003', 'M', CURRENT_DATE(), '生产计划', + 'DAY', NULL, 0, 0, 'ACTIVE', '样例计划账号' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM hr_employee WHERE employee_code = 'EMP-PLAN-001'); + +INSERT INTO hr_employee ( + employee_code, employee_name, dept_id, mobile, gender, hire_date, job_title, + shift_code, manager_employee_id, is_operator, is_workshop_staff, status, remark +) +SELECT + 'EMP-WH-001', '仓库主管', + (SELECT id FROM sys_department WHERE dept_code = 'WAREHOUSE'), + '13800000004', 'M', CURRENT_DATE(), '仓储主管', + 'DAY', NULL, 0, 0, 'ACTIVE', '样例仓储账号' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM hr_employee WHERE employee_code = 'EMP-WH-001'); + +INSERT INTO hr_employee ( + employee_code, employee_name, dept_id, mobile, gender, hire_date, job_title, + shift_code, manager_employee_id, is_operator, is_workshop_staff, status, remark +) +SELECT + 'EMP-SUP-001', '车间主管', + (SELECT id FROM sys_department WHERE dept_code = 'PRODUCTION'), + '13800000005', 'M', CURRENT_DATE(), '车间主管', + 'DAY', NULL, 0, 1, 'ACTIVE', '样例车间主管账号' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM hr_employee WHERE employee_code = 'EMP-SUP-001'); + +-- 用户 +INSERT INTO sys_user ( + username, password_hash, employee_id, dept_id, nickname, email, + is_super_admin, last_login_at, status +) +SELECT + 'admin', + 'pbkdf2_sha256$600000$wQvlXNnVpATAKxQ4TW41JQ==$KcuwnxttYYu1RyThhviUb4BaCztSkwZ_Z-oUkz3lwzs=', + (SELECT id FROM hr_employee WHERE employee_code = 'EMP-ADMIN-001'), + (SELECT id FROM sys_department WHERE dept_code = 'ADMIN'), + '系统管理员', 'admin@jiaheng.local', + 1, NULL, 'ACTIVE' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_user WHERE username = 'admin'); + +INSERT INTO sys_user ( + username, password_hash, employee_id, dept_id, nickname, email, + is_super_admin, last_login_at, status +) +SELECT + 'sales', + 'pbkdf2_sha256$600000$dK6ZnRGU7UWZGguuiTZ63g==$R3H8ds5shAvmwK9AblmKgufZvlJTIPWzK_6CvO_a73M=', + (SELECT id FROM hr_employee WHERE employee_code = 'EMP-SALES-001'), + (SELECT id FROM sys_department WHERE dept_code = 'SALES'), + '销售专员', 'sales@jiaheng.local', + 0, NULL, 'ACTIVE' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_user WHERE username = 'sales'); + +INSERT INTO sys_user ( + username, password_hash, employee_id, dept_id, nickname, email, + is_super_admin, last_login_at, status +) +SELECT + 'planner', + 'pbkdf2_sha256$600000$yJtJeB76-AWLXUKd8ex20A==$PyZUtFuPjGlb0nYLBx9IHunjhtnV-HjE5pauLD-ogHk=', + (SELECT id FROM hr_employee WHERE employee_code = 'EMP-PLAN-001'), + (SELECT id FROM sys_department WHERE dept_code = 'PRODUCTION'), + '计划员', 'planner@jiaheng.local', + 0, NULL, 'ACTIVE' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_user WHERE username = 'planner'); + +INSERT INTO sys_user ( + username, password_hash, employee_id, dept_id, nickname, email, + is_super_admin, last_login_at, status +) +SELECT + 'warehouse', + 'pbkdf2_sha256$600000$FmDxVJ07KoHNLocfUMilGw==$ZKuYJnKc_aUZ1el128w9Ukn3ofC_OQQrr-86iV412XY=', + (SELECT id FROM hr_employee WHERE employee_code = 'EMP-WH-001'), + (SELECT id FROM sys_department WHERE dept_code = 'WAREHOUSE'), + '仓库主管', 'warehouse@jiaheng.local', + 0, NULL, 'ACTIVE' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_user WHERE username = 'warehouse'); + +INSERT INTO sys_user ( + username, password_hash, employee_id, dept_id, nickname, email, + is_super_admin, last_login_at, status +) +SELECT + 'supervisor', + 'pbkdf2_sha256$600000$GsM4Kgs232UoRx-QoHmZ_w==$E33NOSOdhtd_-LqqxL9QAYjPmOhDqUkyxXNj5CRe1aY=', + (SELECT id FROM hr_employee WHERE employee_code = 'EMP-SUP-001'), + (SELECT id FROM sys_department WHERE dept_code = 'PRODUCTION'), + '车间主管', 'supervisor@jiaheng.local', + 0, NULL, 'ACTIVE' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_user WHERE username = 'supervisor'); + +-- 用户角色 +INSERT INTO sys_user_role (user_id, role_id) +SELECT + (SELECT id FROM sys_user WHERE username = 'admin'), + (SELECT id FROM sys_role WHERE role_code = 'ADMIN') +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_user_role + WHERE user_id = (SELECT id FROM sys_user WHERE username = 'admin') + AND role_id = (SELECT id FROM sys_role WHERE role_code = 'ADMIN') +); + +INSERT INTO sys_user_role (user_id, role_id) +SELECT + (SELECT id FROM sys_user WHERE username = 'sales'), + (SELECT id FROM sys_role WHERE role_code = 'SALES') +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_user_role + WHERE user_id = (SELECT id FROM sys_user WHERE username = 'sales') + AND role_id = (SELECT id FROM sys_role WHERE role_code = 'SALES') +); + +INSERT INTO sys_user_role (user_id, role_id) +SELECT + (SELECT id FROM sys_user WHERE username = 'planner'), + (SELECT id FROM sys_role WHERE role_code = 'PLANNER') +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_user_role + WHERE user_id = (SELECT id FROM sys_user WHERE username = 'planner') + AND role_id = (SELECT id FROM sys_role WHERE role_code = 'PLANNER') +); + +INSERT INTO sys_user_role (user_id, role_id) +SELECT + (SELECT id FROM sys_user WHERE username = 'warehouse'), + (SELECT id FROM sys_role WHERE role_code = 'WAREHOUSE') +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_user_role + WHERE user_id = (SELECT id FROM sys_user WHERE username = 'warehouse') + AND role_id = (SELECT id FROM sys_role WHERE role_code = 'WAREHOUSE') +); + +INSERT INTO sys_user_role (user_id, role_id) +SELECT + (SELECT id FROM sys_user WHERE username = 'supervisor'), + (SELECT id FROM sys_role WHERE role_code = 'SUPERVISOR') +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_user_role + WHERE user_id = (SELECT id FROM sys_user WHERE username = 'supervisor') + AND role_id = (SELECT id FROM sys_role WHERE role_code = 'SUPERVISOR') +); + +-- 角色菜单权限 +INSERT INTO sys_role_permission (role_id, permission_id) +SELECT + (SELECT id FROM sys_role WHERE role_code = 'ADMIN'), + (SELECT id FROM sys_permission WHERE permission_code = 'MENU_DASHBOARD') +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_role_permission + WHERE role_id = (SELECT id FROM sys_role WHERE role_code = 'ADMIN') + AND permission_id = (SELECT id FROM sys_permission WHERE permission_code = 'MENU_DASHBOARD') +); + +INSERT INTO sys_role_permission (role_id, permission_id) +SELECT + (SELECT id FROM sys_role WHERE role_code = 'ADMIN'), + (SELECT id FROM sys_permission WHERE permission_code = 'MENU_SALES_PLANNING') +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_role_permission + WHERE role_id = (SELECT id FROM sys_role WHERE role_code = 'ADMIN') + AND permission_id = (SELECT id FROM sys_permission WHERE permission_code = 'MENU_SALES_PLANNING') +); + +INSERT INTO sys_role_permission (role_id, permission_id) +SELECT + (SELECT id FROM sys_role WHERE role_code = 'ADMIN'), + (SELECT id FROM sys_permission WHERE permission_code = 'MENU_PROCESS_CENTER') +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_role_permission + WHERE role_id = (SELECT id FROM sys_role WHERE role_code = 'ADMIN') + AND permission_id = (SELECT id FROM sys_permission WHERE permission_code = 'MENU_PROCESS_CENTER') +); + +INSERT INTO sys_role_permission (role_id, permission_id) +SELECT + (SELECT id FROM sys_role WHERE role_code = 'ADMIN'), + (SELECT id FROM sys_permission WHERE permission_code = 'MENU_DATA_MODEL') +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_role_permission + WHERE role_id = (SELECT id FROM sys_role WHERE role_code = 'ADMIN') + AND permission_id = (SELECT id FROM sys_permission WHERE permission_code = 'MENU_DATA_MODEL') +); + +INSERT INTO sys_role_permission (role_id, permission_id) +SELECT + (SELECT id FROM sys_role WHERE role_code = 'SALES'), + (SELECT id FROM sys_permission WHERE permission_code = 'MENU_DASHBOARD') +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_role_permission + WHERE role_id = (SELECT id FROM sys_role WHERE role_code = 'SALES') + AND permission_id = (SELECT id FROM sys_permission WHERE permission_code = 'MENU_DASHBOARD') +); + +INSERT INTO sys_role_permission (role_id, permission_id) +SELECT + (SELECT id FROM sys_role WHERE role_code = 'SALES'), + (SELECT id FROM sys_permission WHERE permission_code = 'MENU_SALES_PLANNING') +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_role_permission + WHERE role_id = (SELECT id FROM sys_role WHERE role_code = 'SALES') + AND permission_id = (SELECT id FROM sys_permission WHERE permission_code = 'MENU_SALES_PLANNING') +); + +INSERT INTO sys_role_permission (role_id, permission_id) +SELECT + (SELECT id FROM sys_role WHERE role_code = 'PLANNER'), + (SELECT id FROM sys_permission WHERE permission_code = 'MENU_DASHBOARD') +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_role_permission + WHERE role_id = (SELECT id FROM sys_role WHERE role_code = 'PLANNER') + AND permission_id = (SELECT id FROM sys_permission WHERE permission_code = 'MENU_DASHBOARD') +); + +INSERT INTO sys_role_permission (role_id, permission_id) +SELECT + (SELECT id FROM sys_role WHERE role_code = 'PLANNER'), + (SELECT id FROM sys_permission WHERE permission_code = 'MENU_SALES_PLANNING') +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_role_permission + WHERE role_id = (SELECT id FROM sys_role WHERE role_code = 'PLANNER') + AND permission_id = (SELECT id FROM sys_permission WHERE permission_code = 'MENU_SALES_PLANNING') +); + +INSERT INTO sys_role_permission (role_id, permission_id) +SELECT + (SELECT id FROM sys_role WHERE role_code = 'PLANNER'), + (SELECT id FROM sys_permission WHERE permission_code = 'MENU_PROCESS_CENTER') +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_role_permission + WHERE role_id = (SELECT id FROM sys_role WHERE role_code = 'PLANNER') + AND permission_id = (SELECT id FROM sys_permission WHERE permission_code = 'MENU_PROCESS_CENTER') +); + +INSERT INTO sys_role_permission (role_id, permission_id) +SELECT + (SELECT id FROM sys_role WHERE role_code = 'WAREHOUSE'), + (SELECT id FROM sys_permission WHERE permission_code = 'MENU_DASHBOARD') +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_role_permission + WHERE role_id = (SELECT id FROM sys_role WHERE role_code = 'WAREHOUSE') + AND permission_id = (SELECT id FROM sys_permission WHERE permission_code = 'MENU_DASHBOARD') +); + +INSERT INTO sys_role_permission (role_id, permission_id) +SELECT + (SELECT id FROM sys_role WHERE role_code = 'WAREHOUSE'), + (SELECT id FROM sys_permission WHERE permission_code = 'MENU_PROCESS_CENTER') +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_role_permission + WHERE role_id = (SELECT id FROM sys_role WHERE role_code = 'WAREHOUSE') + AND permission_id = (SELECT id FROM sys_permission WHERE permission_code = 'MENU_PROCESS_CENTER') +); + +INSERT INTO sys_role_permission (role_id, permission_id) +SELECT + (SELECT id FROM sys_role WHERE role_code = 'SUPERVISOR'), + (SELECT id FROM sys_permission WHERE permission_code = 'MENU_DASHBOARD') +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_role_permission + WHERE role_id = (SELECT id FROM sys_role WHERE role_code = 'SUPERVISOR') + AND permission_id = (SELECT id FROM sys_permission WHERE permission_code = 'MENU_DASHBOARD') +); + +INSERT INTO sys_role_permission (role_id, permission_id) +SELECT + (SELECT id FROM sys_role WHERE role_code = 'SUPERVISOR'), + (SELECT id FROM sys_permission WHERE permission_code = 'MENU_PROCESS_CENTER') +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM sys_role_permission + WHERE role_id = (SELECT id FROM sys_role WHERE role_code = 'SUPERVISOR') + AND permission_id = (SELECT id FROM sys_permission WHERE permission_code = 'MENU_PROCESS_CENTER') +); + +-- 细分菜单权限 +INSERT INTO sys_permission (permission_code, permission_name, module_code, action_code, status) +SELECT p.permission_code, p.permission_name, p.module_code, p.action_code, 'ACTIVE' +FROM ( + SELECT 'MENU_SUPPLIER_MANAGEMENT' AS permission_code, '供应商管理菜单' AS permission_name, 'SUPPLIER_MANAGEMENT' AS module_code, 'VIEW' AS action_code + UNION ALL + SELECT 'MENU_PURCHASE_ORDER', '采购订单菜单', 'PURCHASE_ORDER', 'VIEW' + UNION ALL + SELECT 'MENU_PURCHASE_RECEIPT', '到货入库菜单', 'PURCHASE_RECEIPT', 'VIEW' + UNION ALL + SELECT 'MENU_INVENTORY_LEDGER', '原材料库存台账与批次菜单', 'INVENTORY_LEDGER', 'VIEW' + UNION ALL + SELECT 'MENU_WORK_ORDER', '生产工单菜单', 'WORK_ORDER', 'VIEW' + UNION ALL + SELECT 'MENU_OPERATION_REPORT', '工序报工菜单', 'OPERATION_REPORT', 'VIEW' + UNION ALL + SELECT 'MENU_SCRAP_REWORK', '报废返工处理菜单', 'SCRAP_REWORK', 'VIEW' + UNION ALL + SELECT 'MENU_COMPLETION_RECEIPT', '成品入库菜单', 'COMPLETION_RECEIPT', 'VIEW' + UNION ALL + SELECT 'MENU_DELIVERY_MANAGEMENT', '发货管理菜单', 'DELIVERY_MANAGEMENT', 'VIEW' + UNION ALL + SELECT 'MENU_EQUIPMENT_MANAGEMENT', '设备台账与保养菜单', 'EQUIPMENT_MANAGEMENT', 'VIEW' + UNION ALL + SELECT 'MENU_QUALITY_INSPECTION', '质量检验菜单', 'QUALITY_INSPECTION', 'VIEW' + UNION ALL + SELECT 'MENU_FINANCE_REPORT', '财务成本与报表菜单', 'FINANCE_REPORT', 'VIEW' + UNION ALL + SELECT 'MENU_RETURN_MANAGEMENT', '退货处理菜单', 'RETURN_MANAGEMENT', 'VIEW' + UNION ALL + SELECT 'MENU_MATERIAL_MANAGEMENT', '原材料管理菜单', 'MATERIAL_MANAGEMENT', 'VIEW' + UNION ALL + SELECT 'MENU_BOM_MANAGEMENT', 'BOM管理菜单', 'BOM_MANAGEMENT', 'VIEW' + UNION ALL + SELECT 'MENU_ROUTE_MANAGEMENT', '工艺路线管理菜单', 'ROUTE_MANAGEMENT', 'VIEW' + UNION ALL + SELECT 'MENU_EMPLOYEE_MANAGEMENT', '员工管理菜单', 'EMPLOYEE_MANAGEMENT', 'VIEW' + UNION ALL + SELECT 'MENU_PRODUCT_MANAGEMENT', '产品管理菜单', 'PRODUCT_MANAGEMENT', 'VIEW' + UNION ALL + SELECT 'MENU_SYSTEM_PERMISSION', '系统权限管理菜单', 'SYSTEM_PERMISSION', 'VIEW' + UNION ALL + SELECT 'MENU_SYSTEM_EXTENSION', '系统拓展菜单', 'SYSTEM_EXTENSION', 'VIEW' +) AS p +LEFT JOIN sys_permission existing ON existing.permission_code = p.permission_code +WHERE existing.id IS NULL; + +-- 补充角色测试账号对应员工 +INSERT INTO hr_employee ( + employee_code, employee_name, dept_id, mobile, gender, hire_date, job_title, + shift_code, manager_employee_id, is_operator, is_workshop_staff, status, remark +) +SELECT + e.employee_code, e.employee_name, d.id, e.mobile, e.gender, CURRENT_DATE(), e.job_title, + e.shift_code, NULL, e.is_operator, e.is_workshop_staff, 'ACTIVE', e.remark +FROM ( + SELECT 'EMP-PUR-001' AS employee_code, '采购专员' AS employee_name, 'PURCHASE' AS dept_code, '13800000006' AS mobile, 'F' AS gender, '采购跟单' AS job_title, 'DAY' AS shift_code, 0 AS is_operator, 0 AS is_workshop_staff, '样例采购账号' AS remark + UNION ALL + SELECT 'EMP-FIN-001', '财务专员', 'FINANCE', '13800000007', 'F', '成本会计', 'DAY', 0, 0, '样例财务账号' + UNION ALL + SELECT 'EMP-EQP-001', '设备管理员', 'EQUIPMENT', '13800000008', 'M', '设备工程师', 'DAY', 0, 0, '样例设备管理员账号' + UNION ALL + SELECT 'EMP-OP-001', '冲压操作工', 'PRODUCTION', '13800000009', 'M', '冲压操作工', 'A', 1, 1, '样例加工员工账号' +) AS e +JOIN sys_department d ON d.dept_code = e.dept_code +LEFT JOIN hr_employee existing ON existing.employee_code = e.employee_code +WHERE existing.id IS NULL; + +-- 补充系统账号 +INSERT INTO sys_user ( + username, password_hash, employee_id, dept_id, nickname, email, + is_super_admin, last_login_at, status +) +SELECT + u.username, u.password_hash, e.id, d.id, u.nickname, u.email, + 0, NULL, 'ACTIVE' +FROM ( + SELECT 'purchaser' AS username, 'pbkdf2_sha256$600000$ddxZt8DlrH-U27hQprqEzg==$QKgRLC3hdLmUI9nZ6AaP0mlVoH5vtu6diHeq9uS2fTg=' AS password_hash, 'EMP-PUR-001' AS employee_code, 'PURCHASE' AS dept_code, '采购专员' AS nickname, 'purchaser@jiaheng.local' AS email + UNION ALL + SELECT 'finance', 'pbkdf2_sha256$600000$K8pAnzCmAYKS74hZQ7M-uQ==$M1eT-INdsarnu297oEJNU_muEMxY8GMIGuYq3aiK_8A=', 'EMP-FIN-001', 'FINANCE', '财务专员', 'finance@jiaheng.local' + UNION ALL + SELECT 'equipadmin', 'pbkdf2_sha256$600000$FpH9NnUxpcxyc10mHnTzsg==$EHCnopdhmaIPzCIixzE431wEZVj7uk8IUaoA-0vAl1Y=', 'EMP-EQP-001', 'EQUIPMENT', '设备管理员', 'equipadmin@jiaheng.local' + UNION ALL + SELECT 'operator', 'pbkdf2_sha256$600000$zvfrPJQL-fSdmSbuJNns7w==$7U1npAmi-5KLSW3BPFWDwqXb0Vx-ag5q6rlVJVxTDkU=', 'EMP-OP-001', 'PRODUCTION', '加工员工', 'operator@jiaheng.local' +) AS u +JOIN hr_employee e ON e.employee_code = u.employee_code +JOIN sys_department d ON d.dept_code = u.dept_code +LEFT JOIN sys_user existing ON existing.username = u.username +WHERE existing.id IS NULL; + +-- 补充用户角色 +INSERT INTO sys_user_role (user_id, role_id) +SELECT u.id, r.id +FROM ( + SELECT 'purchaser' AS username, 'PURCHASER' AS role_code + UNION ALL + SELECT 'finance', 'FINANCE' + UNION ALL + SELECT 'equipadmin', 'EQUIP_ADMIN' + UNION ALL + SELECT 'operator', 'OPERATOR' +) AS m +JOIN sys_user u ON u.username = m.username +JOIN sys_role r ON r.role_code = m.role_code +LEFT JOIN sys_user_role existing ON existing.user_id = u.id AND existing.role_id = r.id +WHERE existing.id IS NULL; + +-- 细分角色菜单权限 +INSERT INTO sys_role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM ( + SELECT 'ADMIN' AS role_code, 'MENU_SUPPLIER_MANAGEMENT' AS permission_code + UNION ALL + SELECT 'ADMIN', 'MENU_PURCHASE_ORDER' + UNION ALL + SELECT 'ADMIN', 'MENU_PURCHASE_RECEIPT' + UNION ALL + SELECT 'ADMIN', 'MENU_INVENTORY_LEDGER' + UNION ALL + SELECT 'ADMIN', 'MENU_WORK_ORDER' + UNION ALL + SELECT 'ADMIN', 'MENU_OPERATION_REPORT' + UNION ALL + SELECT 'ADMIN', 'MENU_SCRAP_REWORK' + UNION ALL + SELECT 'ADMIN', 'MENU_COMPLETION_RECEIPT' + UNION ALL + SELECT 'ADMIN', 'MENU_DELIVERY_MANAGEMENT' + UNION ALL + SELECT 'ADMIN', 'MENU_EQUIPMENT_MANAGEMENT' + UNION ALL + SELECT 'ADMIN', 'MENU_QUALITY_INSPECTION' + UNION ALL + SELECT 'ADMIN', 'MENU_FINANCE_REPORT' + UNION ALL + SELECT 'ADMIN', 'MENU_RETURN_MANAGEMENT' + UNION ALL + SELECT 'ADMIN', 'MENU_MATERIAL_MANAGEMENT' + UNION ALL + SELECT 'ADMIN', 'MENU_BOM_MANAGEMENT' + UNION ALL + SELECT 'ADMIN', 'MENU_ROUTE_MANAGEMENT' + UNION ALL + SELECT 'ADMIN', 'MENU_EMPLOYEE_MANAGEMENT' + UNION ALL + SELECT 'ADMIN', 'MENU_PRODUCT_MANAGEMENT' + UNION ALL + SELECT 'ADMIN', 'MENU_SYSTEM_PERMISSION' + UNION ALL + SELECT 'ADMIN', 'MENU_SYSTEM_EXTENSION' + UNION ALL + SELECT 'SALES', 'MENU_DELIVERY_MANAGEMENT' + UNION ALL + SELECT 'SALES', 'MENU_RETURN_MANAGEMENT' + UNION ALL + SELECT 'PURCHASER', 'MENU_DASHBOARD' + UNION ALL + SELECT 'PURCHASER', 'MENU_SUPPLIER_MANAGEMENT' + UNION ALL + SELECT 'PURCHASER', 'MENU_PURCHASE_ORDER' + UNION ALL + SELECT 'PURCHASER', 'MENU_INVENTORY_LEDGER' + UNION ALL + SELECT 'PURCHASER', 'MENU_MATERIAL_MANAGEMENT' + UNION ALL + SELECT 'WAREHOUSE', 'MENU_PURCHASE_RECEIPT' + UNION ALL + SELECT 'WAREHOUSE', 'MENU_INVENTORY_LEDGER' + UNION ALL + SELECT 'WAREHOUSE', 'MENU_COMPLETION_RECEIPT' + UNION ALL + SELECT 'WAREHOUSE', 'MENU_DELIVERY_MANAGEMENT' + UNION ALL + SELECT 'WAREHOUSE', 'MENU_RETURN_MANAGEMENT' + UNION ALL + SELECT 'WAREHOUSE', 'MENU_QUALITY_INSPECTION' + UNION ALL + SELECT 'PLANNER', 'MENU_WORK_ORDER' + UNION ALL + SELECT 'PLANNER', 'MENU_INVENTORY_LEDGER' + UNION ALL + SELECT 'PLANNER', 'MENU_MATERIAL_MANAGEMENT' + UNION ALL + SELECT 'PLANNER', 'MENU_BOM_MANAGEMENT' + UNION ALL + SELECT 'PLANNER', 'MENU_ROUTE_MANAGEMENT' + UNION ALL + SELECT 'PLANNER', 'MENU_PRODUCT_MANAGEMENT' + UNION ALL + SELECT 'SUPERVISOR', 'MENU_WORK_ORDER' + UNION ALL + SELECT 'SUPERVISOR', 'MENU_OPERATION_REPORT' + UNION ALL + SELECT 'SUPERVISOR', 'MENU_SCRAP_REWORK' + UNION ALL + SELECT 'SUPERVISOR', 'MENU_COMPLETION_RECEIPT' + UNION ALL + SELECT 'SUPERVISOR', 'MENU_QUALITY_INSPECTION' + UNION ALL + SELECT 'SUPERVISOR', 'MENU_BOM_MANAGEMENT' + UNION ALL + SELECT 'SUPERVISOR', 'MENU_ROUTE_MANAGEMENT' + UNION ALL + SELECT 'SUPERVISOR', 'MENU_PRODUCT_MANAGEMENT' + UNION ALL + SELECT 'OPERATOR', 'MENU_OPERATION_REPORT' + UNION ALL + SELECT 'FINANCE', 'MENU_DASHBOARD' + UNION ALL + SELECT 'FINANCE', 'MENU_INVENTORY_LEDGER' + UNION ALL + SELECT 'FINANCE', 'MENU_FINANCE_REPORT' + UNION ALL + SELECT 'FINANCE', 'MENU_DELIVERY_MANAGEMENT' + UNION ALL + SELECT 'FINANCE', 'MENU_RETURN_MANAGEMENT' + UNION ALL + SELECT 'EQUIP_ADMIN', 'MENU_DASHBOARD' + UNION ALL + SELECT 'EQUIP_ADMIN', 'MENU_EQUIPMENT_MANAGEMENT' + UNION ALL + SELECT 'ADMIN', 'MENU_DASHBOARD' + UNION ALL + SELECT 'ADMIN', 'MENU_SALES_PLANNING' + UNION ALL + SELECT 'ADMIN', 'MENU_PROCESS_CENTER' + UNION ALL + SELECT 'ADMIN', 'MENU_DATA_MODEL' + UNION ALL + SELECT 'SALES', 'MENU_DASHBOARD' + UNION ALL + SELECT 'SALES', 'MENU_SALES_PLANNING' + UNION ALL + SELECT 'PLANNER', 'MENU_DASHBOARD' + UNION ALL + SELECT 'PLANNER', 'MENU_SALES_PLANNING' + UNION ALL + SELECT 'PLANNER', 'MENU_PROCESS_CENTER' + UNION ALL + SELECT 'SUPERVISOR', 'MENU_DASHBOARD' + UNION ALL + SELECT 'SUPERVISOR', 'MENU_PROCESS_CENTER' +) AS rp +JOIN sys_role r ON r.role_code = rp.role_code +JOIN sys_permission p ON p.permission_code = rp.permission_code +LEFT JOIN sys_role_permission existing ON existing.role_id = r.id AND existing.permission_id = p.id +WHERE existing.id IS NULL; + +-- 当前仓储角色不再展示流程与工序中心总菜单,改为按仓储页面细分展示 +DELETE rp +FROM sys_role_permission rp +JOIN sys_role r ON r.id = rp.role_id +JOIN sys_permission p ON p.id = rp.permission_id +WHERE r.role_code = 'WAREHOUSE' + AND p.permission_code = 'MENU_PROCESS_CENTER'; + +COMMIT; diff --git a/backend/sql/seed_benti_master_data.sql b/backend/sql/seed_benti_master_data.sql new file mode 100644 index 0000000..89965e8 --- /dev/null +++ b/backend/sql/seed_benti_master_data.sql @@ -0,0 +1,682 @@ +USE jiaheng_erp; + +SET NAMES utf8mb4; + +START TRANSACTION; + +-- 基础组织 +INSERT INTO sys_department (dept_code, dept_name, parent_id, dept_type, manager_name, status, sort_no, remark) +SELECT 'ADMIN', '综合管理部', NULL, 'ADMIN', '总经理', 'ACTIVE', 10, '负责主数据、权限、经营总览' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_department WHERE dept_code = 'ADMIN'); + +INSERT INTO sys_department (dept_code, dept_name, parent_id, dept_type, manager_name, status, sort_no, remark) +SELECT 'SALES', '销售部', NULL, 'SALES', '销售主管', 'ACTIVE', 20, '负责客户、订单、交期' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_department WHERE dept_code = 'SALES'); + +INSERT INTO sys_department (dept_code, dept_name, parent_id, dept_type, manager_name, status, sort_no, remark) +SELECT 'PURCHASE', '采购部', NULL, 'PURCHASE', '采购主管', 'ACTIVE', 30, '负责钢板采购与供应商协同' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_department WHERE dept_code = 'PURCHASE'); + +INSERT INTO sys_department (dept_code, dept_name, parent_id, dept_type, manager_name, status, sort_no, remark) +SELECT 'WAREHOUSE', '仓储部', NULL, 'WAREHOUSE', '仓库主管', 'ACTIVE', 40, '负责原材料、成品、退货、废料收发存' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_department WHERE dept_code = 'WAREHOUSE'); + +INSERT INTO sys_department (dept_code, dept_name, parent_id, dept_type, manager_name, status, sort_no, remark) +SELECT 'PRODUCTION', '生产部', NULL, 'PRODUCTION', '车间主任', 'ACTIVE', 50, '负责工单、工序、报工' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_department WHERE dept_code = 'PRODUCTION'); + +INSERT INTO sys_department (dept_code, dept_name, parent_id, dept_type, manager_name, status, sort_no, remark) +SELECT 'FINANCE', '财务部', NULL, 'FINANCE', '财务主管', 'ACTIVE', 60, '负责成本、对账、报表' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_department WHERE dept_code = 'FINANCE'); + +INSERT INTO sys_department (dept_code, dept_name, parent_id, dept_type, manager_name, status, sort_no, remark) +SELECT 'EQUIPMENT', '设备部', NULL, 'EQUIPMENT', '设备主管', 'ACTIVE', 70, '负责设备保养、维修、点检' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_department WHERE dept_code = 'EQUIPMENT'); + +-- 基础角色 +INSERT INTO sys_role (role_code, role_name, role_scope, status, remark) +SELECT 'ADMIN', '系统管理员', 'SYSTEM', 'ACTIVE', '全局主数据、参数、异常中心' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_role WHERE role_code = 'ADMIN'); + +INSERT INTO sys_role (role_code, role_name, role_scope, status, remark) +SELECT 'SALES', '销售员', 'SYSTEM', 'ACTIVE', '客户与订单维护' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_role WHERE role_code = 'SALES'); + +INSERT INTO sys_role (role_code, role_name, role_scope, status, remark) +SELECT 'PURCHASER', '采购员', 'SYSTEM', 'ACTIVE', '采购申请、采购订单、到货跟踪' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_role WHERE role_code = 'PURCHASER'); + +INSERT INTO sys_role (role_code, role_name, role_scope, status, remark) +SELECT 'WAREHOUSE', '仓库员', 'SYSTEM', 'ACTIVE', '收货、上架、领料、盘点、发货' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_role WHERE role_code = 'WAREHOUSE'); + +INSERT INTO sys_role (role_code, role_name, role_scope, status, remark) +SELECT 'PLANNER', '计划员', 'SYSTEM', 'ACTIVE', 'MRP、工单下达、排产' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_role WHERE role_code = 'PLANNER'); + +INSERT INTO sys_role (role_code, role_name, role_scope, status, remark) +SELECT 'SUPERVISOR', '车间主管', 'SYSTEM', 'ACTIVE', '工单进度、异常审核、现场调度' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_role WHERE role_code = 'SUPERVISOR'); + +INSERT INTO sys_role (role_code, role_name, role_scope, status, remark) +SELECT 'OPERATOR', '加工员工', 'SYSTEM', 'ACTIVE', '扫码报工、报废反馈、停机反馈' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_role WHERE role_code = 'OPERATOR'); + +INSERT INTO sys_role (role_code, role_name, role_scope, status, remark) +SELECT 'FINANCE', '财务员', 'SYSTEM', 'ACTIVE', '费用归集、报表、核对' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_role WHERE role_code = 'FINANCE'); + +INSERT INTO sys_role (role_code, role_name, role_scope, status, remark) +SELECT 'EQUIP_ADMIN', '设备管理员', 'SYSTEM', 'ACTIVE', '设备台账、保养计划、维修' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_role WHERE role_code = 'EQUIP_ADMIN'); + +-- 单位 +INSERT INTO md_unit (unit_code, unit_name, precision_digits) +SELECT 'PCS', '件', 0 FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM md_unit WHERE unit_code = 'PCS'); + +INSERT INTO md_unit (unit_code, unit_name, precision_digits) +SELECT 'KG', '千克', 3 FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM md_unit WHERE unit_code = 'KG'); + +INSERT INTO md_unit (unit_code, unit_name, precision_digits) +SELECT 'TON', '吨', 3 FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM md_unit WHERE unit_code = 'TON'); + +INSERT INTO md_unit (unit_code, unit_name, precision_digits) +SELECT 'SHEET', '张', 0 FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM md_unit WHERE unit_code = 'SHEET'); + +-- 物料分类 +INSERT INTO md_item_category (category_code, category_name, parent_id, item_type, sort_no, status) +SELECT 'CAT-RM-PLATE', '钢板原材料', NULL, 'RAW_MATERIAL', 10, 'ACTIVE' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM md_item_category WHERE category_code = 'CAT-RM-PLATE'); + +INSERT INTO md_item_category (category_code, category_name, parent_id, item_type, sort_no, status) +SELECT 'CAT-FG-HARDWARE', '五金成品', NULL, 'FINISHED_GOOD', 20, 'ACTIVE' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM md_item_category WHERE category_code = 'CAT-FG-HARDWARE'); + +INSERT INTO md_item_category (category_code, category_name, parent_id, item_type, sort_no, status) +SELECT 'CAT-SCRAP-METAL', '金属废料', NULL, 'SCRAP', 30, 'ACTIVE' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM md_item_category WHERE category_code = 'CAT-SCRAP-METAL'); + +-- 供应商 +INSERT INTO md_supplier (supplier_code, supplier_name, short_name, contact_name, contact_phone, address, lead_time_days, default_tax_rate, status, remark) +SELECT 'SUP-NB-JG-001', '宁波嘉钢材料有限公司', '嘉钢材料', '王经理', '13800001111', '宁波市镇海区钢材市场', 5, 0.1300, 'ACTIVE', '本体默认板材供应商' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM md_supplier WHERE supplier_code = 'SUP-NB-JG-001'); + +-- 仓库 +INSERT INTO wh_warehouse (warehouse_code, warehouse_name, warehouse_type, manager_employee_id, status, remark) +SELECT 'WH-RM-01', '原材料库', 'RAW', NULL, 'ACTIVE', '自家料、客供料等生产主材库存' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM wh_warehouse WHERE warehouse_code = 'WH-RM-01'); + +INSERT INTO wh_warehouse (warehouse_code, warehouse_name, warehouse_type, manager_employee_id, status, remark) +SELECT 'WH-SEMI-01', '半成品库', 'SEMI', NULL, 'ACTIVE', '加工到一半、尚未完成全部工艺的半成品暂存' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM wh_warehouse WHERE warehouse_code = 'WH-SEMI-01'); + +INSERT INTO wh_warehouse (warehouse_code, warehouse_name, warehouse_type, manager_employee_id, status, remark) +SELECT 'WH-FG-01', '成品库', 'FINISHED', NULL, 'ACTIVE', '完工入库后可发货的成品库存' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM wh_warehouse WHERE warehouse_code = 'WH-FG-01'); + +INSERT INTO wh_warehouse (warehouse_code, warehouse_name, warehouse_type, manager_employee_id, status, remark) +SELECT 'WH-AUX-01', '辅料库', 'AUX', NULL, 'ACTIVE', '手套、薄膜、纸箱、螺丝钉等生产消耗辅料库存' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM wh_warehouse WHERE warehouse_code = 'WH-AUX-01'); + +INSERT INTO wh_warehouse (warehouse_code, warehouse_name, warehouse_type, manager_employee_id, status, remark) +SELECT 'WH-RT-01', '退货仓', 'RETURN', NULL, 'ACTIVE', '客户退货待判定区域' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM wh_warehouse WHERE warehouse_code = 'WH-RT-01'); + +INSERT INTO wh_warehouse (warehouse_code, warehouse_name, warehouse_type, manager_employee_id, status, remark) +SELECT 'WH-SCRAP-01', '废料库', 'SCRAP', NULL, 'ACTIVE', '生产废料、边角料、委外废料、报废品等废料库存' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM wh_warehouse WHERE warehouse_code = 'WH-SCRAP-01'); + +-- 默认库位 +INSERT INTO wh_location (warehouse_id, location_code, location_name, zone_name, is_default, is_locked, status, remark) +SELECT (SELECT id FROM wh_warehouse WHERE warehouse_code = 'WH-RM-01'), 'RM-A-01', '原料主库位', 'A区', 1, 0, 'ACTIVE', '默认收货库位' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM wh_location + WHERE warehouse_id = (SELECT id FROM wh_warehouse WHERE warehouse_code = 'WH-RM-01') + AND location_code = 'RM-A-01' +); + +INSERT INTO wh_location (warehouse_id, location_code, location_name, zone_name, is_default, is_locked, status, remark) +SELECT (SELECT id FROM wh_warehouse WHERE warehouse_code = 'WH-SEMI-01'), 'SEMI-B-01', '半成品暂存位', 'B区', 1, 0, 'ACTIVE', '半成品暂存' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM wh_location + WHERE warehouse_id = (SELECT id FROM wh_warehouse WHERE warehouse_code = 'WH-SEMI-01') + AND location_code = 'SEMI-B-01' +); + +INSERT INTO wh_location (warehouse_id, location_code, location_name, zone_name, is_default, is_locked, status, remark) +SELECT (SELECT id FROM wh_warehouse WHERE warehouse_code = 'WH-FG-01'), 'FG-C-01', '成品主库位', 'C区', 1, 0, 'ACTIVE', '成品默认库位' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM wh_location + WHERE warehouse_id = (SELECT id FROM wh_warehouse WHERE warehouse_code = 'WH-FG-01') + AND location_code = 'FG-C-01' +); + +INSERT INTO wh_location (warehouse_id, location_code, location_name, zone_name, is_default, is_locked, status, remark) +SELECT (SELECT id FROM wh_warehouse WHERE warehouse_code = 'WH-AUX-01'), 'AUX-D-01', '辅料主库位', 'D区', 1, 0, 'ACTIVE', '辅料默认库位' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM wh_location + WHERE warehouse_id = (SELECT id FROM wh_warehouse WHERE warehouse_code = 'WH-AUX-01') + AND location_code = 'AUX-D-01' +); + +INSERT INTO wh_location (warehouse_id, location_code, location_name, zone_name, is_default, is_locked, status, remark) +SELECT (SELECT id FROM wh_warehouse WHERE warehouse_code = 'WH-RT-01'), 'RT-D-01', '退货暂存位', 'D区', 1, 0, 'ACTIVE', '退货待返工或报废' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM wh_location + WHERE warehouse_id = (SELECT id FROM wh_warehouse WHERE warehouse_code = 'WH-RT-01') + AND location_code = 'RT-D-01' +); + +INSERT INTO wh_location (warehouse_id, location_code, location_name, zone_name, is_default, is_locked, status, remark) +SELECT (SELECT id FROM wh_warehouse WHERE warehouse_code = 'WH-SCRAP-01'), 'SCR-E-01', '废料暂存位', 'E区', 1, 0, 'ACTIVE', '金属废料集中存放' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM wh_location + WHERE warehouse_id = (SELECT id FROM wh_warehouse WHERE warehouse_code = 'WH-SCRAP-01') + AND location_code = 'SCR-E-01' +); + +-- 工序 +INSERT INTO md_process (process_code, process_name, process_type, is_report_required, is_quality_gate, status, remark) +SELECT 'PROC-KP', '开平', 'PREP', 1, 0, 'ACTIVE', '钢板整平、裁片准备' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM md_process WHERE process_code = 'PROC-KP'); + +INSERT INTO md_process (process_code, process_name, process_type, is_report_required, is_quality_gate, status, remark) +SELECT 'PROC-CY', '冲压', 'PRESS', 1, 0, 'ACTIVE', '主成形工序,重点监控报废' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM md_process WHERE process_code = 'PROC-CY'); + +INSERT INTO md_process (process_code, process_name, process_type, is_report_required, is_quality_gate, status, remark) +SELECT 'PROC-MC', '去毛刺', 'FINISH', 1, 0, 'ACTIVE', '去除冲压后毛刺' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM md_process WHERE process_code = 'PROC-MC'); + +INSERT INTO md_process (process_code, process_name, process_type, is_report_required, is_quality_gate, status, remark) +SELECT 'PROC-ZW', '折弯', 'BEND', 1, 0, 'ACTIVE', '形成结构外形' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM md_process WHERE process_code = 'PROC-ZW'); + +INSERT INTO md_process (process_code, process_name, process_type, is_report_required, is_quality_gate, status, remark) +SELECT 'PROC-HJ', '焊接', 'WELD', 1, 0, 'ACTIVE', '关键连接工序' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM md_process WHERE process_code = 'PROC-HJ'); + +INSERT INTO md_process (process_code, process_name, process_type, is_report_required, is_quality_gate, status, remark) +SELECT 'PROC-QA', '检验包装', 'QA', 1, 1, 'ACTIVE', '终检、包装、入库前放行' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM md_process WHERE process_code = 'PROC-QA'); + +-- 工作中心 +INSERT INTO md_work_center (center_code, center_name, dept_id, center_type, shift_pattern, capacity_hours_per_day, status, remark) +SELECT 'WC-KP-01', '开平工作中心', (SELECT id FROM sys_department WHERE dept_code = 'PRODUCTION'), 'PREP', '两班制', 16.00, 'ACTIVE', '本体板材预处理' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM md_work_center WHERE center_code = 'WC-KP-01'); + +INSERT INTO md_work_center (center_code, center_name, dept_id, center_type, shift_pattern, capacity_hours_per_day, status, remark) +SELECT 'WC-CY-01', '冲压工作中心', (SELECT id FROM sys_department WHERE dept_code = 'PRODUCTION'), 'PRESS', '两班制', 16.00, 'ACTIVE', '本体冲压成形' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM md_work_center WHERE center_code = 'WC-CY-01'); + +INSERT INTO md_work_center (center_code, center_name, dept_id, center_type, shift_pattern, capacity_hours_per_day, status, remark) +SELECT 'WC-MC-01', '去毛刺工作中心', (SELECT id FROM sys_department WHERE dept_code = 'PRODUCTION'), 'FINISH', '两班制', 16.00, 'ACTIVE', '去毛刺与修边' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM md_work_center WHERE center_code = 'WC-MC-01'); + +INSERT INTO md_work_center (center_code, center_name, dept_id, center_type, shift_pattern, capacity_hours_per_day, status, remark) +SELECT 'WC-ZW-01', '折弯工作中心', (SELECT id FROM sys_department WHERE dept_code = 'PRODUCTION'), 'BEND', '两班制', 16.00, 'ACTIVE', '折弯定型' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM md_work_center WHERE center_code = 'WC-ZW-01'); + +INSERT INTO md_work_center (center_code, center_name, dept_id, center_type, shift_pattern, capacity_hours_per_day, status, remark) +SELECT 'WC-HJ-01', '焊接工作中心', (SELECT id FROM sys_department WHERE dept_code = 'PRODUCTION'), 'WELD', '两班制', 16.00, 'ACTIVE', '焊接与补焊' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM md_work_center WHERE center_code = 'WC-HJ-01'); + +INSERT INTO md_work_center (center_code, center_name, dept_id, center_type, shift_pattern, capacity_hours_per_day, status, remark) +SELECT 'WC-QA-01', '检验包装工作中心', (SELECT id FROM sys_department WHERE dept_code = 'PRODUCTION'), 'QA', '白班', 8.00, 'ACTIVE', '终检、包装、贴标' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM md_work_center WHERE center_code = 'WC-QA-01'); + +-- 物料主档:原材料、成品、废料 +INSERT INTO md_item ( + item_code, item_name, item_type, category_id, specification, model_no, material_type, material_grade, + length_mm, width_mm, thickness_mm, density_kg_m3, theoretical_weight_kg, unit_weight_kg, + sales_unit_id, stock_unit_id, purchase_unit_id, safety_stock_qty, safety_stock_weight_kg, + scrap_sale_price, status, remark +) +SELECT + 'RM-SAP440-500600-2.5', 'SAP440钢板', 'RAW_MATERIAL', + (SELECT id FROM md_item_category WHERE category_code = 'CAT-RM-PLATE'), + '500*600*2.5', NULL, '钢板', 'SAP440', + 500.000, 600.000, 2.500, 7850.000, 5.887500, 5.887500, + (SELECT id FROM md_unit WHERE unit_code = 'KG'), + (SELECT id FROM md_unit WHERE unit_code = 'KG'), + (SELECT id FROM md_unit WHERE unit_code = 'KG'), + 0.000000, 10000.000000, + 0.0000, 'ACTIVE', '本体标准板材;采购按重量结算,采购倍数按2吨' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM md_item WHERE item_code = 'RM-SAP440-500600-2.5'); + +INSERT INTO md_item ( + item_code, item_name, item_type, category_id, specification, model_no, material_type, material_grade, + length_mm, width_mm, thickness_mm, density_kg_m3, theoretical_weight_kg, unit_weight_kg, + sales_unit_id, stock_unit_id, purchase_unit_id, safety_stock_qty, safety_stock_weight_kg, + scrap_sale_price, status, remark +) +SELECT + 'FG-BT-001', '本体', 'FINISHED_GOOD', + (SELECT id FROM md_item_category WHERE category_code = 'CAT-FG-HARDWARE'), + '标准结构件', 'BT-V1', '五金结构件', NULL, + 320.000, 145.000, 0.000, 0.000, 5.200000, 5.200000, + (SELECT id FROM md_unit WHERE unit_code = 'PCS'), + (SELECT id FROM md_unit WHERE unit_code = 'PCS'), + (SELECT id FROM md_unit WHERE unit_code = 'PCS'), + 500.000000, 0.000000, + 0.0000, 'ACTIVE', '宁波嘉恒智能本体示例成品,用于演示销售、MRP、工单、成本闭环' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM md_item WHERE item_code = 'FG-BT-001'); + +INSERT INTO md_item ( + item_code, item_name, item_type, category_id, specification, model_no, material_type, material_grade, + length_mm, width_mm, thickness_mm, density_kg_m3, theoretical_weight_kg, unit_weight_kg, + sales_unit_id, stock_unit_id, purchase_unit_id, safety_stock_qty, safety_stock_weight_kg, + scrap_sale_price, status, remark +) +SELECT + 'SCR-SAP440-EDGE', 'SAP440边角废料', 'SCRAP', + (SELECT id FROM md_item_category WHERE category_code = 'CAT-SCRAP-METAL'), + '冲压边角料', NULL, '金属废料', 'SAP440', + 0.000, 0.000, 0.000, 7850.000, 1.000000, 1.000000, + (SELECT id FROM md_unit WHERE unit_code = 'KG'), + (SELECT id FROM md_unit WHERE unit_code = 'KG'), + (SELECT id FROM md_unit WHERE unit_code = 'KG'), + 0.000000, 0.000000, + 1.8000, 'ACTIVE', '本体生产过程中产生的可售废料' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM md_item WHERE item_code = 'SCR-SAP440-EDGE'); + +-- 原材料扩展 +INSERT INTO md_material ( + item_id, default_supplier_id, purchase_calc_mode, min_purchase_qty, purchase_multiple_qty, + purchase_multiple_weight_kg, lead_time_days, quality_rule +) +SELECT + (SELECT id FROM md_item WHERE item_code = 'RM-SAP440-500600-2.5'), + (SELECT id FROM md_supplier WHERE supplier_code = 'SUP-NB-JG-001'), + 'BY_WEIGHT', 2000.000000, 0.000000, 2000.000000, 5, + '需提供材质证明;来料需检验厚度、表面状态、重量偏差' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM md_material + WHERE item_id = (SELECT id FROM md_item WHERE item_code = 'RM-SAP440-500600-2.5') +); + +-- 产品扩展 +INSERT INTO md_product ( + item_id, drawing_no, product_family, version_no, standard_output_qty, + allowed_scrap_rate, quality_standard +) +SELECT + (SELECT id FROM md_item WHERE item_code = 'FG-BT-001'), + 'JH-BT-001', '结构件', 'V1.0', 1.000000, 0.0350, + '尺寸公差控制在图纸范围内;焊点连续;表面无明显压伤、裂纹、毛刺' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM md_product + WHERE item_id = (SELECT id FROM md_item WHERE item_code = 'FG-BT-001') +); + +-- BOM +INSERT INTO md_bom (bom_code, product_item_id, version_no, is_default, status, effective_date, expire_date, remark) +SELECT + 'BOM-BT-001', + (SELECT id FROM md_item WHERE item_code = 'FG-BT-001'), + 'V1.0', 1, 'ACTIVE', CURRENT_DATE(), NULL, + '本体标准BOM:单件毛重5.9kg,单件净重5.2kg' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM md_bom WHERE bom_code = 'BOM-BT-001'); + +INSERT INTO md_bom_item ( + bom_id, seq_no, material_item_id, usage_type, qty_per_piece, net_weight_per_piece_kg, + gross_weight_per_piece_kg, loss_rate, issue_over_tolerance_rate, remark +) +SELECT + (SELECT id FROM md_bom WHERE bom_code = 'BOM-BT-001'), + 10, + (SELECT id FROM md_item WHERE item_code = 'RM-SAP440-500600-2.5'), + 'WEIGHT', 1.000000, 5.200000, 5.900000, 0.1180, 0.0200, + '按单件毛重5.9kg参与MRP;按采购2吨倍数向上取整' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM md_bom_item + WHERE bom_id = (SELECT id FROM md_bom WHERE bom_code = 'BOM-BT-001') + AND seq_no = 10 +); + +-- 工艺路线 +INSERT INTO md_process_route ( + route_code, route_name, product_item_id, version_no, is_default, status, effective_date, expire_date, remark +) +SELECT + 'ROUTE-BT-001', '本体标准工艺路线', + (SELECT id FROM md_item WHERE item_code = 'FG-BT-001'), + 'V1.0', 1, 'ACTIVE', CURRENT_DATE(), NULL, + '适用于本体标准批量生产的六道工序路线' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM md_process_route WHERE route_code = 'ROUTE-BT-001'); + +INSERT INTO md_process_route_operation ( + route_id, seq_no, process_id, work_center_id, operation_name, + std_setup_minutes, std_cycle_seconds, std_labor_cost, std_machine_cost, std_outsource_cost, + allowed_scrap_rate, report_required, is_key_operation, status, remark +) +SELECT + (SELECT id FROM md_process_route WHERE route_code = 'ROUTE-BT-001'), + 10, + (SELECT id FROM md_process WHERE process_code = 'PROC-KP'), + (SELECT id FROM md_work_center WHERE center_code = 'WC-KP-01'), + '开平', + 20.00, 6.00, 0.12, 0.18, 0.00, 0.0050, 1, 0, 'ACTIVE', '板材整平、送料准备' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM md_process_route_operation + WHERE route_id = (SELECT id FROM md_process_route WHERE route_code = 'ROUTE-BT-001') + AND seq_no = 10 +); + +INSERT INTO md_process_route_operation ( + route_id, seq_no, process_id, work_center_id, operation_name, + std_setup_minutes, std_cycle_seconds, std_labor_cost, std_machine_cost, std_outsource_cost, + allowed_scrap_rate, report_required, is_key_operation, status, remark +) +SELECT + (SELECT id FROM md_process_route WHERE route_code = 'ROUTE-BT-001'), + 20, + (SELECT id FROM md_process WHERE process_code = 'PROC-CY'), + (SELECT id FROM md_work_center WHERE center_code = 'WC-CY-01'), + '冲压', + 45.00, 9.00, 0.34, 0.46, 0.00, 0.0200, 1, 1, 'ACTIVE', '主成形工序,模具与板材波动需重点监控' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM md_process_route_operation + WHERE route_id = (SELECT id FROM md_process_route WHERE route_code = 'ROUTE-BT-001') + AND seq_no = 20 +); + +INSERT INTO md_process_route_operation ( + route_id, seq_no, process_id, work_center_id, operation_name, + std_setup_minutes, std_cycle_seconds, std_labor_cost, std_machine_cost, std_outsource_cost, + allowed_scrap_rate, report_required, is_key_operation, status, remark +) +SELECT + (SELECT id FROM md_process_route WHERE route_code = 'ROUTE-BT-001'), + 30, + (SELECT id FROM md_process WHERE process_code = 'PROC-MC'), + (SELECT id FROM md_work_center WHERE center_code = 'WC-MC-01'), + '去毛刺', + 15.00, 5.00, 0.08, 0.06, 0.00, 0.0080, 1, 0, 'ACTIVE', '清理毛刺与锐边' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM md_process_route_operation + WHERE route_id = (SELECT id FROM md_process_route WHERE route_code = 'ROUTE-BT-001') + AND seq_no = 30 +); + +INSERT INTO md_process_route_operation ( + route_id, seq_no, process_id, work_center_id, operation_name, + std_setup_minutes, std_cycle_seconds, std_labor_cost, std_machine_cost, std_outsource_cost, + allowed_scrap_rate, report_required, is_key_operation, status, remark +) +SELECT + (SELECT id FROM md_process_route WHERE route_code = 'ROUTE-BT-001'), + 40, + (SELECT id FROM md_process WHERE process_code = 'PROC-ZW'), + (SELECT id FROM md_work_center WHERE center_code = 'WC-ZW-01'), + '折弯', + 20.00, 8.00, 0.21, 0.22, 0.00, 0.0100, 1, 1, 'ACTIVE', '关键角度成形控制' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM md_process_route_operation + WHERE route_id = (SELECT id FROM md_process_route WHERE route_code = 'ROUTE-BT-001') + AND seq_no = 40 +); + +INSERT INTO md_process_route_operation ( + route_id, seq_no, process_id, work_center_id, operation_name, + std_setup_minutes, std_cycle_seconds, std_labor_cost, std_machine_cost, std_outsource_cost, + allowed_scrap_rate, report_required, is_key_operation, status, remark +) +SELECT + (SELECT id FROM md_process_route WHERE route_code = 'ROUTE-BT-001'), + 50, + (SELECT id FROM md_process WHERE process_code = 'PROC-HJ'), + (SELECT id FROM md_work_center WHERE center_code = 'WC-HJ-01'), + '焊接', + 30.00, 15.00, 0.48, 0.28, 0.00, 0.0150, 1, 1, 'ACTIVE', '焊缝质量对成品稳定性影响大' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM md_process_route_operation + WHERE route_id = (SELECT id FROM md_process_route WHERE route_code = 'ROUTE-BT-001') + AND seq_no = 50 +); + +INSERT INTO md_process_route_operation ( + route_id, seq_no, process_id, work_center_id, operation_name, + std_setup_minutes, std_cycle_seconds, std_labor_cost, std_machine_cost, std_outsource_cost, + allowed_scrap_rate, report_required, is_key_operation, status, remark +) +SELECT + (SELECT id FROM md_process_route WHERE route_code = 'ROUTE-BT-001'), + 60, + (SELECT id FROM md_process WHERE process_code = 'PROC-QA'), + (SELECT id FROM md_work_center WHERE center_code = 'WC-QA-01'), + '检验包装', + 12.00, 10.00, 0.14, 0.03, 0.00, 0.0020, 1, 1, 'ACTIVE', '终检不合格件转返工或报废' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM md_process_route_operation + WHERE route_id = (SELECT id FROM md_process_route WHERE route_code = 'ROUTE-BT-001') + AND seq_no = 60 +); + +-- 设备台账 +INSERT INTO em_equipment ( + equipment_code, equipment_name, category_name, work_center_id, manufacturer, + model_no, install_date, rated_power_kw, occupied_area_sqm, maintenance_cycle_hours, + last_maintenance_at, next_maintenance_at, status, remark +) +SELECT + 'EQ-KP-01', '开平机 01', '开平机', + (SELECT id FROM md_work_center WHERE center_code = 'WC-KP-01'), + '宁波精虎机械', 'KP-1600', '2024-01-15', 18.00, 12.50, 220.00, + DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 20 DAY), DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 10 DAY), + 'IDLE', '本体板材预处理设备' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM em_equipment WHERE equipment_code = 'EQ-KP-01'); + +INSERT INTO em_equipment ( + equipment_code, equipment_name, category_name, work_center_id, manufacturer, + model_no, install_date, rated_power_kw, occupied_area_sqm, maintenance_cycle_hours, + last_maintenance_at, next_maintenance_at, status, remark +) +SELECT + 'EQ-CY-01', '冲床 01', '冲床', + (SELECT id FROM md_work_center WHERE center_code = 'WC-CY-01'), + '扬力集团', 'JH21-125T', '2024-01-20', 22.00, 16.00, 280.00, + DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 15 DAY), DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 7 DAY), + 'IDLE', '本体主冲压设备' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM em_equipment WHERE equipment_code = 'EQ-CY-01'); + +INSERT INTO em_equipment ( + equipment_code, equipment_name, category_name, work_center_id, manufacturer, + model_no, install_date, rated_power_kw, occupied_area_sqm, maintenance_cycle_hours, + last_maintenance_at, next_maintenance_at, status, remark +) +SELECT + 'EQ-MC-01', '去毛刺机 01', '去毛刺机', + (SELECT id FROM md_work_center WHERE center_code = 'WC-MC-01'), + '苏州锐虎机电', 'MC-800', '2024-02-08', 7.50, 8.00, 180.00, + DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 18 DAY), DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 12 DAY), + 'IDLE', '本体去毛刺设备' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM em_equipment WHERE equipment_code = 'EQ-MC-01'); + +INSERT INTO em_equipment ( + equipment_code, equipment_name, category_name, work_center_id, manufacturer, + model_no, install_date, rated_power_kw, occupied_area_sqm, maintenance_cycle_hours, + last_maintenance_at, next_maintenance_at, status, remark +) +SELECT + 'EQ-ZW-01', '折弯机 01', '折弯机', + (SELECT id FROM md_work_center WHERE center_code = 'WC-ZW-01'), + '江苏亚威', 'PBH-110', '2024-02-16', 15.00, 14.00, 240.00, + DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 22 DAY), DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 8 DAY), + 'IDLE', '本体折弯设备' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM em_equipment WHERE equipment_code = 'EQ-ZW-01'); + +INSERT INTO em_equipment ( + equipment_code, equipment_name, category_name, work_center_id, manufacturer, + model_no, install_date, rated_power_kw, occupied_area_sqm, maintenance_cycle_hours, + last_maintenance_at, next_maintenance_at, status, remark +) +SELECT + 'EQ-HJ-01', '焊机 01', '焊机', + (SELECT id FROM md_work_center WHERE center_code = 'WC-HJ-01'), + '松下焊机', 'YD-350', '2024-03-01', 9.00, 6.00, 160.00, + DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 12 DAY), DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 14 DAY), + 'IDLE', '本体焊接设备' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM em_equipment WHERE equipment_code = 'EQ-HJ-01'); + +INSERT INTO em_equipment ( + equipment_code, equipment_name, category_name, work_center_id, manufacturer, + model_no, install_date, rated_power_kw, occupied_area_sqm, maintenance_cycle_hours, + last_maintenance_at, next_maintenance_at, status, remark +) +SELECT + 'EQ-QA-01', '终检包装工位 01', '检验工位', + (SELECT id FROM md_work_center WHERE center_code = 'WC-QA-01'), + '嘉恒内部配置', 'QA-STATION', '2024-03-10', 1.50, 4.00, 300.00, + DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 25 DAY), DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 20 DAY), + 'IDLE', '终检与包装工位' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM em_equipment WHERE equipment_code = 'EQ-QA-01'); + +-- 设备保养计划 +INSERT INTO em_maintenance_plan ( + plan_code, equipment_id, plan_name, maintenance_type, cycle_hours, cycle_days, + last_execute_at, next_execute_at, status, remark +) +SELECT + 'MP-EQ-KP-01', + (SELECT id FROM em_equipment WHERE equipment_code = 'EQ-KP-01'), + '开平机周期保养', 'PREVENTIVE', 220.00, 30, + DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 20 DAY), DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 10 DAY), + 'ACTIVE', '每220小时或每30天执行保养' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM em_maintenance_plan WHERE plan_code = 'MP-EQ-KP-01'); + +INSERT INTO em_maintenance_plan ( + plan_code, equipment_id, plan_name, maintenance_type, cycle_hours, cycle_days, + last_execute_at, next_execute_at, status, remark +) +SELECT + 'MP-EQ-CY-01', + (SELECT id FROM em_equipment WHERE equipment_code = 'EQ-CY-01'), + '冲床周期保养', 'PREVENTIVE', 280.00, 30, + DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 15 DAY), DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 7 DAY), + 'ACTIVE', '冲床模具与润滑需重点保养' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM em_maintenance_plan WHERE plan_code = 'MP-EQ-CY-01'); + +INSERT INTO em_maintenance_plan ( + plan_code, equipment_id, plan_name, maintenance_type, cycle_hours, cycle_days, + last_execute_at, next_execute_at, status, remark +) +SELECT + 'MP-EQ-MC-01', + (SELECT id FROM em_equipment WHERE equipment_code = 'EQ-MC-01'), + '去毛刺机周期保养', 'PREVENTIVE', 180.00, 30, + DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 18 DAY), DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 12 DAY), + 'ACTIVE', '砂轮与集尘装置维护' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM em_maintenance_plan WHERE plan_code = 'MP-EQ-MC-01'); + +INSERT INTO em_maintenance_plan ( + plan_code, equipment_id, plan_name, maintenance_type, cycle_hours, cycle_days, + last_execute_at, next_execute_at, status, remark +) +SELECT + 'MP-EQ-ZW-01', + (SELECT id FROM em_equipment WHERE equipment_code = 'EQ-ZW-01'), + '折弯机周期保养', 'PREVENTIVE', 240.00, 30, + DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 22 DAY), DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 8 DAY), + 'ACTIVE', '液压系统与模具保养' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM em_maintenance_plan WHERE plan_code = 'MP-EQ-ZW-01'); + +INSERT INTO em_maintenance_plan ( + plan_code, equipment_id, plan_name, maintenance_type, cycle_hours, cycle_days, + last_execute_at, next_execute_at, status, remark +) +SELECT + 'MP-EQ-HJ-01', + (SELECT id FROM em_equipment WHERE equipment_code = 'EQ-HJ-01'), + '焊机周期保养', 'PREVENTIVE', 160.00, 30, + DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 12 DAY), DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 14 DAY), + 'ACTIVE', '焊枪与送丝机构检查' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM em_maintenance_plan WHERE plan_code = 'MP-EQ-HJ-01'); + +INSERT INTO em_maintenance_plan ( + plan_code, equipment_id, plan_name, maintenance_type, cycle_hours, cycle_days, + last_execute_at, next_execute_at, status, remark +) +SELECT + 'MP-EQ-QA-01', + (SELECT id FROM em_equipment WHERE equipment_code = 'EQ-QA-01'), + '终检工位周期保养', 'PREVENTIVE', 300.00, 45, + DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 25 DAY), DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 20 DAY), + 'ACTIVE', '量具点检与包装工位维护' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM em_maintenance_plan WHERE plan_code = 'MP-EQ-QA-01'); + +COMMIT; diff --git a/backend/sql/unify_inventory_lot_trace_patch.sql b/backend/sql/unify_inventory_lot_trace_patch.sql new file mode 100644 index 0000000..dfa68d0 --- /dev/null +++ b/backend/sql/unify_inventory_lot_trace_patch.sql @@ -0,0 +1,116 @@ +CREATE TABLE IF NOT EXISTS po_purchase_order_sales_order ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + purchase_order_id BIGINT NOT NULL, + sales_order_id BIGINT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_po_so_link (purchase_order_id, sales_order_id), + KEY idx_po_so_link_so (sales_order_id), + CONSTRAINT fk_po_so_link_po FOREIGN KEY (purchase_order_id) REFERENCES po_purchase_order(id) ON DELETE CASCADE, + CONSTRAINT fk_po_so_link_so FOREIGN KEY (sales_order_id) REFERENCES so_sales_order(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +SET @idx_wh_stock_lot_item_lot_no_exists := ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'wh_stock_lot' + AND INDEX_NAME = 'idx_wh_stock_lot_item_lot_no' +); +SET @idx_wh_stock_lot_item_lot_no_sql := IF( + @idx_wh_stock_lot_item_lot_no_exists = 0, + 'ALTER TABLE wh_stock_lot ADD INDEX idx_wh_stock_lot_item_lot_no (item_id, lot_no)', + 'SELECT ''idx_wh_stock_lot_item_lot_no already exists''' +); +PREPARE idx_wh_stock_lot_item_lot_no_stmt FROM @idx_wh_stock_lot_item_lot_no_sql; +EXECUTE idx_wh_stock_lot_item_lot_no_stmt; +DEALLOCATE PREPARE idx_wh_stock_lot_item_lot_no_stmt; + +SET @duplicate_stock_lot_no_groups := ( + SELECT COUNT(*) + FROM ( + SELECT lot_no + FROM wh_stock_lot + WHERE lot_no IS NOT NULL + GROUP BY lot_no + HAVING COUNT(*) > 1 + ) duplicated_lots +); +SET @uq_wh_stock_lot_lot_no_sql := ( + SELECT IF( + @duplicate_stock_lot_no_groups > 0, + CONCAT('SIGNAL SQLSTATE ''45000'' SET MESSAGE_TEXT = ''wh_stock_lot.lot_no存在重复,重复批次号组数=', @duplicate_stock_lot_no_groups, ',请先清理后再执行迁移'''), + IF( + NOT EXISTS ( + SELECT 1 + FROM ( + SELECT INDEX_NAME + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'wh_stock_lot' + GROUP BY INDEX_NAME + HAVING MAX(NON_UNIQUE) = 0 + AND COUNT(*) = 1 + AND SUM(CASE WHEN COLUMN_NAME = 'lot_no' THEN 1 ELSE 0 END) = 1 + ) existing_unique_lot_no_indexes + ), + 'ALTER TABLE wh_stock_lot ADD UNIQUE KEY uq_wh_stock_lot_lot_no (lot_no)', + 'SELECT ''uq_wh_stock_lot_lot_no already exists''' + ) + ) +); +PREPARE uq_wh_stock_lot_lot_no_stmt FROM @uq_wh_stock_lot_lot_no_sql; +EXECUTE uq_wh_stock_lot_lot_no_stmt; +DEALLOCATE PREPARE uq_wh_stock_lot_lot_no_stmt; + +SET @idx_pp_issue_source_lot_exists := ( + SELECT COUNT(DISTINCT INDEX_NAME) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'pp_work_order_material_issue' + AND COLUMN_NAME = 'source_lot_id' +); +SET @idx_pp_issue_source_lot_sql := IF( + @idx_pp_issue_source_lot_exists = 0, + 'ALTER TABLE pp_work_order_material_issue ADD INDEX idx_pp_issue_source_lot (source_lot_id)', + 'SELECT ''source_lot_id index already exists on pp_work_order_material_issue''' +); +PREPARE idx_pp_issue_source_lot_stmt FROM @idx_pp_issue_source_lot_sql; +EXECUTE idx_pp_issue_source_lot_stmt; +DEALLOCATE PREPARE idx_pp_issue_source_lot_stmt; + +SET @uk_material_sub_batch_no_exists := ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'pp_work_order_material_issue' + AND INDEX_NAME = 'uk_material_sub_batch_no' +); +SET @drop_uk_material_sub_batch_no_sql := IF( + @uk_material_sub_batch_no_exists > 0, + 'ALTER TABLE pp_work_order_material_issue DROP INDEX uk_material_sub_batch_no', + 'SELECT ''uk_material_sub_batch_no already absent on pp_work_order_material_issue''' +); +PREPARE drop_uk_material_sub_batch_no_stmt FROM @drop_uk_material_sub_batch_no_sql; +EXECUTE drop_uk_material_sub_batch_no_stmt; +DEALLOCATE PREPARE drop_uk_material_sub_batch_no_stmt; + +INSERT INTO po_purchase_order_sales_order (purchase_order_id, sales_order_id, created_at, updated_at) +SELECT DISTINCT po.id, so.id, NOW(), NOW() +FROM po_purchase_order po +JOIN po_purchase_order_item poi ON poi.purchase_order_id = po.id +JOIN mrp_material_demand md ON md.id = poi.source_demand_id +JOIN so_sales_order_item soi ON soi.id = md.sales_order_item_id +JOIN so_sales_order so ON so.id = soi.sales_order_id +WHERE NOT EXISTS ( + SELECT 1 + FROM po_purchase_order_sales_order existing + WHERE existing.purchase_order_id = po.id + AND existing.sales_order_id = so.id + ); + +UPDATE pp_work_order_material_issue issue +JOIN wh_stock_lot lot ON lot.id = issue.source_lot_id +SET issue.material_sub_batch_no = lot.lot_no +WHERE issue.material_sub_batch_no IS NULL + OR issue.material_sub_batch_no <> lot.lot_no; diff --git a/backend/sql/unify_raw_inventory_lot_numbers_patch.sql b/backend/sql/unify_raw_inventory_lot_numbers_patch.sql new file mode 100644 index 0000000..5a4b9f5 --- /dev/null +++ b/backend/sql/unify_raw_inventory_lot_numbers_patch.sql @@ -0,0 +1,80 @@ +DROP TEMPORARY TABLE IF EXISTS tmp_raw_lot_rename; +DROP TEMPORARY TABLE IF EXISTS tmp_raw_lot_existing_seq; + +CREATE TEMPORARY TABLE tmp_raw_lot_existing_seq AS +SELECT + l.item_id, + COALESCE( + MAX( + CASE + WHEN REPLACE(l.lot_no, CONCAT('JH_', i.item_code, '_'), '') REGEXP '^[0-9]{4}$' + THEN CAST(REPLACE(l.lot_no, CONCAT('JH_', i.item_code, '_'), '') AS UNSIGNED) + ELSE 0 + END + ), + 0 + ) AS max_seq +FROM wh_stock_lot l +JOIN md_item i ON i.id = l.item_id +JOIN wh_warehouse w ON w.id = l.warehouse_id +WHERE i.item_type = 'RAW_MATERIAL' + AND w.warehouse_type = 'RAW' + AND l.lot_no LIKE CONCAT('JH_', i.item_code, '_%') +GROUP BY l.item_id; + +CREATE TEMPORARY TABLE tmp_raw_lot_rename AS +SELECT + ranked.lot_id, + ranked.old_lot_no, + CONCAT('JH_', ranked.item_code, '_', LPAD(ranked.max_seq + ranked.seq_no, 4, '0')) AS new_lot_no +FROM ( + SELECT + l.id AS lot_id, + l.item_id, + i.item_code, + l.lot_no AS old_lot_no, + COALESCE(seq.max_seq, 0) AS max_seq, + ROW_NUMBER() OVER (PARTITION BY l.item_id ORDER BY l.id) AS seq_no + FROM wh_stock_lot l + JOIN md_item i ON i.id = l.item_id + JOIN wh_warehouse w ON w.id = l.warehouse_id + LEFT JOIN tmp_raw_lot_existing_seq seq ON seq.item_id = l.item_id + WHERE i.item_type = 'RAW_MATERIAL' + AND w.warehouse_type = 'RAW' + AND l.lot_no NOT LIKE CONCAT('JH_', i.item_code, '_%') +) ranked; + +UPDATE po_receipt_item pri +JOIN tmp_raw_lot_rename map ON map.old_lot_no = pri.lot_no +SET pri.lot_no = map.new_lot_no; + +UPDATE pp_work_order_material_issue issue_row +JOIN tmp_raw_lot_rename map ON map.old_lot_no = issue_row.material_sub_batch_no +SET issue_row.material_sub_batch_no = map.new_lot_no; + +UPDATE pp_completion_receipt_item item_row +JOIN tmp_raw_lot_rename map ON map.old_lot_no = item_row.source_material_sub_batch_no +SET item_row.source_material_sub_batch_no = map.new_lot_no; + +UPDATE wh_stock_lot source_row +JOIN tmp_raw_lot_rename map ON map.old_lot_no = source_row.material_sub_batch_no +SET source_row.material_sub_batch_no = map.new_lot_no; + +UPDATE wh_stock_lot source_row +JOIN tmp_raw_lot_rename map ON map.old_lot_no = source_row.source_material_sub_batch_no +SET source_row.source_material_sub_batch_no = map.new_lot_no; + +UPDATE wh_stocktake_line line_row +JOIN tmp_raw_lot_rename map ON map.old_lot_no = line_row.lot_no +SET line_row.lot_no = map.new_lot_no; + +UPDATE wh_stocktake_line line_row +JOIN tmp_raw_lot_rename map ON map.old_lot_no = line_row.source_material_sub_batch_no +SET line_row.source_material_sub_batch_no = map.new_lot_no; + +UPDATE wh_stock_lot lot_row +JOIN tmp_raw_lot_rename map ON map.lot_id = lot_row.id +SET lot_row.lot_no = map.new_lot_no; + +DROP TEMPORARY TABLE IF EXISTS tmp_raw_lot_rename; +DROP TEMPORARY TABLE IF EXISTS tmp_raw_lot_existing_seq; diff --git a/backend/sql/versioned_master_constraints_patch.sql b/backend/sql/versioned_master_constraints_patch.sql new file mode 100644 index 0000000..4fd3553 --- /dev/null +++ b/backend/sql/versioned_master_constraints_patch.sql @@ -0,0 +1,120 @@ +USE jiaheng_erp; + +SET @db_name = DATABASE(); + +-- 1) 产品版本:允许同一产品编码保留多个版本,因此取消 md_item.item_code 的全局唯一约束。 +SET @drop_md_item_code_unique = ( + SELECT IFNULL( + ( + SELECT CONCAT('ALTER TABLE md_item DROP INDEX `', index_name, '`') + FROM information_schema.statistics + WHERE table_schema = @db_name + AND table_name = 'md_item' + AND column_name = 'item_code' + AND non_unique = 0 + AND index_name <> 'PRIMARY' + GROUP BY index_name + HAVING COUNT(*) = 1 + LIMIT 1 + ), + 'SELECT 1' + ) +); +PREPARE stmt FROM @drop_md_item_code_unique; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @create_md_item_code_index = ( + SELECT IF( + EXISTS( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = @db_name + AND table_name = 'md_item' + AND index_name = 'idx_md_item_code' + ), + 'SELECT 1', + 'CREATE INDEX idx_md_item_code ON md_item (item_code)' + ) +); +PREPARE stmt FROM @create_md_item_code_index; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 2) BOM 版本:同一 BOM 编码允许多个版本,唯一性改为 (bom_code, version_no)。 +SET @drop_md_bom_code_unique = ( + SELECT IFNULL( + ( + SELECT CONCAT('ALTER TABLE md_bom DROP INDEX `', index_name, '`') + FROM information_schema.statistics + WHERE table_schema = @db_name + AND table_name = 'md_bom' + AND column_name = 'bom_code' + AND non_unique = 0 + AND index_name <> 'PRIMARY' + GROUP BY index_name + HAVING COUNT(*) = 1 + LIMIT 1 + ), + 'SELECT 1' + ) +); +PREPARE stmt FROM @drop_md_bom_code_unique; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @create_md_bom_code_version_unique = ( + SELECT IF( + EXISTS( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = @db_name + AND table_name = 'md_bom' + AND index_name = 'uk_bom_code_version' + ), + 'SELECT 1', + 'ALTER TABLE md_bom ADD UNIQUE KEY uk_bom_code_version (bom_code, version_no)' + ) +); +PREPARE stmt FROM @create_md_bom_code_version_unique; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 3) 工艺路线版本:同一工艺路线编码允许多个版本,唯一性改为 (route_code, version_no)。 +SET @drop_md_route_code_unique = ( + SELECT IFNULL( + ( + SELECT CONCAT('ALTER TABLE md_process_route DROP INDEX `', index_name, '`') + FROM information_schema.statistics + WHERE table_schema = @db_name + AND table_name = 'md_process_route' + AND column_name = 'route_code' + AND non_unique = 0 + AND index_name <> 'PRIMARY' + GROUP BY index_name + HAVING COUNT(*) = 1 + LIMIT 1 + ), + 'SELECT 1' + ) +); +PREPARE stmt FROM @drop_md_route_code_unique; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @create_md_route_code_version_unique = ( + SELECT IF( + EXISTS( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = @db_name + AND table_name = 'md_process_route' + AND index_name = 'uk_route_code_version' + ), + 'SELECT 1', + 'ALTER TABLE md_process_route ADD UNIQUE KEY uk_route_code_version (route_code, version_no)' + ) +); +PREPARE stmt FROM @create_md_route_code_version_unique; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/tests/test_auxiliary_quantity_flow.py b/backend/tests/test_auxiliary_quantity_flow.py new file mode 100644 index 0000000..4ae0b88 --- /dev/null +++ b/backend/tests/test_auxiliary_quantity_flow.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +import unittest +from datetime import date, datetime +from decimal import Decimal + +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.api.routes.inventory import create_warehouse_outbound # noqa: E402 +from app.api.routes.purchase import create_purchase_order, create_purchase_receipt, lock_purchase_order # noqa: E402 +from app.api.routes.quality import inspect_receipt_item # noqa: E402 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Item, Material, StockBalance, Unit, Warehouse # noqa: E402 +from app.models.operations import InventoryTxn, PurchaseOrderItem, PurchaseReceiptItem, StockLot, Supplier # noqa: E402 +from app.models.org import Department, Employee, Permission, User # noqa: E402 +from app.schemas.operations import ( # noqa: E402 + PurchaseOrderCreate, + PurchaseOrderItemCreate, + PurchaseReceiptCreate, + PurchaseReceiptItemCreate, + QualityInspectionAction, + WarehouseOutboundCreate, +) +from app.services.auth import AuthContext # noqa: E402 +from app.services.operations import ( # noqa: E402 + get_inventory_txn_ledger_query, + get_inventory_txn_query, + get_stock_balances_query, + get_stock_lots_query, +) + + +class AuxiliaryQuantityFlowTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self._seed_master_data() + + def tearDown(self) -> None: + self.db.close() + + def _seed_master_data(self) -> None: + self.department = Department(id=1, dept_code="D-AUX", dept_name="仓库部", dept_type="WAREHOUSE", status="ACTIVE") + self.employee = Employee(id=1, employee_code="EMP-AUX-001", employee_name="辅料仓管", dept_id=1, status="ACTIVE") + self.user = User( + id=1, + username="aux_tester", + password_hash="x", + employee_id=1, + dept_id=1, + nickname="辅料测试员", + is_super_admin=1, + status="ACTIVE", + ) + self.purchase_receipt_permission = Permission( + id=1, + permission_code="MENU_PURCHASE_RECEIPT", + permission_name="到货入库", + module_code="PURCHASE", + action_code="MENU", + status="ACTIVE", + created_at=datetime.now(), + updated_at=datetime.now(), + ) + self.unit = Unit(id=1, unit_code="PCS_BOX", unit_name="个", precision_digits=0) + self.aux_item = Item( + id=1, + item_code="辅料00001", + item_name="包装薄膜", + item_type="RAW_MATERIAL", + specification="500*300", + unit_weight_kg=Decimal("0"), + sales_unit_id=1, + stock_unit_id=1, + purchase_unit_id=1, + status="ACTIVE", + ) + self.material = Material( + item_id=1, + default_supplier_id=1, + purchase_calc_mode="BY_QTY", + min_purchase_qty=Decimal("0"), + purchase_multiple_qty=Decimal("0"), + purchase_multiple_weight_kg=Decimal("0"), + lead_time_days=0, + ) + self.supplier = Supplier( + id=1, + supplier_code="SUP-AUX-001", + supplier_name="辅料供应商", + short_name="辅料供应商", + lead_time_days=0, + default_tax_rate=Decimal("0"), + status="ACTIVE", + ) + self.warehouse = Warehouse(id=1, warehouse_code="WH-AUX", warehouse_name="辅料库", warehouse_type="AUX", status="ACTIVE") + self.db.add_all([ + self.department, + self.employee, + self.user, + self.purchase_receipt_permission, + self.unit, + self.aux_item, + self.material, + self.supplier, + self.warehouse, + ]) + self.db.commit() + + def _auth_context(self) -> AuthContext: + return AuthContext( + user=self.user, + employee=self.employee, + department=self.department, + role_codes=["ADMIN"], + role_names=["管理员"], + permission_codes=[], + ) + + def test_auxiliary_purchase_receipt_quality_and_production_out_use_quantity(self) -> None: + order = create_purchase_order( + PurchaseOrderCreate( + supplier_id=self.supplier.id, + expected_date=date.today(), + target_warehouse_type="AUX", + items=[ + PurchaseOrderItemCreate( + material_item_id=self.aux_item.id, + order_qty=Decimal("10"), + unit_price=Decimal("2.5"), + ) + ], + ), + db=self.db, + ) + + po_item = self.db.scalar(select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == order.purchase_order_id)) + self.assertEqual(float(po_item.order_qty), 10) + self.assertEqual(float(po_item.order_weight_kg), 0) + self.assertEqual(float(po_item.line_amount), 25) + self.assertEqual(float(order.total_amount), 25) + self.assertEqual(float(order.total_order_qty), 10) + self.assertEqual(float(order.total_order_weight_kg), 0) + + lock_purchase_order(order.purchase_order_id, db=self.db) + create_purchase_receipt( + PurchaseReceiptCreate( + purchase_order_id=order.purchase_order_id, + warehouse_id=self.warehouse.id, + receiver_employee_id=self.employee.id, + items=[ + PurchaseReceiptItemCreate( + purchase_order_item_id=po_item.id, + material_item_id=self.aux_item.id, + received_qty=Decimal("6"), + unit_cost=Decimal("2.5"), + ) + ], + ), + context=self._auth_context(), + db=self.db, + ) + + receipt_item = self.db.scalar(select(PurchaseReceiptItem)) + lot = self.db.scalar(select(StockLot)) + balance = self.db.scalar(select(StockBalance)) + inbound_txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "PURCHASE_IN")) + + self.assertEqual(float(receipt_item.received_qty), 6) + self.assertEqual(float(receipt_item.received_weight_kg), 0) + self.assertEqual(float(lot.inbound_qty), 6) + self.assertEqual(float(lot.remaining_qty), 6) + self.assertEqual(float(lot.locked_qty), 6) + self.assertEqual(float(lot.inbound_weight_kg), 0) + self.assertEqual(float(balance.qty_on_hand), 6) + self.assertEqual(float(balance.qty_available), 0) + self.assertEqual(float(balance.weight_on_hand_kg), 0) + self.assertEqual(float(inbound_txn.qty_change), 6) + self.assertEqual(float(inbound_txn.weight_change_kg), 0) + self.assertEqual(float(inbound_txn.amount), 15) + + inspect_receipt_item( + receipt_item.id, + QualityInspectionAction(result="PASS", remark="合格"), + context=self._auth_context(), + db=self.db, + ) + + self.db.refresh(po_item) + self.db.refresh(receipt_item) + self.db.refresh(lot) + self.db.refresh(balance) + self.assertEqual(float(receipt_item.accepted_qty), 6) + self.assertEqual(float(receipt_item.accepted_weight_kg), 0) + self.assertEqual(float(po_item.received_qty), 6) + self.assertEqual(float(po_item.received_weight_kg), 0) + self.assertEqual(po_item.status, "PARTIAL") + self.assertEqual(float(lot.locked_qty), 0) + self.assertEqual(float(balance.qty_available), 6) + + balance_rows = self.db.execute(get_stock_balances_query()).mappings().all() + aux_balance_row = next(row for row in balance_rows if row["warehouse_type"] == "AUX") + self.assertEqual(float(aux_balance_row["qty_on_hand"]), 6) + self.assertEqual(float(aux_balance_row["qty_available"]), 6) + self.assertEqual(aux_balance_row["specification"], "500*300") + self.assertEqual(aux_balance_row["stock_unit_name"], "个") + + lot_rows = self.db.execute(get_stock_lots_query()).mappings().all() + aux_lot_row = next(row for row in lot_rows if row["warehouse_type"] == "AUX") + self.assertEqual(float(aux_lot_row["inbound_qty"]), 6) + self.assertEqual(float(aux_lot_row["remaining_qty"]), 6) + self.assertEqual(float(aux_lot_row["locked_qty"]), 0) + + txn_rows = self.db.execute(get_inventory_txn_query()).mappings().all() + aux_purchase_txn_row = next(row for row in txn_rows if row["warehouse_type"] == "AUX" and row["txn_type"] == "PURCHASE_IN") + self.assertEqual(float(aux_purchase_txn_row["qty_change"]), 6) + + ledger_rows = self.db.execute(get_inventory_txn_ledger_query(warehouse_type="AUX")).mappings().all() + aux_purchase_ledger_row = next(row for row in ledger_rows if row["txn_type"] == "PURCHASE_IN") + self.assertEqual(float(aux_purchase_ledger_row["qty_change"]), 6) + + create_warehouse_outbound( + WarehouseOutboundCreate( + biz_type="PRODUCTION_OUT", + item_id=self.aux_item.id, + warehouse_id=self.warehouse.id, + outbound_qty=Decimal("2"), + remark="包装一线使用", + ), + context=self._auth_context(), + db=self.db, + ) + + self.db.refresh(lot) + self.db.refresh(balance) + outbound_txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "PRODUCTION_OUT")) + self.assertEqual(float(lot.remaining_qty), 4) + self.assertEqual(float(lot.remaining_weight_kg), 0) + self.assertEqual(float(balance.qty_on_hand), 4) + self.assertEqual(float(balance.qty_available), 4) + self.assertEqual(float(outbound_txn.qty_change), -2) + self.assertEqual(float(outbound_txn.weight_change_kg), 0) + self.assertEqual(float(outbound_txn.amount), 5) + self.assertIn("用途:包装一线使用", outbound_txn.remark) + + lot_rows_after_outbound = self.db.execute(get_stock_lots_query()).mappings().all() + aux_lot_after_outbound = next(row for row in lot_rows_after_outbound if row["warehouse_type"] == "AUX") + self.assertEqual(float(aux_lot_after_outbound["remaining_qty"]), 4) + + txn_rows_after_outbound = self.db.execute(get_inventory_txn_query()).mappings().all() + aux_outbound_txn_row = next(row for row in txn_rows_after_outbound if row["warehouse_type"] == "AUX" and row["txn_type"] == "PRODUCTION_OUT") + self.assertEqual(float(aux_outbound_txn_row["qty_change"]), -2) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_customer_supplied_inbound_lot_no.py b/backend/tests/test_customer_supplied_inbound_lot_no.py new file mode 100644 index 0000000..2a4b757 --- /dev/null +++ b/backend/tests/test_customer_supplied_inbound_lot_no.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import unittest +from decimal import Decimal + +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +from app.api.routes.inventory import create_customer_supplied_inbound, create_warehouse_inbound # noqa: E402 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Item, StockBalance, Warehouse # noqa: E402 +from app.models.operations import StockLot # noqa: E402 +from app.schemas.operations import CustomerSuppliedInboundCreate, WarehouseInboundCreate # noqa: E402 + + +class User: + id = 1 + + +class Context: + user = User() + + +class CustomerSuppliedInboundLotNoTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self.material = Item( + id=1, + item_code="110501000185", + item_name="左右后纵梁后段毛坯", + item_type="RAW_MATERIAL", + unit_weight_kg=Decimal("1"), + status="ACTIVE", + ) + self.warehouse = Warehouse( + id=1, + warehouse_code="WH-RAW", + warehouse_name="原材料库", + warehouse_type="RAW", + status="ACTIVE", + ) + self.db.add_all([self.material, self.warehouse]) + self.db.commit() + + def tearDown(self) -> None: + self.db.close() + + def _seed_existing_lot(self, lot_no: str) -> None: + self.db.add( + StockLot( + lot_no=lot_no, + lot_role="CUSTOMER_SUPPLIED_RAW", + item_id=self.material.id, + warehouse_id=self.warehouse.id, + source_doc_type="CUSTOMER_SUPPLIED_IN", + source_doc_id=1, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("100"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("100"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("0"), + quality_status="PASS", + status="AVAILABLE", + ) + ) + self.db.commit() + + def test_generic_customer_supplied_inbound_ignores_manual_lot_no_and_uses_global_raw_sequence(self) -> None: + self._seed_existing_lot("JH_左右后纵梁后段毛坯00185_0001") + + first_row = create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="CUSTOMER_SUPPLIED", + item_id=self.material.id, + warehouse_id=self.warehouse.id, + inbound_weight_kg=50, + unit_cost=0, + lot_no="用户手填批次号", + source_material_sub_batch_no="用户手填来源批次", + provider_name="客户A", + waybill_no="YD-KL-001", + freight_amount=0, + ), + context=Context(), + db=self.db, + ) + second_row = create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="CUSTOMER_SUPPLIED", + item_id=self.material.id, + warehouse_id=self.warehouse.id, + inbound_weight_kg=25, + unit_cost=0, + lot_no="第二个用户手填批次号", + source_material_sub_batch_no="第二个用户手填来源批次", + provider_name="客户A", + waybill_no="YD-KL-002", + freight_amount=0, + ), + context=Context(), + db=self.db, + ) + + lot = self.db.scalar(select(StockLot).where(StockLot.id == first_row.lot_id)) + second_lot = self.db.scalar(select(StockLot).where(StockLot.id == second_row.lot_id)) + balance = self.db.scalar( + select(StockBalance).where( + StockBalance.item_id == self.material.id, + StockBalance.warehouse_id == self.warehouse.id, + ) + ) + self.assertEqual(lot.lot_no, "YL0001") + self.assertEqual(second_lot.lot_no, "YL0002") + self.assertIsNone(lot.source_material_sub_batch_no) + self.assertEqual(lot.source_material_summary, "客户A") + self.assertIsNotNone(balance) + self.assertEqual(balance.weight_on_hand_kg, Decimal("75.000000")) + + def test_dedicated_customer_supplied_inbound_ignores_manual_lot_no_and_starts_from_one(self) -> None: + row = create_customer_supplied_inbound( + CustomerSuppliedInboundCreate( + material_item_id=self.material.id, + warehouse_id=self.warehouse.id, + provider_name="客户B", + inbound_weight_kg=80, + lot_no="旧接口手填批次号", + ), + context=Context(), + db=self.db, + ) + + lot = self.db.scalar(select(StockLot).where(StockLot.id == row.lot_id)) + self.assertEqual(lot.lot_no, "YL0001") + self.assertEqual(lot.source_material_summary, "客户B") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_document_archive_model.py b/backend/tests/test_document_archive_model.py new file mode 100644 index 0000000..ff84567 --- /dev/null +++ b/backend/tests/test_document_archive_model.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import unittest + +from pydantic import ValidationError +from sqlalchemy import create_engine, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session, sessionmaker + + +import app.models.document_archive # noqa: E402,F401 +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.document_archive import DocumentArchive # noqa: E402 +from app.schemas.document_archives import DocumentArchiveBatchDownloadRequest # noqa: E402 + + +class DocumentArchiveModelTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + + def tearDown(self) -> None: + self.db.close() + + def _archive(self, *, business_id: int = 1001, archive_version: int = 1) -> DocumentArchive: + return DocumentArchive( + document_type="销售订单", + business_id=business_id, + document_no="SO-20260611-0001", + archive_version=archive_version, + template_version="单据纸面V1", + file_format="PDF", + file_name="SO-20260611-0001.pdf", + file_path="/archives/sales/SO-20260611-0001.pdf", + file_hash="abc123", + status="已归档", + error_message=None, + created_by=1, + ) + + def test_document_archive_round_trips_chinese_business_values(self) -> None: + archive = self._archive() + self.db.add(archive) + self.db.commit() + + saved = self.db.execute(select(DocumentArchive).where(DocumentArchive.id == archive.id)).scalar_one() + + self.assertIsNotNone(saved.id) + self.assertEqual(saved.status, "已归档") + self.assertEqual(saved.template_version, "单据纸面V1") + self.assertEqual(saved.archive_version, 1) + + def test_document_archive_rejects_duplicate_archive_version_format(self) -> None: + self.db.add(self._archive()) + self.db.commit() + + self.db.add(self._archive()) + + with self.assertRaises(IntegrityError): + self.db.commit() + + def test_batch_download_request_requires_business_ids_when_omitted(self) -> None: + with self.assertRaises(ValidationError): + DocumentArchiveBatchDownloadRequest(document_type="销售订单") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_document_archive_routes.py b/backend/tests/test_document_archive_routes.py new file mode 100644 index 0000000..95bfa6e --- /dev/null +++ b/backend/tests/test_document_archive_routes.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import unittest +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import BigInteger, create_engine +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +import app.models.document_archive # noqa: E402,F401 +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.api.routes.document_archives import normalize_document_type_key, router # noqa: E402 +from app.db.session import get_db # noqa: E402 +from app.models.base import Base # noqa: E402 +from app.services.auth import require_authenticated_user # noqa: E402 + + +@compiles(BigInteger, "sqlite") +def _compile_big_integer_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +class DocumentArchiveRouteTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + future=True, + ) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + + app = FastAPI() + app.include_router(router, prefix="/document-archives") + + def override_get_db() -> Session: + db = self.SessionLocal() + try: + yield db + finally: + db.close() + + def override_require_authenticated_user() -> dict[str, int]: + return {"user_id": 123} + + app.dependency_overrides[get_db] = override_get_db + app.dependency_overrides[require_authenticated_user] = override_require_authenticated_user + self.client = TestClient(app) + + def test_normalize_document_type_key_supports_route_keys(self) -> None: + self.assertEqual(normalize_document_type_key("sales-order"), "销售订单") + self.assertEqual(normalize_document_type_key("purchase-order"), "采购订单") + self.assertEqual(normalize_document_type_key("quality-inspection"), "质量校验单") + self.assertEqual(normalize_document_type_key("production-material-out"), "生产领料出库单") + self.assertEqual(normalize_document_type_key("production-inbound-settlement"), "生产入库结算单") + self.assertEqual(normalize_document_type_key("warehouse-operation"), "仓库出入库单") + self.assertEqual(normalize_document_type_key("仓库出入库单"), "仓库出入库单") + + def test_preview_missing_archive_returns_chinese_404(self) -> None: + response = self.client.get("/document-archives/sales-order/999/latest/preview") + + self.assertEqual(response.status_code, 404) + self.assertIn("归档文件不存在", response.json()["detail"]) + + def test_batch_download_missing_archives_returns_chinese_404(self) -> None: + with TemporaryDirectory() as tmp_dir, patch("app.api.routes.document_archives.default_archive_root", return_value=tmp_dir): + response = self.client.post( + "/document-archives/batch-download", + json={"document_type": "sales-order", "business_ids": [999]}, + ) + + self.assertEqual(response.status_code, 404) + self.assertIn("没有可下载的归档文件", response.json()["detail"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_document_archive_service.py b/backend/tests/test_document_archive_service.py new file mode 100644 index 0000000..cf14318 --- /dev/null +++ b/backend/tests/test_document_archive_service.py @@ -0,0 +1,777 @@ +from __future__ import annotations + +import unittest +from datetime import date, datetime +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from reportlab.lib.pagesizes import A4 +from reportlab.pdfbase import pdfmetrics +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + +import app.models.document_archive # noqa: E402,F401 +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.document_archive import DocumentArchive # noqa: E402 +from app.models.master_data import Item, Warehouse # noqa: E402 +from app.models.operations import PurchaseOrder, PurchaseOrderItem, PurchaseReceipt, PurchaseReceiptItem, StockLot, Supplier # noqa: E402 +from app.models.org import Department, Employee, User # noqa: E402 +from app.models.sales import Customer, SalesOrder, SalesOrderItem # noqa: E402 +from app.services.document_archives import ( # noqa: E402 + ARCHIVE_STATUS_FAILED, + ARCHIVE_STATUS_READY, + ArchiveContext, + ArchiveLine, + DOCUMENT_TYPE_PURCHASE_RECEIPT, + DOCUMENT_TYPE_QUALITY_INSPECTION, + DOCUMENT_TYPE_SALES_ORDER, + DOCUMENT_TYPE_PURCHASE_ORDER, + FILE_FORMAT_PDF, + TEMPLATE_VERSION, + collect_purchase_order_archive_context, + collect_purchase_receipt_archive_context, + collect_quality_inspection_archive_context, + generate_document_archive, + get_latest_archive, + render_pdf_archive, + _archive_table_columns, + _draw_cell, + _draw_archive_footer, +) + + +@compiles(BigInteger, "sqlite") +def _compile_big_integer_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +class DocumentArchiveServiceTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + + def tearDown(self) -> None: + self.db.close() + + def _seed_sales_order(self) -> SalesOrder: + now = datetime.now() + customer = Customer( + id=1, + customer_code="CUST-001", + customer_name="上海测试客户有限公司", + short_name="测试客户", + contact_name="张三", + contact_phone="13800000000", + address="上海市浦东新区测试路 100 号", + tax_no="91310000TEST", + credit_days=30, + status="启用", + created_at=now, + updated_at=now, + ) + item = Item( + id=10, + item_code="FG-001", + item_name="精密锻件", + item_type="成品", + specification="A-100", + material_grade="42CrMo", + unit_weight_kg=1.25, + status="启用", + created_at=now, + updated_at=now, + ) + order = SalesOrder( + id=100, + order_no="SO-20260611-0001", + customer_id=customer.id, + order_date=date(2026, 6, 11), + promised_date=date(2026, 6, 20), + sales_employee_id=None, + delivery_address="上海市浦东新区测试路 100 号", + tax_rate=0.13, + total_amount=1234.56, + status="已审核", + created_at=now, + updated_at=now, + ) + order_item = SalesOrderItem( + id=1000, + sales_order_id=order.id, + line_no=1, + product_item_id=item.id, + customer_part_no="CPN-001", + order_qty=12, + delivered_qty=2, + unit_price=102.88, + line_amount=1234.56, + promised_date=date(2026, 6, 20), + status="未交付", + created_at=now, + updated_at=now, + ) + self.db.add_all([customer, item, order, order_item]) + self.db.commit() + return order + + def _seed_archive_user(self, *, nickname: str | None = "张制单") -> User: + now = datetime.now() + department = Department( + id=900, + dept_code="D-ARCHIVE", + dept_name="归档测试部", + parent_id=None, + org_node_type="DEPARTMENT", + dept_type="部门", + manager_name=None, + manager_employee_id=None, + status="ACTIVE", + sort_no=0, + remark=None, + created_at=now, + updated_at=now, + ) + employee = Employee( + id=901, + employee_code="EMP-ARCHIVE", + employee_name="归档操作员", + dept_id=department.id, + mobile="13811112222", + gender=None, + hire_date=None, + job_title=None, + shift_code=None, + manager_employee_id=None, + is_operator=0, + is_workshop_staff=0, + status="ACTIVE", + remark=None, + created_at=now, + updated_at=now, + ) + user = User( + id=902, + username="archive_admin", + password_hash="hashed", + employee_id=employee.id, + dept_id=department.id, + nickname=nickname, + email=None, + is_super_admin=0, + last_login_at=None, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add_all([department, employee, user]) + self.db.commit() + return user + + def _seed_purchase_order(self, *, target_warehouse_type: str) -> PurchaseOrder: + now = datetime.now() + supplier = Supplier( + id=200, + supplier_code="SUP-001", + supplier_name="宁波测试供应商有限公司", + short_name="测试供应商", + contact_name="李四", + contact_phone="13900000000", + address="宁波市测试路 1 号", + lead_time_days=0, + default_tax_rate=0.13, + status="启用", + created_at=now, + updated_at=now, + ) + item = Item( + id=210, + item_code="RM-001" if target_warehouse_type == "RAW" else "AUX-001", + item_name="圆钢" if target_warehouse_type == "RAW" else "包装辅料", + item_type="原材料" if target_warehouse_type == "RAW" else "辅料", + specification="D40", + material_grade="42CrMo", + unit_weight_kg=1, + status="启用", + created_at=now, + updated_at=now, + ) + order = PurchaseOrder( + id=300, + po_no=f"PO-{target_warehouse_type}-20260611-0001", + supplier_id=supplier.id, + order_date=date(2026, 6, 11), + expected_date=date(2026, 6, 20), + warning_lead_days=0, + purchaser_employee_id=None, + target_warehouse_type=target_warehouse_type, + tax_rate=0.13, + total_amount=8641.98, + status="已审核", + remark="采购归档测试", + created_at=now, + updated_at=now, + ) + order_item = PurchaseOrderItem( + id=3000, + purchase_order_id=order.id, + line_no=1, + material_item_id=item.id, + source_demand_id=None, + order_qty=0 if target_warehouse_type == "RAW" else 12, + order_weight_kg=1234.56 if target_warehouse_type == "RAW" else 0, + received_qty=0 if target_warehouse_type == "RAW" else 3, + received_weight_kg=234.5 if target_warehouse_type == "RAW" else 0, + unit_price=7, + line_amount=8641.98, + status="未收货", + remark="测试明细", + created_at=now, + updated_at=now, + ) + self.db.add_all([supplier, item, order, order_item]) + self.db.commit() + return order + + def _seed_purchase_receipt(self, *, target_warehouse_type: str = "RAW") -> PurchaseReceipt: + now = datetime.now() + supplier = Supplier( + id=400, + supplier_code="SUP-RCPT", + supplier_name="宁波到货供应商有限公司", + short_name="到货供应商", + contact_name="王五", + contact_phone="13700000000", + address="宁波市到货路 2 号", + lead_time_days=0, + default_tax_rate=0.13, + status="启用", + created_at=now, + updated_at=now, + ) + item = Item( + id=410, + item_code="RM-RCPT-001" if target_warehouse_type == "RAW" else "AUX-RCPT-001", + item_name="到货圆钢" if target_warehouse_type == "RAW" else "到货辅料", + item_type="原材料" if target_warehouse_type == "RAW" else "辅料", + specification="D50", + material_grade="42CrMo", + unit_weight_kg=1, + status="启用", + created_at=now, + updated_at=now, + ) + warehouse = Warehouse( + id=420, + warehouse_code="WH-RCPT", + warehouse_name="原材料库" if target_warehouse_type == "RAW" else "辅料库", + warehouse_type=target_warehouse_type, + manager_employee_id=None, + status="启用", + created_at=now, + updated_at=now, + ) + order = PurchaseOrder( + id=430, + po_no="采购2026-00088", + supplier_id=supplier.id, + order_date=date(2026, 6, 11), + expected_date=date(2026, 6, 20), + warning_lead_days=0, + purchaser_employee_id=None, + target_warehouse_type=target_warehouse_type, + tax_rate=0.13, + total_amount=3500, + status="LOCKED", + remark="到货归档采购单", + created_at=now, + updated_at=now, + ) + order_item = PurchaseOrderItem( + id=440, + purchase_order_id=order.id, + line_no=1, + material_item_id=item.id, + source_demand_id=None, + order_qty=0 if target_warehouse_type == "RAW" else 10, + order_weight_kg=500 if target_warehouse_type == "RAW" else 0, + received_qty=0 if target_warehouse_type == "RAW" else 4, + received_weight_kg=125.5 if target_warehouse_type == "RAW" else 0, + unit_price=7, + line_amount=3500, + status="PARTIAL_RECEIVED", + remark="采购明细备注", + created_at=now, + updated_at=now, + ) + receipt = PurchaseReceipt( + id=450, + receipt_no="入库2026-00009", + purchase_order_id=order.id, + warehouse_id=warehouse.id, + receipt_date=datetime(2026, 6, 12, 9, 30, 0), + receiver_employee_id=None, + supplier_delivery_no=None, + logistics_waybill_no="YD-20260612", + logistics_freight_amount=88.8, + logistics_photo_url="/uploads/receipt.jpg", + status="PENDING_QC", + remark="到货入库归档测试", + created_at=now, + updated_at=now, + ) + receipt_item = PurchaseReceiptItem( + id=460, + receipt_id=receipt.id, + line_no=1, + purchase_order_item_id=order_item.id, + material_item_id=item.id, + location_id=None, + lot_no="JH到货圆钢00001_0001", + merge_to_lot_id=None, + received_qty=0 if target_warehouse_type == "RAW" else 4, + received_weight_kg=125.5 if target_warehouse_type == "RAW" else 0, + accepted_qty=0, + accepted_weight_kg=0, + unit_cost=7.2, + status="PENDING_QC", + remark="入库明细备注", + created_at=now, + updated_at=now, + ) + self.db.add_all([supplier, item, warehouse, order, order_item, receipt, receipt_item]) + self.db.commit() + return receipt + + def test_generate_sales_order_pdf_archive_creates_latest_ready_archive(self) -> None: + order = self._seed_sales_order() + + with TemporaryDirectory() as tempdir: + result = generate_document_archive( + self.db, + DOCUMENT_TYPE_SALES_ORDER, + order.id, + archive_root=tempdir, + ) + + self.assertEqual(result.archive_status, ARCHIVE_STATUS_READY) + self.assertEqual(result.archive_version, 1) + self.assertEqual(result.document_no, order.order_no) + + latest = get_latest_archive(self.db, DOCUMENT_TYPE_SALES_ORDER, order.id) + self.assertIsNotNone(latest) + assert latest is not None + self.assertEqual(latest.document_no, order.order_no) + self.assertEqual(latest.archive_version, 1) + self.assertEqual(latest.status, ARCHIVE_STATUS_READY) + + pdf_path = Path(latest.file_path) + self.assertTrue(pdf_path.exists()) + self.assertEqual(pdf_path.read_bytes()[:4], b"%PDF") + + def test_generate_archive_fills_preparer_and_operator_from_current_user(self) -> None: + order = self._seed_sales_order() + user = self._seed_archive_user(nickname="张制单") + captured_contexts: list[ArchiveContext] = [] + + def capture_render(context: ArchiveContext, output_path: str | Path) -> None: + captured_contexts.append(context) + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + Path(output_path).write_bytes(b"%PDF archive test") + + with TemporaryDirectory() as tempdir, patch( + "app.services.document_archives.render_pdf_archive", + side_effect=capture_render, + ): + result = generate_document_archive( + self.db, + DOCUMENT_TYPE_SALES_ORDER, + order.id, + created_by=user.id, + archive_root=tempdir, + ) + + self.assertEqual(result.archive_status, ARCHIVE_STATUS_READY) + self.assertEqual(len(captured_contexts), 1) + self.assertEqual(captured_contexts[0].prepared_by, "张制单") + self.assertEqual(captured_contexts[0].operator_name, "张制单") + + def test_draw_archive_footer_renders_preparer_and_operator_names(self) -> None: + class FakePdf: + def __init__(self) -> None: + self.drawn_texts: list[str] = [] + + def setFont(self, *args, **kwargs) -> None: + _ = args, kwargs + + def drawRightString(self, x, y, text) -> None: + _ = x, y + self.drawn_texts.append(text) + + def drawString(self, x, y, text) -> None: + _ = x, y + self.drawn_texts.append(text) + + context = ArchiveContext( + document_type=DOCUMENT_TYPE_SALES_ORDER, + business_id=100, + document_no="SO-20260611-0001", + title="销售订单归档", + partner_label="客户", + partner_name="上海测试客户有限公司", + document_date=date(2026, 6, 11), + due_date_label="承诺日期", + due_date=date(2026, 6, 20), + status="已审核", + tax_rate=0.13, + total_amount=2500, + prepared_by="张制单", + operator_name="张经办", + ) + fake_pdf = FakePdf() + + _draw_archive_footer(fake_pdf, context, 120, "Helvetica", "Helvetica-Bold") + + self.assertIn("制单: 张制单", fake_pdf.drawn_texts) + self.assertIn("经办人: 张经办", fake_pdf.drawn_texts) + self.assertNotIn("制单: __________", fake_pdf.drawn_texts) + + def test_collect_raw_purchase_order_archive_context_uses_weight_as_quantity(self) -> None: + order = self._seed_purchase_order(target_warehouse_type="RAW") + + context = collect_purchase_order_archive_context(self.db, order.id) + + self.assertEqual(context.document_type, DOCUMENT_TYPE_PURCHASE_ORDER) + self.assertEqual(len(context.lines), 1) + line = context.lines[0] + self.assertEqual(float(line.quantity or 0), 1234.56) + self.assertEqual(float(line.delivered_or_received_quantity or 0), 234.5) + self.assertNotEqual(float(line.quantity or 0), 0) + self.assertIn("kg", line.remark or "") + + def test_collect_aux_purchase_order_archive_context_uses_order_quantity(self) -> None: + order = self._seed_purchase_order(target_warehouse_type="AUX") + + context = collect_purchase_order_archive_context(self.db, order.id) + + self.assertEqual(len(context.lines), 1) + line = context.lines[0] + self.assertEqual(float(line.quantity or 0), 12) + self.assertEqual(float(line.delivered_or_received_quantity or 0), 3) + self.assertEqual(line.remark, "测试明细") + + def test_collect_purchase_receipt_archive_context_uses_receipt_items(self) -> None: + receipt = self._seed_purchase_receipt(target_warehouse_type="RAW") + + context = collect_purchase_receipt_archive_context(self.db, receipt.id) + + self.assertEqual(context.document_type, DOCUMENT_TYPE_PURCHASE_RECEIPT) + self.assertEqual(context.business_id, receipt.id) + self.assertEqual(context.document_no, "入库2026-00009") + self.assertEqual(context.title, "到货入库单归档") + self.assertEqual(context.partner_label, "供应商") + self.assertEqual(context.partner_name, "宁波到货供应商有限公司") + self.assertEqual(context.due_date_label, "采购订单") + self.assertEqual(context.due_date, "采购2026-00088") + self.assertIn("运单号:YD-20260612", context.remark or "") + self.assertIn("运费:88.8", context.remark or "") + self.assertEqual(len(context.lines), 1) + line = context.lines[0] + self.assertEqual(line.item_code, "RM-RCPT-001") + self.assertEqual(line.item_name, "到货圆钢") + self.assertEqual(float(line.quantity or 0), 125.5) + self.assertEqual(float(line.delivered_or_received_quantity or 0), 0) + self.assertEqual(float(line.unit_price or 0), 7.2) + self.assertEqual(context.status, "待质检") + self.assertEqual(line.remark, "批次:JH到货圆钢00001_0001;状态:待质检;入库明细备注") + + def test_collect_quality_inspection_archive_context_uses_inspection_result(self) -> None: + receipt = self._seed_purchase_receipt(target_warehouse_type="RAW") + receipt_item = self.db.scalar(select(PurchaseReceiptItem).where(PurchaseReceiptItem.receipt_id == receipt.id)) + self.assertIsNotNone(receipt_item) + assert receipt_item is not None + receipt.status = "PASSED" + receipt_item.status = "PASSED" + receipt_item.accepted_weight_kg = receipt_item.received_weight_kg + receipt_item.remark = "表面无划伤,尺寸符合要求" + lot = StockLot( + id=470, + lot_no=receipt_item.lot_no, + parent_lot_id=None, + lot_role="INBOUND_RAW", + material_sub_batch_no=None, + item_id=receipt_item.material_item_id, + warehouse_id=receipt.warehouse_id, + location_id=None, + source_doc_type="PURCHASE_RECEIPT", + source_doc_id=receipt.id, + source_line_id=receipt_item.id, + source_material_lot_id=None, + source_material_sub_batch_no=None, + source_material_summary=None, + inbound_qty=0, + inbound_weight_kg=receipt_item.received_weight_kg, + remaining_qty=0, + remaining_weight_kg=receipt_item.received_weight_kg, + locked_qty=0, + locked_weight_kg=0, + unit_cost=receipt_item.unit_cost, + production_date=None, + expire_date=None, + quality_status="PASS", + logistics_waybill_no=None, + logistics_freight_amount=None, + logistics_photo_url=None, + status="AVAILABLE", + remark="质检员王工", + created_at=datetime.now(), + updated_at=datetime.now(), + ) + self.db.add_all([receipt, receipt_item, lot]) + self.db.commit() + + context = collect_quality_inspection_archive_context(self.db, receipt_item.id) + + self.assertEqual(context.document_type, DOCUMENT_TYPE_QUALITY_INSPECTION) + self.assertEqual(context.business_id, receipt_item.id) + self.assertEqual(context.document_no, f"质检-{receipt.receipt_no}-{receipt_item.line_no}") + self.assertEqual(context.title, "质量校验单归档") + self.assertEqual(context.partner_label, "供应商") + self.assertEqual(context.partner_name, "宁波到货供应商有限公司") + self.assertEqual(context.due_date_label, "入库单") + self.assertEqual(context.due_date, receipt.receipt_no) + self.assertEqual(context.status, "已放行") + self.assertIn("质检人员:质检员王工", context.remark or "") + self.assertEqual(len(context.lines), 1) + line = context.lines[0] + self.assertEqual(line.item_code, "RM-RCPT-001") + self.assertEqual(line.item_name, "到货圆钢") + self.assertEqual(float(line.quantity or 0), 125.5) + self.assertEqual(float(line.delivered_or_received_quantity or 0), 125.5) + self.assertEqual(line.remark, "库存批次号:JH到货圆钢00001_0001;质检结论:已放行;表面无划伤,尺寸符合要求") + + def test_generate_purchase_receipt_pdf_archive_creates_latest_ready_archive(self) -> None: + receipt = self._seed_purchase_receipt(target_warehouse_type="RAW") + + with TemporaryDirectory() as tempdir: + result = generate_document_archive( + self.db, + DOCUMENT_TYPE_PURCHASE_RECEIPT, + receipt.id, + archive_root=tempdir, + ) + + self.assertEqual(result.archive_status, ARCHIVE_STATUS_READY) + self.assertEqual(result.document_no, "入库2026-00009") + + latest = get_latest_archive(self.db, DOCUMENT_TYPE_PURCHASE_RECEIPT, receipt.id) + self.assertIsNotNone(latest) + assert latest is not None + self.assertEqual(latest.status, ARCHIVE_STATUS_READY) + self.assertTrue(Path(latest.file_path).exists()) + + def test_generate_sales_order_pdf_archive_increments_archive_version(self) -> None: + order = self._seed_sales_order() + + with TemporaryDirectory() as tempdir: + first = generate_document_archive( + self.db, + DOCUMENT_TYPE_SALES_ORDER, + order.id, + archive_root=tempdir, + ) + second = generate_document_archive( + self.db, + DOCUMENT_TYPE_SALES_ORDER, + order.id, + archive_root=tempdir, + ) + + self.assertEqual(first.archive_version, 1) + self.assertEqual(second.archive_version, 2) + + archives = self.db.execute( + select(DocumentArchive) + .where( + DocumentArchive.document_type == DOCUMENT_TYPE_SALES_ORDER, + DocumentArchive.business_id == order.id, + ) + .order_by(DocumentArchive.archive_version) + ).scalars().all() + self.assertEqual(len(archives), 2) + self.assertEqual([archive.archive_version for archive in archives], [1, 2]) + self.assertTrue(all(Path(archive.file_path).exists() for archive in archives)) + + def test_generate_archive_records_failed_row_when_version_lookup_fails(self) -> None: + order = self._seed_sales_order() + + with patch( + "app.services.document_archives.next_archive_version", + side_effect=RuntimeError("version lookup failed"), + ): + result = generate_document_archive(self.db, DOCUMENT_TYPE_SALES_ORDER, order.id) + + self.assertEqual(result.archive_status, ARCHIVE_STATUS_FAILED) + self.assertEqual(result.archive_version, 1) + self.assertIn("version lookup failed", result.archive_error_message or "") + + failed_archives = self.db.execute( + select(DocumentArchive).where( + DocumentArchive.document_type == DOCUMENT_TYPE_SALES_ORDER, + DocumentArchive.business_id == order.id, + DocumentArchive.status == ARCHIVE_STATUS_FAILED, + ) + ).scalars().all() + self.assertEqual(len(failed_archives), 1) + self.assertEqual(failed_archives[0].archive_version, 1) + self.assertEqual(failed_archives[0].document_no, f"{DOCUMENT_TYPE_SALES_ORDER}-{order.id}") + + def test_render_pdf_archive_includes_more_than_eighteen_lines(self) -> None: + context = ArchiveContext( + document_type=DOCUMENT_TYPE_SALES_ORDER, + business_id=100, + document_no="SO-20260611-0001", + title="销售订单归档", + partner_label="客户", + partner_name="上海测试客户有限公司", + document_date=date(2026, 6, 11), + due_date_label="承诺日期", + due_date=date(2026, 6, 20), + status="已审核", + tax_rate=0.13, + total_amount=2500, + lines=[ + ArchiveLine( + line_no=line_no, + item_code=f"FG-{line_no:03d}", + item_name=f"精密锻件{line_no}", + specification="A-100", + quantity=line_no, + delivered_or_received_quantity=0, + unit_price=100, + line_amount=100 * line_no, + promised_or_expected_date=date(2026, 6, 20), + ) + for line_no in range(1, 26) + ], + ) + captured_line_numbers: list[int] = [] + + def capture_draw_cell(pdf, x, y, width, height, text, font_name, size=8, align="left"): + _ = pdf, x, y, width, height, font_name, size, align + if str(text).isdigit(): + captured_line_numbers.append(int(text)) + + with TemporaryDirectory() as tempdir, patch("app.services.document_archives._draw_cell", side_effect=capture_draw_cell): + render_pdf_archive(context, Path(tempdir) / "archive.pdf") + + for line_no in range(1, 26): + self.assertIn(line_no, captured_line_numbers) + + def test_archive_table_columns_fit_inside_a4_page_frame(self) -> None: + table_x = 42 + right_margin = 42 + total_width = sum(width for _, width, _ in _archive_table_columns()) + + self.assertLessEqual(table_x + total_width, A4[0] - right_margin) + + def test_draw_cell_wraps_long_text_without_ellipsis(self) -> None: + class FakePdf: + def __init__(self) -> None: + self.font_name = "Helvetica" + self.size = 8 + self.drawn_texts: list[str] = [] + + def rect(self, *args, **kwargs) -> None: + _ = args, kwargs + + def setFont(self, font_name, size) -> None: + self.font_name = font_name + self.size = size + + def drawString(self, x, y, text) -> None: + _ = x, y + self.drawn_texts.append(text) + + def drawRightString(self, x, y, text) -> None: + _ = x, y + self.drawn_texts.append(text) + + def drawCentredString(self, x, y, text) -> None: + _ = x, y + self.drawn_texts.append(text) + + fake_pdf = FakePdf() + + _draw_cell( + fake_pdf, + 42, + 600, + 62, + 72, + "批次:JH_不锈钢板用于测试00101_0001;状态:待质检;入库明细备注", + "Helvetica", + 8, + "left", + ) + + rendered_text = "".join(fake_pdf.drawn_texts) + self.assertNotIn("...", rendered_text) + self.assertEqual(rendered_text, "批次:JH_不锈钢板用于测试00101_0001;状态:待质检;入库明细备注") + for line in fake_pdf.drawn_texts: + rendered_width = pdfmetrics.stringWidth(line, fake_pdf.font_name, fake_pdf.size) + self.assertLessEqual(rendered_width, 54) + + def test_generate_archive_failed_row_uses_free_version_after_collision(self) -> None: + order = self._seed_sales_order() + + def insert_colliding_archive_and_fail(context, output_path): + _ = context, output_path + self.db.add( + DocumentArchive( + document_type=DOCUMENT_TYPE_SALES_ORDER, + business_id=order.id, + document_no=order.order_no, + archive_version=1, + template_version=TEMPLATE_VERSION, + file_format=FILE_FORMAT_PDF, + file_name="existing.pdf", + file_path="/tmp/existing.pdf", + file_hash="existing", + status=ARCHIVE_STATUS_READY, + error_message=None, + created_by=None, + ) + ) + self.db.commit() + raise RuntimeError("render failed after version collision") + + with patch("app.services.document_archives.render_pdf_archive", side_effect=insert_colliding_archive_and_fail): + result = generate_document_archive(self.db, DOCUMENT_TYPE_SALES_ORDER, order.id) + + self.assertEqual(result.archive_status, ARCHIVE_STATUS_FAILED) + self.assertEqual(result.archive_version, 2) + + failed_archive = self.db.execute( + select(DocumentArchive).where( + DocumentArchive.document_type == DOCUMENT_TYPE_SALES_ORDER, + DocumentArchive.business_id == order.id, + DocumentArchive.status == ARCHIVE_STATUS_FAILED, + ) + ).scalar_one() + self.assertEqual(failed_archive.archive_version, 2) + self.assertIn("render failed after version collision", failed_archive.error_message or "") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_inventory_ledger_export.py b/backend/tests/test_inventory_ledger_export.py new file mode 100644 index 0000000..515b141 --- /dev/null +++ b/backend/tests/test_inventory_ledger_export.py @@ -0,0 +1,330 @@ +from __future__ import annotations + +import unittest +from datetime import date, datetime +from decimal import Decimal +from io import BytesIO + +from openpyxl import load_workbook +from sqlalchemy import BigInteger, create_engine +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + + +@compiles(BigInteger, "sqlite") +def _compile_big_integer_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.document_archive # noqa: E402,F401 +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.api.routes.inventory import export_inventory_transaction_ledger # noqa: E402 +from app.models.base import Base # noqa: E402 +from app.models.document_archive import DocumentArchive # noqa: E402 +from app.models.master_data import Item, Warehouse # noqa: E402 +from app.models.operations import InventoryTxn, ProductionBatchLedger, ProductionBatchLedgerTxn, StockLot, WarehouseLocation # noqa: E402 +from app.api.routes.inventory import list_inventory_transaction_ledger # noqa: E402 +from app.services.operations import get_inventory_txn_ledger_query, get_inventory_txn_query # noqa: E402 + + +class InventoryLedgerExportTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + future=True, + ) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self.now = datetime(2026, 6, 11, 9, 0, 0) + self._next_id = 100 + self._seed_master_data() + + def tearDown(self) -> None: + self.db.close() + + def _id(self) -> int: + self._next_id += 1 + return self._next_id + + def _seed_master_data(self) -> None: + self.warehouse = Warehouse( + id=1, + warehouse_code="RAW", + warehouse_name="原材料库", + warehouse_type="RAW", + status="ACTIVE", + created_at=self.now, + updated_at=self.now, + ) + self.location = WarehouseLocation( + id=2, + warehouse_id=self.warehouse.id, + location_code="RAW-A", + location_name="原料主库位", + is_default=1, + status="ACTIVE", + created_at=self.now, + updated_at=self.now, + ) + self.item = Item( + id=3, + item_code="RM-LEDGER-001", + item_name="测试冷轧钢", + item_type="RAW_MATERIAL", + unit_weight_kg=Decimal("1"), + status="ACTIVE", + created_at=self.now, + updated_at=self.now, + ) + self.lot = StockLot( + id=4, + lot_no="YL0001", + lot_role="RAW_MATERIAL", + item_id=self.item.id, + warehouse_id=self.warehouse.id, + location_id=self.location.id, + source_doc_type="期初入库", + source_doc_id=1, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("500"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("500"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("5.5"), + quality_status="PASS", + status="AVAILABLE", + created_at=self.now, + updated_at=self.now, + ) + self.db.add_all([self.warehouse, self.location, self.item, self.lot]) + self.db.commit() + + def _seed_raw_inventory_txn( + self, + *, + txn_type: str = "CUSTOMER_SUPPLIED_IN", + source_doc_type: str = "客料入库", + source_doc_id: int = 1, + txn_no: str | None = None, + biz_time: datetime | None = None, + ) -> InventoryTxn: + txn = InventoryTxn( + id=self._id(), + txn_no=txn_no or f"TXN-{self._next_id}", + txn_type=txn_type, + item_id=self.item.id, + warehouse_id=self.warehouse.id, + location_id=self.location.id, + lot_id=self.lot.id, + qty_change=Decimal("0"), + weight_change_kg=Decimal("100"), + unit_cost=Decimal("5.5"), + amount=Decimal("550"), + source_doc_type=source_doc_type, + source_doc_id=source_doc_id, + logistics_waybill_no="SF123", + logistics_freight_amount=Decimal("12.5"), + logistics_photo_url="/uploads/ledger.jpg", + biz_time=biz_time or self.now, + remark="流水导出测试备注", + created_at=self.now, + updated_at=self.now, + ) + self.db.add(txn) + self.db.commit() + return txn + + def test_inventory_ledger_returns_warehouse_archive_fields(self) -> None: + txn = self._seed_raw_inventory_txn(txn_no="TXN-ARCHIVE-001") + self.db.add( + DocumentArchive( + document_type="仓库出入库单", + business_id=txn.id, + document_no="客料入库-TXN-ARCHIVE-001", + archive_version=1, + template_version="单据纸面V1", + file_format="PDF", + file_name="客料入库.pdf", + file_path="/tmp/客料入库.pdf", + file_hash="abc", + status="已归档", + ) + ) + self.db.commit() + + row = self.db.execute(get_inventory_txn_ledger_query(warehouse_type="RAW")).mappings().one() + + self.assertEqual(row["archive_status"], "已归档") + self.assertEqual(row["archive_business_id"], txn.id) + self.assertEqual(row["archive_document_type"], "仓库出入库单") + self.assertIsNone(row["archive_error_message"]) + + def test_inventory_detail_transactions_return_archive_fields(self) -> None: + txn = self._seed_raw_inventory_txn(txn_no="TXN-DETAIL-ARCHIVE") + self.db.add( + DocumentArchive( + document_type="仓库出入库单", + business_id=txn.id, + document_no="客料入库-TXN-DETAIL-ARCHIVE", + archive_version=1, + template_version="单据纸面V1", + file_format="PDF", + file_name="客料入库.pdf", + file_path="/tmp/客料入库.pdf", + file_hash="detail", + status="已归档", + ) + ) + self.db.commit() + + row = self.db.execute(get_inventory_txn_query()).mappings().one() + + self.assertEqual(row["archive_status"], "已归档") + self.assertEqual(row["archive_business_id"], txn.id) + self.assertEqual(row["archive_document_type"], "仓库出入库单") + self.assertIsNone(row["archive_error_message"]) + + def test_inventory_ledger_points_production_out_to_material_out_archive(self) -> None: + self._seed_raw_inventory_txn( + txn_type="PRODUCTION_OUT", + source_doc_type="生产台账", + source_doc_id=88, + txn_no="TXN-PRODUCTION-OUT", + ) + + row = self.db.execute(get_inventory_txn_ledger_query(warehouse_type="RAW")).mappings().one() + + self.assertEqual(row["archive_status"], "未生成") + self.assertEqual(row["archive_document_type"], "生产领料出库单") + self.assertEqual(row["archive_business_id"], 88) + + def test_transaction_ledger_export_uses_date_filter_and_chinese_headers(self) -> None: + self._seed_raw_inventory_txn( + txn_no="TXN-IN-RANGE", + biz_time=datetime(2026, 6, 10, 8, 0, 0), + ) + self._seed_raw_inventory_txn( + txn_no="TXN-OUT-RANGE", + biz_time=datetime(2026, 5, 1, 8, 0, 0), + ) + + response = export_inventory_transaction_ledger( + warehouse_type="RAW", + direction="IN", + start_date=date(2026, 6, 1), + end_date=date(2026, 6, 30), + db=self.db, + ) + + self.assertEqual(response.status_code, 200) + self.assertIn( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + response.media_type, + ) + + workbook = load_workbook(BytesIO(response.body)) + sheet = workbook.active + headers = [cell.value for cell in sheet[1]] + self.assertIn("业务时间", headers) + self.assertIn("PDF归档状态", headers) + rows = [[cell.value for cell in row] for row in sheet.iter_rows(min_row=2)] + flattened = "\n".join(str(value) for row in rows for value in row) + self.assertIn("TXN-IN-RANGE", flattened) + self.assertNotIn("TXN-OUT-RANGE", flattened) + + def test_production_inbound_rows_share_settlement_archive_business_id(self) -> None: + finished_txn_id = 301 + surplus_txn = self._seed_raw_inventory_txn( + txn_type="PRODUCTION_SURPLUS_IN", + source_doc_type="生产台账", + source_doc_id=66, + txn_no="TXN-SURPLUS-SHARED", + biz_time=datetime(2026, 6, 10, 10, 0, 0), + ) + ledger = ProductionBatchLedger( + id=66, + material_lot_id=self.lot.id, + material_lot_no=self.lot.lot_no, + material_item_id=self.item.id, + product_item_id=self.item.id, + status="在生产", + total_issued_weight_kg=Decimal("100"), + outside_weight_kg=Decimal("80"), + created_at=self.now, + updated_at=self.now, + ) + batch_no = "生产结算20260610100000000001" + finished_ledger_txn = ProductionBatchLedgerTxn( + id=401, + production_ledger_id=ledger.id, + txn_type="成品入库", + qty_delta=Decimal("20"), + weight_delta_kg=Decimal("-12"), + outside_weight_after_kg=Decimal("88"), + source_doc_type="库存流水", + source_doc_id=finished_txn_id, + biz_time=datetime(2026, 6, 10, 10, 0, 0), + remark=f"成品入库联动生产台账;单据批次号:{batch_no}", + created_at=self.now, + updated_at=self.now, + ) + surplus_ledger_txn = ProductionBatchLedgerTxn( + id=402, + production_ledger_id=ledger.id, + txn_type="生产余料入库", + qty_delta=Decimal("0"), + weight_delta_kg=Decimal("8"), + outside_weight_after_kg=Decimal("80"), + source_doc_type="库存流水", + source_doc_id=surplus_txn.id, + biz_time=datetime(2026, 6, 10, 10, 0, 0), + remark=f"生产余料入库联动生产台账;单据批次号:{batch_no}", + created_at=self.now, + updated_at=self.now, + ) + self.db.add_all([ + ledger, + finished_ledger_txn, + surplus_ledger_txn, + DocumentArchive( + document_type="生产入库结算单", + business_id=finished_txn_id, + document_no="生产入库结算-TXN-FINISHED-SHARED", + archive_version=1, + template_version="单据纸面V1", + file_format="PDF", + file_name="生产入库结算.pdf", + file_path="/tmp/生产入库结算.pdf", + file_hash="abc", + status="已归档", + ), + ]) + self.db.commit() + + result = list_inventory_transaction_ledger( + warehouse_type="RAW", + direction="IN", + page=1, + page_size=10, + db=self.db, + ) + row = next(item for item in result.items if item.inventory_txn_id == surplus_txn.id) + + self.assertEqual(row.archive_document_type, "生产入库结算单") + self.assertEqual(row.archive_business_id, finished_txn_id) + self.assertEqual(row.archive_status, "已归档") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_inventory_lot_numbering.py b/backend/tests/test_inventory_lot_numbering.py new file mode 100644 index 0000000..0758b2a --- /dev/null +++ b/backend/tests/test_inventory_lot_numbering.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import unittest +from decimal import Decimal + +from sqlalchemy import BigInteger, create_engine +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Item, Warehouse # noqa: E402 +from app.models.operations import StockLot # noqa: E402 +from app.services.system_config import RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE, upsert_system_config # noqa: E402 +from app.services.operations import build_inventory_lot_no # noqa: E402 + + +class InventoryLotNumberingTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self.material = Item( + id=1, + item_code="110501000185", + item_name="左右后纵梁后段毛坯", + item_type="RAW_MATERIAL", + unit_weight_kg=Decimal("1"), + status="ACTIVE", + ) + self.other_material = Item( + id=2, + item_code="110501000186", + item_name="其他原材料", + item_type="RAW_MATERIAL", + unit_weight_kg=Decimal("1"), + status="ACTIVE", + ) + self.warehouse = Warehouse( + id=1, + warehouse_code="WH-RAW", + warehouse_name="原材料库", + warehouse_type="RAW", + status="ACTIVE", + ) + self.db.add_all([self.material, self.other_material, self.warehouse]) + self.db.commit() + + def tearDown(self) -> None: + self.db.close() + + def test_inventory_lot_no_starts_from_global_prefix_sequence(self) -> None: + self.assertEqual(build_inventory_lot_no(self.db, self.material.id), "YL0001") + + def test_inventory_lot_no_increments_from_existing_lot_for_different_material(self) -> None: + self.db.add( + StockLot( + lot_no="YL0001", + lot_role="INBOUND_RAW", + item_id=self.other_material.id, + warehouse_id=self.warehouse.id, + source_doc_type="TEST", + source_doc_id=1, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("100"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("100"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("5"), + quality_status="PASS", + status="AVAILABLE", + ) + ) + self.db.commit() + + self.assertEqual(build_inventory_lot_no(self.db, self.material.id), "YL0002") + + def test_inventory_lot_no_uses_natural_width_after_four_digits(self) -> None: + self.db.add( + StockLot( + lot_no="YL9999", + lot_role="INBOUND_RAW", + item_id=self.material.id, + warehouse_id=self.warehouse.id, + source_doc_type="TEST", + source_doc_id=1, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("100"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("100"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("5"), + quality_status="PASS", + status="AVAILABLE", + ) + ) + self.db.commit() + + self.assertEqual(build_inventory_lot_no(self.db, self.material.id), "YL10000") + + def test_inventory_lot_no_uses_configured_prefix(self) -> None: + upsert_system_config( + self.db, + config_code=RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE, + config_name="原材料库存批次号前缀", + config_value="rm", + ) + self.db.commit() + + self.assertEqual(build_inventory_lot_no(self.db, self.material.id), "RM0001") diff --git a/backend/tests/test_material_code_generation.py b/backend/tests/test_material_code_generation.py new file mode 100644 index 0000000..85e28dd --- /dev/null +++ b/backend/tests/test_material_code_generation.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import unittest +from datetime import UTC, datetime +from decimal import Decimal + +from sqlalchemy import BigInteger, create_engine +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +from app.api.routes import master_data # noqa: E402 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Item # noqa: E402 +from app.schemas.database import MaterialCreate # noqa: E402 + + +class MaterialCodeGenerationTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + + def tearDown(self) -> None: + self.db.close() + + def _payload(self) -> MaterialCreate: + return MaterialCreate( + item_name="冷轧钢板", + specification="1.0*1000", + material_grade="SPCC", + unit_weight_kg=1, + safety_stock_weight_kg=0, + scrap_sale_price=0, + default_supplier_id=None, + purchase_calc_mode="BY_WEIGHT", + min_purchase_qty=0, + purchase_multiple_qty=0, + purchase_multiple_weight_kg=0, + lead_time_days=0, + quality_rule=None, + status="ACTIVE", + ) + + def _add_item(self, code: str, item_type: str = "RAW_MATERIAL") -> None: + now = datetime.now(UTC) + self.db.add( + Item( + item_code=code, + item_name=f"测试物料{code}", + item_type=item_type, + specification="", + material_grade="", + unit_weight_kg=Decimal("0"), + safety_stock_weight_kg=Decimal("0"), + scrap_sale_price=Decimal("0"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + ) + self.db.commit() + + def test_material_code_starts_from_named_five_digit_sequence(self) -> None: + self.assertEqual(master_data._build_material_code(self.db, self._payload()), "原材料00001") + + def test_material_code_increments_existing_named_material_codes(self) -> None: + self._add_item("原材料00001") + self._add_item("RM-SPCC-001") + + self.assertEqual(master_data._build_material_code(self.db, self._payload()), "原材料00002") + + def test_material_code_ignores_legacy_numeric_codes(self) -> None: + self._add_item("00001") + self._add_item("110801001050") + self._add_item("110501000185") + + self.assertEqual(master_data._build_material_code(self.db, self._payload()), "原材料00001") + + def test_material_code_skips_codes_used_by_other_item_types(self) -> None: + self._add_item("原材料00001") + self._add_item("原材料00002", item_type="FINISHED_GOOD") + + self.assertEqual(master_data._build_material_code(self.db, self._payload()), "原材料00003") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_material_customer_excel_import_export.py b/backend/tests/test_material_customer_excel_import_export.py new file mode 100644 index 0000000..15ec4d6 --- /dev/null +++ b/backend/tests/test_material_customer_excel_import_export.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import unittest +from datetime import UTC, datetime +from decimal import Decimal +from io import BytesIO + +from openpyxl import Workbook, load_workbook +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.api.routes import master_data, sales # noqa: E402 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Item, Material # noqa: E402 +from app.models.sales import Customer # noqa: E402 + + +def _workbook_bytes(headers: list[str], rows: list[list[object]]) -> bytes: + workbook = Workbook() + worksheet = workbook.active + worksheet.append(headers) + for row in rows: + worksheet.append(row) + output = BytesIO() + workbook.save(output) + return output.getvalue() + + +class MaterialCustomerExcelImportExportTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + + def tearDown(self) -> None: + self.db.close() + + def test_material_import_upserts_by_code_and_exports_performance_requirement(self) -> None: + now = datetime.now(UTC) + existing = Item( + item_code="原材料00001", + item_name="旧冷轧钢板", + item_type="RAW_MATERIAL", + specification="旧规格", + material_grade="旧材质", + unit_weight_kg=Decimal("1"), + safety_stock_weight_kg=Decimal("0"), + scrap_sale_price=Decimal("0"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add(existing) + self.db.flush() + self.db.add( + Material( + item_id=existing.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="旧性能要求", + ) + ) + self.db.commit() + + result = master_data._import_material_excel( + self.db, + _workbook_bytes( + master_data.MATERIAL_EXCEL_HEADERS, + [ + ["原材料00001", "冷轧钢板", "SPCC", "1.0*1000", 2.5, "表面无划伤", "启用"], + ["", "铝合金", "6061", "2.0*500", 4.2, "阳极氧化", "启用"], + ], + ), + ) + + self.assertEqual(result["updated"], 1) + self.assertEqual(result["imported"], 1) + updated_item = self.db.scalar(select(Item).where(Item.item_code == "原材料00001")) + self.assertEqual(updated_item.item_name, "冷轧钢板") + self.assertEqual(updated_item.material_grade, "SPCC") + self.assertEqual(float(updated_item.scrap_sale_price), 2.5) + updated_material = self.db.scalar(select(Material).where(Material.item_id == updated_item.id)) + self.assertEqual(updated_material.quality_rule, "表面无划伤") + + workbook = master_data._build_material_workbook( + self.db.execute(master_data._material_export_stmt()).mappings().all() + ) + exported = BytesIO() + workbook.save(exported) + rows = list(load_workbook(BytesIO(exported.getvalue()), read_only=True, data_only=True).active.iter_rows(values_only=True)) + self.assertEqual(list(rows[0]), master_data.MATERIAL_EXCEL_HEADERS) + self.assertIn(("原材料00001", "冷轧钢板", "SPCC", "1.0*1000", 2.5, "表面无划伤", "启用"), rows[1:]) + + def test_customer_import_upserts_by_name_and_exports_three_contacts(self) -> None: + now = datetime.now(UTC) + self.db.add( + Customer( + customer_code="CUS-EXCEL-001", + customer_name="老客户", + short_name="老客户", + contact_name="旧联系人", + contact_phone="13000000000", + address="旧地址", + credit_days=15, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + ) + self.db.commit() + + result = sales._import_customer_excel( + self.db, + _workbook_bytes( + sales.CUSTOMER_EXCEL_HEADERS, + [ + ["老客户", "老客", "张三", "13800000001", "李四", "13800000002", "王五", "13800000003", "新地址", 30], + ["新客户", "新客", "赵六", "13900000001", "", "", "", "", "新客户地址", 45], + ], + ), + ) + + self.assertEqual(result["updated"], 1) + self.assertEqual(result["imported"], 1) + updated = self.db.scalar(select(Customer).where(Customer.customer_name == "老客户")) + self.assertEqual(updated.short_name, "老客") + self.assertEqual(updated.contact_name, "张三、李四、王五") + self.assertEqual(updated.contact_phone, "13800000001、13800000002、13800000003") + self.assertEqual(updated.address, "新地址") + self.assertEqual(updated.credit_days, 30) + + workbook = sales._build_customer_workbook(self.db.scalars(select(Customer).order_by(Customer.customer_name.asc())).all()) + exported = BytesIO() + workbook.save(exported) + rows = list(load_workbook(BytesIO(exported.getvalue()), read_only=True, data_only=True).active.iter_rows(values_only=True)) + self.assertEqual(list(rows[0]), sales.CUSTOMER_EXCEL_HEADERS) + self.assertIn(("老客户", "老客", "张三", "13800000001", "李四", "13800000002", "王五", "13800000003", "新地址", 30), rows[1:]) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_miniapp_operation_report_date_filter.py b/backend/tests/test_miniapp_operation_report_date_filter.py new file mode 100644 index 0000000..4f22fee --- /dev/null +++ b/backend/tests/test_miniapp_operation_report_date_filter.py @@ -0,0 +1,278 @@ +from __future__ import annotations + +import unittest +from datetime import date, datetime + +from fastapi import HTTPException +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.api.routes import production as production_routes # noqa: E402 +from app.api.routes.production import _miniapp_report_item_rows, list_miniapp_operation_reports # noqa: E402 +from app.models.base import Base # noqa: E402 +from app.models.miniapp import MiniAppPersonnel, MiniAppProductionReport, MiniAppProductionReportItem # noqa: E402 +from app.models.operations import OperationReport # noqa: E402 +from app.models.org import SystemConfig # noqa: E402 +from app.services.system_config import ( # noqa: E402 + MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE, + format_miniapp_operation_report_last_sync, + get_miniapp_operation_report_last_sync, + parse_miniapp_operation_report_last_sync, + upsert_miniapp_operation_report_last_sync, +) + + +class MiniAppOperationReportDateFilterTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self._seed_reports() + + def tearDown(self) -> None: + self.db.close() + + def _seed_reports(self) -> None: + now = datetime(2026, 6, 1, 8, 0, 0) + self.db.add(MiniAppPersonnel(phone="13800000000", name="测试员工", created_at=now, updated_at=now)) + for report_id, report_date in [(1, date(2026, 5, 10)), (2, date(2026, 5, 20)), (3, date(2026, 6, 1))]: + self.db.add( + MiniAppProductionReport( + id=report_id, + attendance_point_name="测试考勤点", + session_id=report_id, + employee_phone="13800000000", + report_date=report_date, + start_at=datetime.combine(report_date, now.time()), + end_at=datetime.combine(report_date, now.time()), + duration_minutes=0, + break_minutes=0, + effective_minutes=0, + total_good_qty=0, + total_output_qty=0, + actual_beat=0, + standard_beat=0, + expected_workload=0, + pace_rate=0, + workload_rate=0, + status="approved", + submitted_at=datetime.combine(report_date, now.time()), + updated_at=datetime.combine(report_date, now.time()), + ) + ) + self.db.add( + MiniAppProductionReportItem( + id=report_id, + report_id=report_id, + attendance_point_name="测试考勤点", + device_no="1#", + project_no="P-001", + product_name=f"测试产品{report_id}", + raw_material_batch_no=f"JH-RM-{report_id:04d}", + process_name="1序", + standard_beat=0, + standard_workload=0, + good_qty=10, + defect_qty=0, + scrap_qty=0, + allocated_minutes=0, + started_at=datetime.combine(report_date, now.time()), + created_at=datetime.combine(report_date, now.time()), + ) + ) + self.db.commit() + + def test_miniapp_report_item_rows_filters_by_report_date_range(self) -> None: + rows = _miniapp_report_item_rows( + self.db, + limit=100, + start_date=date(2026, 5, 18), + end_date=date(2026, 6, 1), + ) + + self.assertEqual([row["source_item_id"] for row in rows], [3, 2]) + self.assertEqual({row["report_date"] for row in rows}, {date(2026, 5, 20), date(2026, 6, 1)}) + + def test_miniapp_operation_reports_rejects_start_date_after_end_date(self) -> None: + with self.assertRaises(HTTPException) as exc: + list_miniapp_operation_reports( + limit=100, + sync_to_erp=False, + start_date=date(2026, 6, 1), + end_date=date(2026, 5, 18), + db=self.db, + ) + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("开始日期不能晚于结束日期", exc.exception.detail) + + def test_miniapp_operation_report_last_sync_config_round_trips_datetime(self) -> None: + timestamp = datetime(2026, 6, 11, 10, 30, 15) + + row = upsert_miniapp_operation_report_last_sync(self.db, timestamp, updated_by=1) + self.db.commit() + + persisted = self.db.scalar( + select(SystemConfig).where(SystemConfig.config_code == MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE) + ) + self.assertIsNotNone(persisted) + self.assertEqual(row.config_value, "2026-06-11 10:30:15") + self.assertEqual(format_miniapp_operation_report_last_sync(timestamp), "2026-06-11 10:30:15") + self.assertEqual(parse_miniapp_operation_report_last_sync("2026-06-11 10:30:15"), timestamp) + self.assertEqual(get_miniapp_operation_report_last_sync(self.db), timestamp) + + def test_list_reports_with_sync_disabled_does_not_call_sync(self) -> None: + calls: list[int] = [] + original_sync = production_routes._sync_miniapp_item_to_erp + + def fake_sync(db, source_item_id, **kwargs): + calls.append(source_item_id) + return None + + production_routes._sync_miniapp_item_to_erp = fake_sync + try: + rows = list_miniapp_operation_reports( + limit=100, + sync_to_erp=False, + start_date=date(2026, 5, 18), + end_date=date(2026, 6, 1), + db=self.db, + ) + finally: + production_routes._sync_miniapp_item_to_erp = original_sync + + self.assertEqual(len(rows), 2) + self.assertEqual(calls, []) + self.assertEqual(self.db.query(OperationReport).count(), 0) + + def test_list_reports_with_sync_disabled_skips_erp_target_resolution(self) -> None: + calls: list[str] = [] + original_find_ledger = production_routes._find_production_ledger_for_miniapp_item + original_find_work_order = production_routes._find_work_order_for_miniapp_item + original_find_operation = production_routes._find_work_order_operation_for_miniapp_item + + def fake_find_ledger(*args, **kwargs): + calls.append("production_ledger") + return None + + def fake_find_work_order(*args, **kwargs): + calls.append("work_order") + return None + + def fake_find_operation(*args, **kwargs): + calls.append("operation") + return None + + production_routes._find_production_ledger_for_miniapp_item = fake_find_ledger + production_routes._find_work_order_for_miniapp_item = fake_find_work_order + production_routes._find_work_order_operation_for_miniapp_item = fake_find_operation + try: + rows = list_miniapp_operation_reports( + limit=100, + sync_to_erp=False, + start_date=date(2026, 5, 18), + end_date=date(2026, 6, 1), + db=self.db, + ) + finally: + production_routes._find_production_ledger_for_miniapp_item = original_find_ledger + production_routes._find_work_order_for_miniapp_item = original_find_work_order + production_routes._find_work_order_operation_for_miniapp_item = original_find_operation + + self.assertEqual(len(rows), 2) + self.assertEqual(calls, []) + + def test_list_reports_caches_missing_erp_reports_without_per_row_scalar_lookup(self) -> None: + original_scalar = self.db.scalar + + def fail_scalar(*args, **kwargs): + raise AssertionError("list refresh must not query missing ERP reports one row at a time") + + rows = _miniapp_report_item_rows( + self.db, + limit=100, + start_date=date(2026, 5, 18), + end_date=date(2026, 6, 1), + ) + cache = production_routes._new_miniapp_resolve_cache() + report_nos = [ + production_routes._miniapp_report_no(int(row["source_report_id"]), int(row["source_item_id"])) + for row in rows + ] + production_routes._preload_miniapp_operation_reports(self.db, report_nos, cache) + + self.db.scalar = fail_scalar + try: + result = [ + production_routes._build_miniapp_operation_report_read( + self.db, + row, + None, + cache=cache, + resolve_targets=False, + ) + for row in rows + ] + finally: + self.db.scalar = original_scalar + + self.assertEqual(len(result), 2) + self.assertTrue(all(not row.is_synced_to_erp for row in result)) + + def test_sync_reports_uses_last_success_time_not_page_filter(self) -> None: + upsert_miniapp_operation_report_last_sync(self.db, datetime(2026, 5, 19, 0, 0, 0), updated_by=1) + self.db.commit() + calls: list[int] = [] + original_sync = production_routes._sync_miniapp_item_to_erp + original_now = production_routes.datetime + + class FixedDateTime(datetime): + @classmethod + def now(cls, tz=None): + return cls(2026, 6, 11, 12, 0, 0) + + def fake_sync(db, source_item_id, **kwargs): + calls.append(source_item_id) + return None + + production_routes._sync_miniapp_item_to_erp = fake_sync + production_routes.datetime = FixedDateTime + try: + result = production_routes.sync_miniapp_operation_reports(db=self.db) + finally: + production_routes._sync_miniapp_item_to_erp = original_sync + production_routes.datetime = original_now + + self.assertEqual(calls, [2, 3]) + self.assertEqual(result.total, 2) + self.assertEqual(result.sync_from, datetime(2026, 5, 19, 0, 0, 0)) + self.assertEqual(result.sync_to, datetime(2026, 6, 11, 12, 0, 0)) + self.assertEqual(get_miniapp_operation_report_last_sync(self.db), datetime(2026, 6, 11, 12, 0, 0)) + + def test_sync_state_returns_last_success_time(self) -> None: + timestamp = datetime(2026, 6, 10, 18, 20, 30) + upsert_miniapp_operation_report_last_sync(self.db, timestamp, updated_by=1) + self.db.commit() + + result = production_routes.get_miniapp_operation_report_sync_state(db=self.db) + + self.assertEqual(result.last_synced_at, timestamp) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_opening_inventory_import.py b/backend/tests/test_opening_inventory_import.py new file mode 100644 index 0000000..b805629 --- /dev/null +++ b/backend/tests/test_opening_inventory_import.py @@ -0,0 +1,310 @@ +from __future__ import annotations + +import unittest +from datetime import UTC, datetime +from decimal import Decimal +from io import BytesIO + +from openpyxl import Workbook, load_workbook +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Item, StockBalance, Warehouse # noqa: E402 +from app.models.operations import InventoryTxn, StockLot, WarehouseLocation # noqa: E402 + + +class OpeningInventoryImportTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self._seed() + + def tearDown(self) -> None: + self.db.close() + + def _seed(self) -> None: + now = datetime.now(UTC) + self.raw_warehouse = Warehouse( + id=1, + warehouse_code="WH-RAW", + warehouse_name="原材料仓", + warehouse_type="RAW", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.finished_warehouse = Warehouse( + id=2, + warehouse_code="WH-FG", + warehouse_name="成品仓", + warehouse_type="FINISHED", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.raw_location = WarehouseLocation( + id=1, + warehouse_id=1, + location_code="RAW-A", + location_name="原料主库位", + is_default=1, + is_locked=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.finished_location = WarehouseLocation( + id=2, + warehouse_id=2, + location_code="FG-A", + location_name="成品主库位", + is_default=1, + is_locked=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.raw_item = Item( + id=1, + item_code="原材料00001", + item_name="304不锈钢", + item_type="RAW_MATERIAL", + unit_weight_kg=Decimal("1"), + safety_stock_weight_kg=Decimal("0"), + scrap_sale_price=Decimal("0"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.finished_item = Item( + id=2, + item_code="产品需规00001", + item_name="五金支架", + item_type="FINISHED_GOOD", + unit_weight_kg=Decimal("2.5"), + safety_stock_weight_kg=Decimal("0"), + scrap_sale_price=Decimal("0"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add_all([ + self.raw_warehouse, + self.finished_warehouse, + self.raw_location, + self.finished_location, + self.raw_item, + self.finished_item, + ]) + self.db.commit() + + def _workbook_bytes(self, rows: list[list[object]]) -> bytes: + from app.api.routes.inventory import OPENING_INVENTORY_HEADERS + + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "期初导入" + worksheet.append(OPENING_INVENTORY_HEADERS) + for row in rows: + worksheet.append(row) + output = BytesIO() + workbook.save(output) + return output.getvalue() + + def test_opening_template_exports_expected_headers(self) -> None: + from app.api.routes.inventory import OPENING_INVENTORY_HEADERS, build_opening_inventory_template + + workbook = build_opening_inventory_template(self.db, "FINISHED") + output = BytesIO() + workbook.save(output) + loaded = load_workbook(BytesIO(output.getvalue()), read_only=True, data_only=True) + rows = list(loaded["成品库期初导入"].iter_rows(values_only=True)) + headers = list(rows[0]) + + self.assertEqual(headers, OPENING_INVENTORY_HEADERS) + self.assertEqual(len(rows), 3) + self.assertEqual(rows[1][headers.index("仓库类型")], "成品库") + self.assertEqual(rows[1][headers.index("批次号")], "示例-FG-0001") + self.assertEqual(rows[2][headers.index("批次号")], "示例-FG-0002") + + def test_raw_opening_template_uses_weight_only_headers(self) -> None: + from app.api.routes.inventory import build_opening_inventory_template + + workbook = build_opening_inventory_template(self.db, "RAW") + output = BytesIO() + workbook.save(output) + loaded = load_workbook(BytesIO(output.getvalue()), read_only=True, data_only=True) + rows = list(loaded["原材料库期初导入"].iter_rows(values_only=True)) + headers = list(rows[0]) + + self.assertNotIn("数量", headers) + self.assertIn("重量(kg)", headers) + self.assertEqual(rows[1][headers.index("仓库类型")], "原材料库") + self.assertEqual(rows[1][headers.index("重量(kg)")], 500) + + def test_import_opening_template_ignores_sample_rows(self) -> None: + from app.api.routes.inventory import build_opening_inventory_template, import_opening_inventory_workbook + + workbook = build_opening_inventory_template(self.db, "AUX") + output = BytesIO() + workbook.save(output) + + with self.assertRaises(ValueError) as exc: + import_opening_inventory_workbook(self.db, "AUX", output.getvalue(), user_id=1) + + self.assertIn("没有可导入的期初库存明细", str(exc.exception)) + + def test_import_opening_inventory_writes_finished_qty_weight_balance_and_txn(self) -> None: + from app.api.routes.inventory import import_opening_inventory_workbook + + content = self._workbook_bytes( + [[ + "成品库", + "成品仓", + "成品主库位", + "产品需规00001", + "五金支架", + "OPEN-FG-001", + 12, + 30, + 8.5, + "初始化清点", + "期初成品", + ]] + ) + + result = import_opening_inventory_workbook(self.db, "FINISHED", content, user_id=1) + + lot = self.db.scalar(select(StockLot).where(StockLot.lot_no == "OPEN-FG-001")) + balance = self.db.scalar(select(StockBalance).where(StockBalance.item_id == self.finished_item.id)) + txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == lot.id)) + + self.assertEqual(result["imported"], 1) + self.assertEqual(float(lot.inbound_qty), 12) + self.assertEqual(float(lot.inbound_weight_kg), 30) + self.assertEqual(float(lot.remaining_qty), 12) + self.assertEqual(float(lot.remaining_weight_kg), 30) + self.assertEqual(float(balance.qty_on_hand), 12) + self.assertEqual(float(balance.weight_on_hand_kg), 30) + self.assertEqual(float(txn.qty_change), 12) + self.assertEqual(float(txn.weight_change_kg), 30) + self.assertEqual(txn.txn_type, "OPENING_IN") + + def test_import_opening_inventory_keeps_raw_material_quantity_zero(self) -> None: + from app.api.routes.inventory import import_opening_inventory_workbook + + content = self._workbook_bytes( + [[ + "原材料库", + "原材料仓", + "原料主库位", + "原材料00001", + "304不锈钢", + "OPEN-RAW-001", + 99, + 500, + 4.2, + "初始化清点", + "期初原材料", + ]] + ) + + import_opening_inventory_workbook(self.db, "RAW", content, user_id=1) + + lot = self.db.scalar(select(StockLot).where(StockLot.lot_no == "YL0001")) + balance = self.db.scalar(select(StockBalance).where(StockBalance.item_id == self.raw_item.id)) + txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == lot.id)) + + self.assertIsNotNone(lot) + self.assertEqual(float(lot.inbound_qty), 0) + self.assertEqual(float(lot.remaining_qty), 0) + self.assertEqual(float(balance.qty_on_hand), 0) + self.assertEqual(float(txn.qty_change), 0) + self.assertEqual(float(lot.inbound_weight_kg), 500) + + def test_import_opening_inventory_generates_global_raw_lot_numbers(self) -> None: + from app.api.routes.inventory import import_opening_inventory_workbook + + content = self._workbook_bytes( + [ + [ + "原材料库", + "原材料仓", + "原料主库位", + "原材料00001", + "304不锈钢", + "用户手填RAW-001", + 0, + 500, + 4.2, + "初始化清点", + "期初原材料1", + ], + [ + "原材料库", + "原材料仓", + "原料主库位", + "原材料00001", + "304不锈钢", + "用户手填RAW-002", + 0, + 600, + 4.2, + "初始化清点", + "期初原材料2", + ], + ] + ) + + import_opening_inventory_workbook(self.db, "RAW", content, user_id=1) + + lot_nos = self.db.scalars( + select(StockLot.lot_no) + .where( + StockLot.item_id == self.raw_item.id, + StockLot.source_doc_type == "OPENING_STOCK", + ) + .order_by(StockLot.id) + ).all() + + self.assertEqual(lot_nos, ["YL0001", "YL0002"]) + + def test_import_opening_inventory_accepts_raw_weight_only_template_without_quantity(self) -> None: + from app.api.routes.inventory import import_opening_inventory_workbook + + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "原材料库期初导入" + worksheet.append(["仓库类型", "仓库名称", "库位", "物料编码", "物料名称", "批次号", "重量(kg)", "单价", "来源说明", "备注"]) + worksheet.append(["原材料库", "原材料仓", "原料主库位", "原材料00001", "304不锈钢", "OPEN-RAW-WEIGHT-ONLY", 600, 4.2, "初始化清点", "无数量列"]) + output = BytesIO() + workbook.save(output) + + import_opening_inventory_workbook(self.db, "RAW", output.getvalue(), user_id=1) + + lot = self.db.scalar(select(StockLot).where(StockLot.lot_no == "YL0001")) + balance = self.db.scalar(select(StockBalance).where(StockBalance.item_id == self.raw_item.id)) + + self.assertIsNotNone(lot) + self.assertEqual(float(lot.inbound_qty), 0) + self.assertEqual(float(lot.remaining_qty), 0) + self.assertEqual(float(lot.inbound_weight_kg), 600) + self.assertEqual(float(balance.qty_on_hand), 0) + self.assertEqual(float(balance.weight_on_hand_kg), 600) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_product_spec_code_generation.py b/backend/tests/test_product_spec_code_generation.py new file mode 100644 index 0000000..c9dd27e --- /dev/null +++ b/backend/tests/test_product_spec_code_generation.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import unittest +from datetime import UTC, datetime +from decimal import Decimal + +from sqlalchemy import BigInteger, create_engine +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +from app.api.routes import master_data # noqa: E402 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Item # noqa: E402 +from app.models.miniapp import MiniAppProduct # noqa: E402 +from app.schemas.database import ProductCreate # noqa: E402 + + +class ProductSpecCodeGenerationTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + + def tearDown(self) -> None: + self.db.close() + + def _payload(self) -> ProductCreate: + return ProductCreate( + item_name="测试支架", + specification=None, + drawing_no=None, + product_family=None, + version_no="V1.0", + unit_weight_kg=1.2, + allowed_scrap_rate=0, + standard_output_qty=0, + quality_standard=None, + safety_stock_weight_kg=0, + status="ACTIVE", + ) + + def _add_item(self, code: str, item_type: str = "FINISHED_GOOD") -> None: + now = datetime.now(UTC) + self.db.add( + Item( + item_code=code, + item_name=f"测试产品{code}", + item_type=item_type, + specification=None, + material_grade=None, + unit_weight_kg=Decimal("1.000000"), + safety_stock_weight_kg=Decimal("0"), + scrap_sale_price=Decimal("0"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + ) + self.db.commit() + + def test_product_spec_code_starts_from_named_five_digit_sequence(self) -> None: + self.assertEqual(master_data._build_product_code(self.db, self._payload()), "产品需规00001") + + def test_product_spec_code_increments_matching_codes_only(self) -> None: + self._add_item("产品需规00001") + self._add_item("FG-TEST-001") + self._add_item("PRJ-001") + + self.assertEqual(master_data._build_product_code(self.db, self._payload()), "产品需规00002") + + def test_product_spec_code_ignores_legacy_non_matching_codes(self) -> None: + self._add_item("FG-LAC-51012170-001") + self._add_item("LAC-51012170") + + self.assertEqual(master_data._build_product_code(self.db, self._payload()), "产品需规00001") + + def test_product_spec_code_skips_same_code_used_by_other_item_types(self) -> None: + self._add_item("产品需规00001") + self._add_item("产品需规00002", item_type="RAW_MATERIAL") + + self.assertEqual(master_data._build_product_code(self.db, self._payload()), "产品需规00003") + + def test_miniapp_created_product_uses_product_spec_code_rule(self) -> None: + row = MiniAppProduct( + attendance_point_name="测试考勤点", + project_no="XCX-PRJ-001", + product_name="小程序同步支架", + device_no="", + process_name="1", + profile_no="PF-001", + product_net_weight_kg=Decimal("1.2000"), + scrap_loss_rate=Decimal("0.0100"), + ) + + product, item = master_data._create_product_from_miniapp_group(self.db, [row]) + + self.assertIsNotNone(product) + self.assertIsNotNone(item) + self.assertEqual(item.item_code, "产品需规00001") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_product_spec_miniapp_material_sync.py b/backend/tests/test_product_spec_miniapp_material_sync.py new file mode 100644 index 0000000..0410849 --- /dev/null +++ b/backend/tests/test_product_spec_miniapp_material_sync.py @@ -0,0 +1,510 @@ +from __future__ import annotations + +import unittest +from decimal import Decimal + +from sqlalchemy import BigInteger, create_engine, func, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.api.routes import master_data # noqa: E402 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Bom, BomItem, Item, Material, Product # noqa: E402 +from app.models.miniapp import MiniAppAttendancePoint, MiniAppProduct # noqa: E402 +from app.models.operations import Process, ProcessRoute, ProcessRouteOperation, WorkCenter # noqa: E402 +from app.models.org import Department # noqa: E402 +from app.schemas.database import MiniAppProductUpsert, ProductUpdate # noqa: E402 + + +class MiniAppProductMaterialSyncTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self._seed_required_master_data() + + def tearDown(self) -> None: + self.db.close() + + def _seed_required_master_data(self) -> None: + self.db.add_all( + [ + Department(id=1, dept_code="D-TEST", dept_name="测试车间", dept_type="WORKSHOP", status="ACTIVE"), + Process(id=1, process_code="PROC-1", process_name="1", process_type="STAMPING", status="ACTIVE"), + WorkCenter( + id=1, + center_code="WC-1", + center_name="测试工作中心", + dept_id=1, + center_type="PRESS", + status="ACTIVE", + ), + MiniAppAttendancePoint(name="测试考勤点", is_active=True), + ] + ) + self.db.commit() + + def _add_miniapp_product(self, *, material_code: str | None, material_name: str | None) -> MiniAppProduct: + row = MiniAppProduct( + attendance_point_name="测试考勤点", + project_no="PRJ-001", + product_name="测试支架", + device_no="", + process_name="1", + profile_no="PF-001", + material_code=material_code, + material_name=material_name, + supplier="小程序供应商", + product_net_weight_kg=Decimal("1.2000"), + product_gross_weight_kg=Decimal("1.5000"), + scrap_loss_rate=Decimal("0.020000"), + waste_price_yuan_per_kg=Decimal("3.5000"), + stamping_method="冲压", + operator_count=Decimal("1"), + standard_beat=Decimal("12.000"), + standard_workload=Decimal("2400.00"), + ) + self.db.add(row) + self.db.commit() + return row + + def _add_material(self) -> Item: + item = Item( + item_code="MAT-ERP-001", + item_name="ERP原材料", + item_type="RAW_MATERIAL", + specification="1.0*100", + material_grade="SPCC", + unit_weight_kg=Decimal("1.000000"), + safety_stock_weight_kg=Decimal("0"), + scrap_sale_price=Decimal("2.5000"), + status="ACTIVE", + ) + self.db.add(item) + self.db.flush() + self.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=None, + ) + ) + self.db.commit() + return item + + def test_miniapp_material_not_in_erp_does_not_block_product_spec_sync(self) -> None: + self._add_miniapp_product(material_code="WX-MAT-001", material_name="小程序自由物料") + + result = master_data._sync_miniapp_product_specs_to_master_data(self.db) + self.db.commit() + + self.assertEqual(result["created"], 1) + self.assertEqual(result["matched"], 1) + self.assertEqual(self.db.scalar(select(func.count(Item.id)).where(Item.item_type == "RAW_MATERIAL")), 0) + self.assertEqual(self.db.scalar(select(func.count(Bom.id))), 0) + self.assertEqual(self.db.scalar(select(func.count(ProcessRoute.id))), 1) + + miniapp_row = self.db.get(MiniAppProduct, ("测试考勤点", "PRJ-001", "测试支架", "", "1")) + self.assertEqual(miniapp_row.material_code, "WX-MAT-001") + self.assertEqual(miniapp_row.material_name, "小程序自由物料") + + def test_miniapp_material_matching_erp_material_creates_default_bom(self) -> None: + material = self._add_material() + self._add_miniapp_product(material_code=material.item_code, material_name=material.item_name) + + result = master_data._sync_miniapp_product_specs_to_master_data(self.db) + self.db.commit() + + self.assertEqual(result["bom_updated"], 1) + bom_item = self.db.scalar(select(BomItem).limit(1)) + self.assertIsNotNone(bom_item) + self.assertEqual(bom_item.material_item_id, material.id) + + def test_upsert_miniapp_product_preserves_erp_selected_material_code_and_name(self) -> None: + payload = MiniAppProductUpsert( + attendance_point_name="测试考勤点", + project_no="ERP-PRJ-001", + product_name="ERP新建支架", + profile_no="PF-ERP", + material_code="MAT-ERP-SELECTED", + material_name="ERP选择原材料", + supplier=None, + product_net_weight_kg=1.2, + product_gross_weight_kg=1.5, + allowed_scrap_rate=0.02, + waste_price_yuan_per_kg=3.5, + device_no="", + process_name="1", + stamping_method="冲压", + operator_count=1, + standard_beat=12, + standard_workload=2400, + ) + + row = master_data._upsert_miniapp_product(self.db, payload) + self.db.commit() + + self.assertEqual(row.material_code, "MAT-ERP-SELECTED") + self.assertEqual(row.material_name, "ERP选择原材料") + + def test_partial_miniapp_route_sync_does_not_deactivate_erp_authored_operations(self) -> None: + product_item = Item( + item_code="产品需规00001", + item_name="测试支架", + item_type="FINISHED_GOOD", + specification=None, + material_grade=None, + unit_weight_kg=Decimal("1.000000"), + safety_stock_weight_kg=Decimal("0"), + scrap_sale_price=Decimal("0"), + status="ACTIVE", + ) + self.db.add(product_item) + self.db.flush() + route = ProcessRoute( + route_code="ROUTE-001", + route_name="测试路线", + product_item_id=product_item.id, + version_no="V1.0", + is_default=1, + status="ACTIVE", + effective_date=None, + expire_date=None, + remark=None, + ) + self.db.add(route) + self.db.flush() + operations = [] + for seq_no, status in [(1, "ACTIVE"), (2, "ACTIVE"), (3, "INACTIVE")]: + operation = ProcessRouteOperation( + route_id=route.id, + seq_no=seq_no, + process_id=1, + work_center_id=1, + operation_name=str(seq_no), + std_setup_minutes=Decimal("0"), + std_cycle_seconds=Decimal("10"), + std_labor_cost=Decimal("0"), + std_machine_cost=Decimal("0"), + std_outsource_cost=Decimal("0"), + allowed_scrap_rate=Decimal("0"), + report_required=1, + is_key_operation=0, + status=status, + remark=None, + ) + self.db.add(operation) + operations.append(operation) + self.db.commit() + + changed = master_data._sync_miniapp_route_operations( + self.db, + route, + operations, + [ + { + "seq_no": 1, + "process_id": 1, + "work_center_id": 1, + "operation_name": "1", + "std_cycle_seconds": Decimal("10"), + "allowed_scrap_rate": Decimal("0"), + } + ], + ) + self.db.flush() + + self.assertTrue(changed) + self.assertEqual(operations[0].status, "ACTIVE") + self.assertEqual(operations[1].status, "ACTIVE") + self.assertEqual(operations[2].status, "ACTIVE") + + def test_miniapp_owned_missing_operation_is_deactivated(self) -> None: + product_item = Item( + item_code="产品需规00002", + item_name="测试支架2", + item_type="FINISHED_GOOD", + specification=None, + material_grade=None, + unit_weight_kg=Decimal("1.000000"), + safety_stock_weight_kg=Decimal("0"), + scrap_sale_price=Decimal("0"), + status="ACTIVE", + ) + self.db.add(product_item) + self.db.flush() + route = ProcessRoute( + route_code="ROUTE-002", + route_name="测试路线2", + product_item_id=product_item.id, + version_no="V1.0", + is_default=1, + status="ACTIVE", + effective_date=None, + expire_date=None, + remark=master_data.MINIAPP_SYNC_REMARK, + ) + self.db.add(route) + self.db.flush() + operation = ProcessRouteOperation( + route_id=route.id, + seq_no=2, + process_id=1, + work_center_id=1, + operation_name="2", + std_setup_minutes=Decimal("0"), + std_cycle_seconds=Decimal("10"), + std_labor_cost=Decimal("0"), + std_machine_cost=Decimal("0"), + std_outsource_cost=Decimal("0"), + allowed_scrap_rate=Decimal("0"), + report_required=1, + is_key_operation=0, + status="ACTIVE", + remark=master_data.MINIAPP_SYNC_REMARK, + ) + self.db.add(operation) + self.db.commit() + + master_data._sync_miniapp_route_operations(self.db, route, [operation], []) + self.db.flush() + + self.assertEqual(operation.status, "INACTIVE") + + def test_stale_product_with_only_miniapp_owned_operations_is_deactivated(self) -> None: + product_item = Item( + item_code="产品需规00003", + item_name="保温杯", + item_type="FINISHED_GOOD", + specification=None, + material_grade=None, + unit_weight_kg=Decimal("1.000000"), + safety_stock_weight_kg=Decimal("0"), + scrap_sale_price=Decimal("0"), + status="ACTIVE", + ) + self.db.add(product_item) + self.db.flush() + product = Product( + item_id=product_item.id, + drawing_no=None, + product_family=None, + version_no="V1.0", + standard_output_qty=Decimal("0"), + allowed_scrap_rate=Decimal("0"), + quality_standard=None, + ) + self.db.add(product) + route = ProcessRoute( + route_code="ROUTE-003", + route_name="保温杯默认工艺路线", + product_item_id=product_item.id, + version_no="V1.0", + is_default=1, + status="ACTIVE", + effective_date=None, + expire_date=None, + remark=None, + ) + self.db.add(route) + self.db.flush() + operation = ProcessRouteOperation( + route_id=route.id, + seq_no=1, + process_id=1, + work_center_id=1, + operation_name="1", + std_setup_minutes=Decimal("0"), + std_cycle_seconds=Decimal("10"), + std_labor_cost=Decimal("0"), + std_machine_cost=Decimal("0"), + std_outsource_cost=Decimal("0"), + allowed_scrap_rate=Decimal("0"), + report_required=1, + is_key_operation=0, + status="ACTIVE", + remark=master_data.MINIAPP_SYNC_REMARK, + ) + self.db.add(operation) + self.db.commit() + + result = master_data._deactivate_stale_miniapp_product_specs_from_master_data(self.db, []) + self.db.flush() + + self.assertEqual(result["deactivated"], 1) + self.assertEqual(product_item.status, "INACTIVE") + self.assertEqual(route.status, "INACTIVE") + self.assertEqual(operation.status, "INACTIVE") + + def test_process_route_read_includes_miniapp_stamping_method(self) -> None: + product_item = Item( + item_code="产品需规00001", + item_name="测试支架", + item_type="FINISHED_GOOD", + unit_weight_kg=Decimal("1.000000"), + safety_stock_weight_kg=Decimal("0"), + scrap_sale_price=Decimal("0"), + status="ACTIVE", + ) + self.db.add(product_item) + self.db.flush() + product = Product( + item_id=product_item.id, + drawing_no="PRJ-001", + product_family="PRJ-001", + version_no="V1.0", + standard_output_qty=Decimal("0"), + allowed_scrap_rate=Decimal("0"), + quality_standard=None, + ) + route = ProcessRoute( + route_code="ROUTE-004", + route_name="测试支架默认工艺路线", + product_item_id=product_item.id, + version_no="V1.0", + is_default=1, + status="ACTIVE", + effective_date=None, + expire_date=None, + remark=master_data.MINIAPP_SYNC_REMARK, + ) + self.db.add_all([product, route]) + self.db.flush() + operation = ProcessRouteOperation( + route_id=route.id, + seq_no=1, + process_id=1, + work_center_id=1, + operation_name="1", + std_setup_minutes=Decimal("0"), + std_cycle_seconds=Decimal("10"), + std_labor_cost=Decimal("0"), + std_machine_cost=Decimal("0"), + std_outsource_cost=Decimal("0"), + allowed_scrap_rate=Decimal("0"), + report_required=1, + is_key_operation=0, + status="ACTIVE", + remark=master_data.MINIAPP_SYNC_REMARK, + ) + miniapp_product = MiniAppProduct( + attendance_point_name="测试考勤点", + project_no="PRJ-001", + product_name="测试支架", + device_no="", + process_name="1", + profile_no="PF-001", + material_code="原材料00001", + material_name="钢板", + supplier="测试供应商", + product_net_weight_kg=Decimal("1.0000"), + product_gross_weight_kg=Decimal("1.2000"), + scrap_loss_rate=Decimal("0.020000"), + waste_price_yuan_per_kg=Decimal("3.5000"), + stamping_method="切边冲孔", + operator_count=Decimal("1"), + standard_beat=Decimal("10.000"), + standard_workload=Decimal("2400.00"), + ) + self.db.add_all([operation, miniapp_product]) + self.db.commit() + + rows = master_data._list_process_route_reads(self.db, limit=10) + target = next(row for row in rows if row.route_id == route.id) + + self.assertEqual(target.operations[0].operation_name, "1") + self.assertEqual(target.operations[0].stamping_method, "切边冲孔") + + def test_product_update_allows_existing_bom_and_route_without_downstream_usage(self) -> None: + product_item = Item( + item_code="产品需规00004", + item_name="钢制碗", + item_type="FINISHED_GOOD", + specification=None, + material_grade=None, + unit_weight_kg=Decimal("0.500000"), + safety_stock_weight_kg=Decimal("0"), + scrap_sale_price=Decimal("0"), + status="ACTIVE", + ) + self.db.add(product_item) + self.db.flush() + product = Product( + item_id=product_item.id, + drawing_no=None, + product_family=None, + version_no="V1.0", + standard_output_qty=Decimal("0"), + allowed_scrap_rate=Decimal("0.03"), + quality_standard=None, + ) + self.db.add(product) + self.db.add( + Bom( + bom_code="BOM-00004", + product_item_id=product_item.id, + version_no="V1.0", + is_default=1, + status="ACTIVE", + effective_date=None, + expire_date=None, + remark=master_data.MINIAPP_SYNC_REMARK, + ) + ) + self.db.add( + ProcessRoute( + route_code="ROUTE-004", + route_name="钢制碗默认工艺路线", + product_item_id=product_item.id, + version_no="V1.0", + is_default=1, + status="ACTIVE", + effective_date=None, + expire_date=None, + remark=master_data.MINIAPP_SYNC_REMARK, + ) + ) + self.db.commit() + + updated = master_data.update_product( + product_item.id, + ProductUpdate( + item_name="钢制碗", + specification=None, + drawing_no=None, + product_family=None, + version_no="V1.0", + unit_weight_kg=0.5, + allowed_scrap_rate=0.03, + standard_output_qty=0, + quality_standard=None, + safety_stock_weight_kg=0, + status="ACTIVE", + ), + db=self.db, + ) + + self.assertEqual(updated.item_id, product_item.id) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_production_batch_ledger.py b/backend/tests/test_production_batch_ledger.py new file mode 100644 index 0000000..d90f8de --- /dev/null +++ b/backend/tests/test_production_batch_ledger.py @@ -0,0 +1,371 @@ +from __future__ import annotations + +import unittest +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Bom, BomItem, Item, Warehouse # noqa: E402 +from app.models.operations import ProductionBatchLedger, ProductionBatchLedgerTxn, StockLot # noqa: E402 + + +class ProductionBatchLedgerModelTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self.now = datetime(2026, 6, 8, 8, 30) + + def tearDown(self) -> None: + self.db.close() + + def _seed_basic_items_and_lot(self) -> None: + raw_item = Item( + id=1, + item_code="RM-001", + item_name="冷轧钢板", + item_type="RAW_MATERIAL", + unit_weight_kg=Decimal("1"), + status="ACTIVE", + created_at=self.now, + updated_at=self.now, + ) + product = Item( + id=2, + item_code="FG-001", + item_name="钢制碗", + item_type="FINISHED_GOOD", + unit_weight_kg=Decimal("0.5"), + status="ACTIVE", + created_at=self.now, + updated_at=self.now, + ) + warehouse = Warehouse( + id=1, + warehouse_code="WH-RAW", + warehouse_name="原材料库", + warehouse_type="RAW", + status="ACTIVE", + created_at=self.now, + updated_at=self.now, + ) + lot = StockLot( + id=1, + lot_no="YL0001", + lot_role="RAW_MATERIAL", + item_id=1, + warehouse_id=1, + source_doc_type="期初入库", + source_doc_id=1, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("500"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("300"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("4"), + quality_status="PASS", + status="AVAILABLE", + created_at=self.now, + updated_at=self.now, + ) + self.db.add_all([raw_item, product, warehouse, lot]) + self.db.flush() + + def _seed_basic_items_bom_and_lot(self) -> None: + self._seed_basic_items_and_lot() + bom = Bom( + id=1, + bom_code="BOM-001", + product_item_id=2, + version_no="V1.0", + is_default=1, + status="ACTIVE", + created_at=self.now, + updated_at=self.now, + ) + bom_item = BomItem( + id=1, + bom_id=1, + seq_no=1, + material_item_id=1, + usage_type="WEIGHT", + gross_weight_per_piece_kg=Decimal("0.6"), + net_weight_per_piece_kg=Decimal("0.5"), + loss_rate=Decimal("0"), + created_at=self.now, + updated_at=self.now, + ) + self.db.add_all([bom, bom_item]) + self.db.flush() + + def _seed_ledger( + self, + *, + total_issued_weight_kg: Decimal, + outside_weight_kg: Decimal | None = None, + ) -> ProductionBatchLedger: + ledger = ProductionBatchLedger( + material_lot_id=1, + material_lot_no="YL0001", + material_item_id=1, + product_item_id=2, + status="在生产", + miniapp_selectable=1, + total_issued_weight_kg=total_issued_weight_kg, + outside_weight_kg=outside_weight_kg if outside_weight_kg is not None else total_issued_weight_kg, + finished_inbound_qty=Decimal("0"), + surplus_inbound_weight_kg=Decimal("0"), + scrap_inbound_weight_kg=Decimal("0"), + writeoff_weight_kg=Decimal("0"), + first_issue_time=self.now, + last_issue_time=self.now, + created_at=self.now, + updated_at=self.now, + ) + self.db.add(ledger) + self.db.flush() + return ledger + + def test_models_persist_chinese_status_and_business_key(self) -> None: + raw_item = Item( + id=1, + item_code="RM-001", + item_name="冷轧钢板", + item_type="RAW_MATERIAL", + unit_weight_kg=Decimal("1"), + status="ACTIVE", + created_at=self.now, + updated_at=self.now, + ) + product = Item( + id=2, + item_code="FG-001", + item_name="钢制碗", + item_type="FINISHED_GOOD", + unit_weight_kg=Decimal("0.5"), + status="ACTIVE", + created_at=self.now, + updated_at=self.now, + ) + warehouse = Warehouse( + id=1, + warehouse_code="WH-RAW", + warehouse_name="原材料库", + warehouse_type="RAW", + status="ACTIVE", + created_at=self.now, + updated_at=self.now, + ) + lot = StockLot( + id=1, + lot_no="YL0001", + lot_role="RAW_MATERIAL", + item_id=1, + warehouse_id=1, + source_doc_type="期初入库", + source_doc_id=1, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("500"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("300"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("4"), + quality_status="PASS", + status="AVAILABLE", + created_at=self.now, + updated_at=self.now, + ) + self.db.add_all([raw_item, product, warehouse, lot]) + self.db.flush() + + ledger = ProductionBatchLedger( + material_lot_id=1, + material_lot_no="YL0001", + material_item_id=1, + product_item_id=2, + status="在生产", + miniapp_selectable=1, + total_issued_weight_kg=Decimal("200"), + outside_weight_kg=Decimal("200"), + finished_inbound_qty=Decimal("0"), + surplus_inbound_weight_kg=Decimal("0"), + scrap_inbound_weight_kg=Decimal("0"), + writeoff_weight_kg=Decimal("0"), + first_issue_time=self.now, + last_issue_time=self.now, + created_at=self.now, + updated_at=self.now, + ) + self.db.add(ledger) + self.db.flush() + self.db.add( + ProductionBatchLedgerTxn( + production_ledger_id=ledger.id, + txn_type="生产出库", + qty_delta=Decimal("0"), + weight_delta_kg=Decimal("200"), + outside_weight_after_kg=Decimal("200"), + source_doc_type="库存流水", + source_doc_id=10, + biz_time=self.now, + operator_user_id=1, + remark="生产出库累计到生产台账", + created_at=self.now, + updated_at=self.now, + ) + ) + self.db.commit() + + saved = self.db.scalar(select(ProductionBatchLedger).where(ProductionBatchLedger.material_lot_no == "YL0001")) + self.assertIsNotNone(saved) + self.assertEqual(saved.status, "在生产") + self.assertEqual(saved.miniapp_selectable, 1) + self.assertEqual(float(saved.outside_weight_kg), 200) + txns = self.db.scalars(select(ProductionBatchLedgerTxn)).all() + self.assertEqual(txns[0].txn_type, "生产出库") + + def test_record_issue_accumulates_same_lot_and_product(self) -> None: + from app.services.production_batch_ledger import record_production_issue_to_ledger + + self._seed_basic_items_and_lot() + first = record_production_issue_to_ledger( + self.db, + material_lot_id=1, + product_item_id=2, + issued_weight_kg=Decimal("120"), + operator_user_id=1, + source_doc_type="库存流水", + source_doc_id=100, + source_line_id=1001, + biz_time=datetime(2026, 6, 8, 9, 0), + remark="第一次出库", + ) + second = record_production_issue_to_ledger( + self.db, + material_lot_id=1, + product_item_id=2, + issued_weight_kg=Decimal("80"), + operator_user_id=1, + source_doc_type="库存流水", + source_doc_id=101, + source_line_id=1002, + biz_time=datetime(2026, 6, 8, 14, 0), + remark="第二次出库", + ) + self.db.commit() + + self.assertEqual(first.id, second.id) + self.assertEqual(second.status, "在生产") + self.assertEqual(float(second.total_issued_weight_kg), 200) + self.assertEqual(float(second.outside_weight_kg), 200) + txns = self.db.scalars(select(ProductionBatchLedgerTxn).order_by(ProductionBatchLedgerTxn.id)).all() + self.assertEqual([row.txn_type for row in txns], ["生产出库", "生产出库"]) + + def test_lock_and_reopen_same_lot_product(self) -> None: + from app.services.production_batch_ledger import lock_production_batch_ledger, record_production_issue_to_ledger + + self._seed_basic_items_and_lot() + ledger = record_production_issue_to_ledger( + self.db, + material_lot_id=1, + product_item_id=2, + issued_weight_kg=Decimal("200"), + operator_user_id=1, + source_doc_type="库存流水", + source_doc_id=100, + source_line_id=1001, + biz_time=datetime(2026, 6, 8, 9, 0), + remark="生产出库", + ) + locked = lock_production_batch_ledger( + self.db, + production_ledger_id=ledger.id, + operator_user_id=1, + biz_time=datetime(2026, 6, 8, 18, 0), + remark="现场确认该批材料已无库外余量", + ) + self.assertEqual(locked.status, "锁单") + self.assertEqual(locked.miniapp_selectable, 0) + self.assertEqual(float(locked.outside_weight_kg), 0) + self.assertEqual(float(locked.writeoff_weight_kg), 200) + + reopened = record_production_issue_to_ledger( + self.db, + material_lot_id=1, + product_item_id=2, + issued_weight_kg=Decimal("50"), + operator_user_id=1, + source_doc_type="库存流水", + source_doc_id=102, + source_line_id=1003, + biz_time=datetime(2026, 6, 9, 9, 0), + remark="继续出库生产", + ) + + self.assertEqual(reopened.id, locked.id) + self.assertEqual(reopened.status, "在生产") + self.assertEqual(reopened.miniapp_selectable, 1) + self.assertEqual(float(reopened.total_issued_weight_kg), 250) + self.assertEqual(float(reopened.outside_weight_kg), 50) + txns = self.db.scalars(select(ProductionBatchLedgerTxn).order_by(ProductionBatchLedgerTxn.id)).all() + self.assertEqual([row.txn_type for row in txns], ["生产出库", "结单核销", "该批材料结单", "重新开工", "生产出库"]) + + def test_disabled_mode_calculates_from_finished_inbound_and_bom(self) -> None: + from app.services.production_batch_ledger import calculate_production_batch_ledger_snapshot + + self._seed_basic_items_bom_and_lot() + ledger = self._seed_ledger(total_issued_weight_kg=Decimal("200")) + ledger.finished_inbound_qty = Decimal("100") + snapshot = calculate_production_batch_ledger_snapshot(self.db, ledger.id, smart_reporting_enabled=False) + + self.assertEqual(float(snapshot.reference_finished_qty), 333) + self.assertEqual(float(snapshot.used_material_weight_kg), 60) + self.assertEqual(float(snapshot.theoretical_scrap_weight_kg), 10) + self.assertEqual(float(snapshot.theoretical_surplus_weight_kg), 140) + + def test_lock_writes_off_remaining_outside_weight_after_inbounds(self) -> None: + from app.services.production_batch_ledger import lock_production_batch_ledger, post_finished_inbound_to_ledger + + self._seed_basic_items_bom_and_lot() + ledger = self._seed_ledger(total_issued_weight_kg=Decimal("200"), outside_weight_kg=Decimal("200")) + post_finished_inbound_to_ledger( + self.db, + production_ledger_id=ledger.id, + finished_qty=Decimal("100"), + operator_user_id=1, + source_doc_type="成品入库", + source_doc_id=1, + source_line_id=1, + biz_time=datetime(2026, 6, 8, 12, 0), + remark="成品入库", + ) + locked = lock_production_batch_ledger( + self.db, + production_ledger_id=ledger.id, + operator_user_id=1, + biz_time=datetime(2026, 6, 8, 18, 0), + remark="该批材料结单", + ) + self.assertEqual(locked.status, "锁单") + self.assertEqual(float(locked.outside_weight_kg), 0) + self.assertEqual(float(locked.finished_inbound_qty), 100) diff --git a/backend/tests/test_production_warehouse_document_archive.py b/backend/tests/test_production_warehouse_document_archive.py new file mode 100644 index 0000000..2e1f75a --- /dev/null +++ b/backend/tests/test_production_warehouse_document_archive.py @@ -0,0 +1,606 @@ +from __future__ import annotations + +import unittest +from datetime import datetime +from decimal import Decimal +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_big_integer_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.document_archive # noqa: E402,F401 +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.document_archive import DocumentArchive # noqa: E402 +from app.models.master_data import Item, Warehouse # noqa: E402 +from app.models.operations import CompletionReceipt, InventoryTxn, ProductionBatchLedger, ProductionBatchLedgerTxn, StockLot, WorkOrder # noqa: E402 +from app.api.routes.inventory import _should_archive_production_inbound # noqa: E402 +from app.api.routes.production import _production_ledger_txn_read, list_production_batch_ledger # noqa: E402 +from app.services.document_archives import ( # noqa: E402 + ARCHIVE_STATUS_READY, + DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, + collect_production_inbound_settlement_archive_context, + collect_production_material_out_archive_context, + generate_document_archive, +) + + +class ProductionWarehouseDocumentArchiveTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self.now = datetime(2026, 6, 11, 9, 0, 0) + self.biz_time = datetime(2026, 6, 11, 10, 30, 0) + self._seed_archive_context_data() + + def tearDown(self) -> None: + self.db.close() + + def _seed_archive_context_data(self) -> None: + self.raw_warehouse = Warehouse( + id=1, + warehouse_code="RAW", + warehouse_name="原材料库", + warehouse_type="RAW", + status="ACTIVE", + created_at=self.now, + updated_at=self.now, + ) + self.finished_warehouse = Warehouse( + id=2, + warehouse_code="FINISHED", + warehouse_name="成品库", + warehouse_type="FINISHED", + status="ACTIVE", + created_at=self.now, + updated_at=self.now, + ) + self.scrap_warehouse = Warehouse( + id=3, + warehouse_code="SCRAP", + warehouse_name="废料库", + warehouse_type="SCRAP", + status="ACTIVE", + created_at=self.now, + updated_at=self.now, + ) + self.material_item = Item( + id=10, + item_code="RM-ARCHIVE-001", + item_name="测试冷轧板", + item_type="RAW_MATERIAL", + specification="1.0mm", + unit_weight_kg=Decimal("1"), + status="ACTIVE", + created_at=self.now, + updated_at=self.now, + ) + self.product_item = Item( + id=20, + item_code="FG-ARCHIVE-001", + item_name="测试钢制碗", + item_type="FINISHED_GOOD", + specification="碗型", + unit_weight_kg=Decimal("0.4"), + status="ACTIVE", + created_at=self.now, + updated_at=self.now, + ) + self.raw_lot = StockLot( + id=100, + lot_no="YL0001", + lot_role="RAW_MATERIAL", + item_id=self.material_item.id, + warehouse_id=self.raw_warehouse.id, + source_doc_type="期初入库", + source_doc_id=1, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("500"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("300"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("6.6"), + quality_status="PASS", + status="AVAILABLE", + created_at=self.now, + updated_at=self.now, + ) + self.ledger = ProductionBatchLedger( + id=200, + material_lot_id=self.raw_lot.id, + material_lot_no="YL0001", + material_item_id=self.material_item.id, + product_item_id=self.product_item.id, + status="在生产", + miniapp_selectable=1, + total_issued_weight_kg=Decimal("200"), + outside_weight_kg=Decimal("80"), + finished_inbound_qty=Decimal("198"), + surplus_inbound_weight_kg=Decimal("60"), + scrap_inbound_weight_kg=Decimal("2"), + writeoff_weight_kg=Decimal("0"), + first_issue_time=self.biz_time, + last_issue_time=self.biz_time, + created_at=self.now, + updated_at=self.now, + ) + self.issue_txn = InventoryTxn( + id=400, + txn_no="TXN-OUT-001", + txn_type="PRODUCTION_OUT", + item_id=self.material_item.id, + warehouse_id=self.raw_warehouse.id, + lot_id=self.raw_lot.id, + qty_change=Decimal("0"), + weight_change_kg=Decimal("-200"), + unit_cost=Decimal("6.6"), + amount=Decimal("1320.00"), + source_doc_type="生产台账", + source_doc_id=self.ledger.id, + biz_time=self.biz_time, + remark="生产领料出库", + created_at=self.now, + updated_at=self.now, + ) + self.ledger_txn = ProductionBatchLedgerTxn( + id=300, + production_ledger_id=self.ledger.id, + txn_type="生产出库", + qty_delta=Decimal("0"), + weight_delta_kg=Decimal("200"), + outside_weight_after_kg=Decimal("80"), + source_doc_type="库存流水", + source_doc_id=self.issue_txn.id, + biz_time=self.biz_time, + operator_user_id=1, + remark="生产出库累计到生产台账", + created_at=self.now, + updated_at=self.now, + ) + self.finished_lot = StockLot( + id=101, + lot_no="FG0001", + lot_role="FINISHED_GOOD", + item_id=self.product_item.id, + warehouse_id=self.finished_warehouse.id, + source_doc_type="生产台账", + source_doc_id=self.ledger.id, + source_material_lot_id=self.raw_lot.id, + inbound_qty=Decimal("198"), + inbound_weight_kg=Decimal("79.2"), + remaining_qty=Decimal("198"), + remaining_weight_kg=Decimal("79.2"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("6.6667"), + quality_status="PASS", + status="AVAILABLE", + created_at=self.now, + updated_at=self.now, + ) + self.finished_txn = InventoryTxn( + id=401, + txn_no="TXN-FG-001", + txn_type="FG_IN", + item_id=self.product_item.id, + warehouse_id=self.finished_warehouse.id, + lot_id=self.finished_lot.id, + qty_change=Decimal("198"), + weight_change_kg=Decimal("79.2"), + unit_cost=Decimal("6.6667"), + amount=Decimal("1320.00"), + source_doc_type="生产台账", + source_doc_id=self.ledger.id, + biz_time=self.biz_time, + remark="偏差说明:称重复核通过;单据批次号:生产结算20260611103000000001", + created_at=self.now, + updated_at=self.now, + ) + self.surplus_txn = InventoryTxn( + id=402, + txn_no="TXN-SURPLUS-001", + txn_type="PRODUCTION_SURPLUS_IN", + item_id=self.material_item.id, + warehouse_id=self.raw_warehouse.id, + lot_id=self.raw_lot.id, + qty_change=Decimal("0"), + weight_change_kg=Decimal("60"), + unit_cost=Decimal("6.6"), + amount=Decimal("396.00"), + source_doc_type="生产台账", + source_doc_id=self.ledger.id, + biz_time=self.biz_time, + remark="偏差说明:称重复核通过;单据批次号:生产结算20260611103000000001", + created_at=self.now, + updated_at=self.now, + ) + self.db.add_all( + [ + self.raw_warehouse, + self.finished_warehouse, + self.scrap_warehouse, + self.material_item, + self.product_item, + self.raw_lot, + self.ledger, + self.issue_txn, + self.ledger_txn, + self.finished_lot, + self.finished_txn, + self.surplus_txn, + ] + ) + self.db.commit() + + def test_collect_material_out_context_uses_production_ledger(self) -> None: + context = collect_production_material_out_archive_context(self.db, self.ledger.id) + + self.assertEqual(context.document_type, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT) + self.assertEqual(context.business_id, self.ledger.id) + self.assertEqual(context.document_no, "生产领料-YL0001-测试钢制碗") + self.assertEqual(context.partner_label, "生产产品") + self.assertEqual(context.partner_name, "测试钢制碗") + self.assertEqual(context.address, "原材料库") + self.assertIn("材料库存批次号:YL0001", context.remark) + self.assertEqual(len(context.lines), 1) + line = context.lines[0] + self.assertEqual(line.item_name, "测试冷轧板") + self.assertEqual(Decimal(str(line.quantity)), Decimal("200")) + self.assertEqual(Decimal(str(line.line_amount)), Decimal("1320.00")) + + def test_collect_inbound_settlement_context_groups_same_save_action(self) -> None: + context = collect_production_inbound_settlement_archive_context(self.db, self.finished_txn.id) + + self.assertEqual(context.document_type, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT) + self.assertEqual(context.business_id, self.finished_txn.id) + self.assertEqual(context.document_no, "生产入库结算-TXN-FG-001") + self.assertEqual(context.partner_label, "生产产品") + self.assertEqual(context.partner_name, "测试钢制碗") + self.assertIn("材料库存批次号:YL0001", context.remark) + self.assertEqual(len(context.lines), 2) + first_chunks = [str(line.remark or "").split(";")[0] for line in context.lines] + self.assertEqual(first_chunks, ["入库类型:成品入库", "入库类型:生产余料入库"]) + + def test_collect_inbound_settlement_context_labels_all_related_warehouses(self) -> None: + scrap_lot = StockLot( + id=102, + lot_no="SCRAP0001", + lot_role="PRODUCTION_SCRAP", + item_id=self.material_item.id, + warehouse_id=self.scrap_warehouse.id, + source_doc_type="生产台账", + source_doc_id=self.ledger.id, + source_material_lot_id=self.raw_lot.id, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("2"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("2"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("6.6"), + quality_status="SCRAP", + status="AVAILABLE", + created_at=self.now, + updated_at=self.now, + ) + scrap_txn = InventoryTxn( + id=403, + txn_no="TXN-SCRAP-001", + txn_type="PRODUCTION_SCRAP_IN", + item_id=self.material_item.id, + warehouse_id=self.scrap_warehouse.id, + lot_id=scrap_lot.id, + qty_change=Decimal("0"), + weight_change_kg=Decimal("2"), + unit_cost=Decimal("6.6"), + amount=Decimal("13.20"), + source_doc_type="生产台账", + source_doc_id=self.ledger.id, + biz_time=self.biz_time, + remark="偏差说明:称重复核通过;单据批次号:生产结算20260611103000000001", + created_at=self.now, + updated_at=self.now, + ) + self.db.add_all([scrap_lot, scrap_txn]) + self.db.commit() + + context = collect_production_inbound_settlement_archive_context(self.db, self.finished_txn.id) + + self.assertEqual(context.address_label, "涉及仓库") + self.assertEqual(context.address, "成品库、原材料库、废料库") + self.assertIn("涉及仓库:成品库、原材料库、废料库", context.remark) + self.assertEqual([str(line.remark or "").split(";")[0] for line in context.lines], ["入库类型:成品入库", "入库类型:生产余料入库", "入库类型:生产废料入库"]) + + def test_collect_work_order_inbound_settlement_groups_receipt_and_work_order_txns(self) -> None: + work_order = WorkOrder( + id=500, + work_order_no="WO-ARCHIVE-001", + work_order_type="正常生产", + product_item_id=self.product_item.id, + route_id=1, + bom_id=1, + planned_qty=Decimal("100"), + released_qty=Decimal("100"), + finished_qty=Decimal("0"), + scrap_qty=Decimal("0"), + priority_level=3, + status="RELEASED", + created_at=self.now, + updated_at=self.now, + ) + receipt = CompletionReceipt( + id=600, + receipt_no="FGI-ARCHIVE-001", + work_order_id=work_order.id, + warehouse_id=self.finished_warehouse.id, + receipt_time=self.biz_time, + status="POSTED", + remark="旧工单入库", + created_at=self.now, + updated_at=self.now, + ) + finished_txn = InventoryTxn( + id=601, + txn_no="TXN-WO-FG-001", + txn_type="FG_IN", + item_id=self.product_item.id, + warehouse_id=self.finished_warehouse.id, + lot_id=self.finished_lot.id, + qty_change=Decimal("50"), + weight_change_kg=Decimal("20"), + unit_cost=Decimal("6.6"), + amount=Decimal("330.00"), + source_doc_type="FG_RECEIPT", + source_doc_id=receipt.id, + biz_time=self.biz_time, + remark="旧工单成品入库", + created_at=self.now, + updated_at=self.now, + ) + surplus_txn = InventoryTxn( + id=602, + txn_no="TXN-WO-SURPLUS-001", + txn_type="PRODUCTION_SURPLUS_IN", + item_id=self.material_item.id, + warehouse_id=self.raw_warehouse.id, + lot_id=self.raw_lot.id, + qty_change=Decimal("0"), + weight_change_kg=Decimal("12"), + unit_cost=Decimal("6.6"), + amount=Decimal("79.20"), + source_doc_type="WORK_ORDER", + source_doc_id=work_order.id, + biz_time=self.biz_time, + remark="旧工单余料入库", + created_at=self.now, + updated_at=self.now, + ) + self.db.add_all([work_order, receipt, finished_txn, surplus_txn]) + self.db.commit() + + context = collect_production_inbound_settlement_archive_context(self.db, finished_txn.id) + + self.assertEqual(context.partner_name, "测试钢制碗") + self.assertEqual(len(context.lines), 2) + self.assertEqual([line.remark.split(";")[0] for line in context.lines], ["入库类型:成品入库", "入库类型:生产余料入库"]) + + def test_generic_finished_inbound_without_production_source_does_not_archive(self) -> None: + self.assertFalse( + _should_archive_production_inbound( + "PRODUCTION_FINISHED", + production_ledger_id=None, + production_work_order_id=None, + ) + ) + self.assertTrue( + _should_archive_production_inbound( + "PRODUCTION_FINISHED", + production_ledger_id=self.ledger.id, + production_work_order_id=None, + ) + ) + self.assertTrue( + _should_archive_production_inbound( + "PRODUCTION_FINISHED", + production_ledger_id=None, + production_work_order_id=500, + ) + ) + + def test_production_out_ledger_txn_does_not_expose_inbound_settlement_archive(self) -> None: + row = _production_ledger_txn_read(self.ledger_txn) + + self.assertEqual(row["txn_type"], "生产出库") + self.assertIsNone(row["archive_business_id"]) + self.assertIsNone(row["archive_document_type"]) + self.assertIsNone(row["archive_status"]) + + def test_production_ledger_inbound_txns_share_main_settlement_archive_id(self) -> None: + finished_ledger_txn = ProductionBatchLedgerTxn( + id=301, + production_ledger_id=self.ledger.id, + txn_type="成品入库", + qty_delta=Decimal("198"), + weight_delta_kg=Decimal("-118.8"), + outside_weight_after_kg=Decimal("81.2"), + source_doc_type="库存流水", + source_doc_id=self.finished_txn.id, + source_line_id=self.finished_lot.id, + biz_time=self.biz_time, + operator_user_id=1, + remark="成品入库联动生产台账;单据批次号:生产结算20260611103000000001", + created_at=self.now, + updated_at=self.now, + ) + surplus_ledger_txn = ProductionBatchLedgerTxn( + id=302, + production_ledger_id=self.ledger.id, + txn_type="生产余料入库", + qty_delta=Decimal("0"), + weight_delta_kg=Decimal("-60"), + outside_weight_after_kg=Decimal("21.2"), + source_doc_type="库存流水", + source_doc_id=self.surplus_txn.id, + source_line_id=self.raw_lot.id, + biz_time=self.biz_time, + operator_user_id=1, + remark="生产余料入库联动生产台账;单据批次号:生产结算20260611103000000001", + created_at=self.now, + updated_at=self.now, + ) + self.db.add_all([finished_ledger_txn, surplus_ledger_txn]) + self.db.commit() + with TemporaryDirectory() as tmp_dir, patch("app.services.document_archives.default_archive_root", return_value=tmp_dir): + generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, self.finished_txn.id) + + rows = list_production_batch_ledger(production_ledger_id=self.ledger.id, status=None, limit=20, db=self.db) + txn_by_type = {txn.txn_type: txn for txn in rows[0].txns} + + self.assertEqual(txn_by_type["成品入库"].archive_business_id, self.finished_txn.id) + self.assertEqual(txn_by_type["生产余料入库"].archive_business_id, self.finished_txn.id) + self.assertEqual(txn_by_type["成品入库"].archive_status, ARCHIVE_STATUS_READY) + self.assertEqual(txn_by_type["生产余料入库"].archive_status, ARCHIVE_STATUS_READY) + + def test_same_second_different_document_batches_do_not_share_archive_id(self) -> None: + first_finished_ledger_txn = ProductionBatchLedgerTxn( + id=311, + production_ledger_id=self.ledger.id, + txn_type="成品入库", + qty_delta=Decimal("198"), + weight_delta_kg=Decimal("-118.8"), + outside_weight_after_kg=Decimal("81.2"), + source_doc_type="库存流水", + source_doc_id=self.finished_txn.id, + source_line_id=self.finished_lot.id, + biz_time=self.biz_time, + operator_user_id=1, + remark="成品入库联动生产台账;单据批次号:生产结算20260611103000000001", + created_at=self.now, + updated_at=self.now, + ) + second_surplus_txn = InventoryTxn( + id=403, + txn_no="TXN-SURPLUS-002", + txn_type="PRODUCTION_SURPLUS_IN", + item_id=self.material_item.id, + warehouse_id=self.raw_warehouse.id, + lot_id=self.raw_lot.id, + qty_change=Decimal("0"), + weight_change_kg=Decimal("12"), + unit_cost=Decimal("6.6"), + amount=Decimal("79.20"), + source_doc_type="生产台账", + source_doc_id=self.ledger.id, + biz_time=self.biz_time, + remark="同秒第二次余料入库;单据批次号:生产结算20260611103000000002", + created_at=self.now, + updated_at=self.now, + ) + second_surplus_ledger_txn = ProductionBatchLedgerTxn( + id=312, + production_ledger_id=self.ledger.id, + txn_type="生产余料入库", + qty_delta=Decimal("0"), + weight_delta_kg=Decimal("-12"), + outside_weight_after_kg=Decimal("69.2"), + source_doc_type="库存流水", + source_doc_id=second_surplus_txn.id, + source_line_id=self.raw_lot.id, + biz_time=self.biz_time, + operator_user_id=1, + remark="同秒第二次余料入库联动生产台账;单据批次号:生产结算20260611103000000002", + created_at=self.now, + updated_at=self.now, + ) + self.db.add_all([first_finished_ledger_txn, second_surplus_txn, second_surplus_ledger_txn]) + self.db.commit() + with TemporaryDirectory() as tmp_dir, patch("app.services.document_archives.default_archive_root", return_value=tmp_dir): + generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, self.finished_txn.id) + generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, second_surplus_txn.id) + + rows = list_production_batch_ledger(production_ledger_id=self.ledger.id, status=None, limit=20, db=self.db) + txn_by_id = {txn.production_ledger_txn_id: txn for txn in rows[0].txns} + + self.assertEqual(txn_by_id[first_finished_ledger_txn.id].archive_business_id, self.finished_txn.id) + self.assertEqual(txn_by_id[second_surplus_ledger_txn.id].archive_business_id, second_surplus_txn.id) + + def test_generate_production_material_out_archive_pdf(self) -> None: + with TemporaryDirectory() as tmp_dir, patch("app.services.document_archives.default_archive_root", return_value=tmp_dir): + result = generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, self.ledger.id) + + self.assertEqual(result.archive_status, ARCHIVE_STATUS_READY) + archive = self.db.scalar( + select(DocumentArchive).where( + DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, + DocumentArchive.business_id == self.ledger.id, + DocumentArchive.archive_version == 1, + ) + ) + self.assertIsNotNone(archive) + + def test_generate_production_inbound_settlement_archive_pdf(self) -> None: + with TemporaryDirectory() as tmp_dir, patch("app.services.document_archives.default_archive_root", return_value=tmp_dir): + result = generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, self.finished_txn.id) + + self.assertEqual(result.archive_status, ARCHIVE_STATUS_READY) + archive = self.db.scalar( + select(DocumentArchive).where( + DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + DocumentArchive.business_id == self.finished_txn.id, + DocumentArchive.archive_version == 1, + ) + ) + self.assertIsNotNone(archive) + + def test_archive_generation_failure_keeps_existing_business_rows(self) -> None: + with patch("app.services.document_archives.render_pdf_archive", side_effect=RuntimeError("PDF服务异常")): + result = generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, self.finished_txn.id) + + self.assertEqual(result.archive_status, "归档失败") + self.assertIsNotNone(self.db.get(InventoryTxn, self.finished_txn.id)) + archive = self.db.scalar( + select(DocumentArchive).where( + DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + DocumentArchive.business_id == self.finished_txn.id, + ) + ) + self.assertIsNotNone(archive) + self.assertEqual(archive.status, "归档失败") + + def test_latest_archive_rows_can_be_joined_by_business_id(self) -> None: + with TemporaryDirectory() as tmp_dir, patch("app.services.document_archives.default_archive_root", return_value=tmp_dir): + generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, self.ledger.id) + generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, self.finished_txn.id) + + material_archive = self.db.scalar( + select(DocumentArchive).where(DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT) + ) + settlement_archive = self.db.scalar( + select(DocumentArchive).where(DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT) + ) + self.assertIsNotNone(material_archive) + self.assertIsNotNone(settlement_archive) + self.assertEqual(material_archive.business_id, self.ledger.id) + self.assertEqual(settlement_archive.business_id, self.finished_txn.id) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_production_work_order_inbound.py b/backend/tests/test_production_work_order_inbound.py new file mode 100644 index 0000000..08b5c46 --- /dev/null +++ b/backend/tests/test_production_work_order_inbound.py @@ -0,0 +1,393 @@ +from __future__ import annotations + +import unittest +from datetime import datetime +from decimal import Decimal + +from fastapi import HTTPException +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Bom, BomItem, Item, StockBalance, Warehouse # noqa: E402 +from app.models.operations import ( # noqa: E402 + InventoryTxn, + Process, + ProcessRoute, + ProcessRouteOperation, + StockLot, + Stocktake, + StocktakeWarehouse, + WorkCenter, + WorkOrder, + WorkOrderMaterial, + WorkOrderMaterialIssue, + WorkOrderOperation, +) +from app.models.org import Department # noqa: E402 +from app.schemas.operations import ProductionWorkOrderInboundCreate # noqa: E402 +from app.services.production_work_order_inbound import ( # noqa: E402 + create_production_work_order_inbound, + preview_production_work_order_inbound, +) + + +class ProductionWorkOrderInboundTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + raw_connection = engine.raw_connection() + raw_connection.create_function("greatest", -1, lambda *values: max(value for value in values if value is not None)) + raw_connection.close() + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + + self.raw_item = Item(id=1, item_code="RM-001", item_name="冷轧钢板", item_type="RAW_MATERIAL", unit_weight_kg=Decimal("1"), status="ACTIVE") + self.product = Item(id=2, item_code="FG-001", item_name="钢制碗", item_type="FINISHED_GOOD", unit_weight_kg=Decimal("0.50"), status="ACTIVE") + self.raw_wh = Warehouse(id=1, warehouse_code="WH-RAW", warehouse_name="原材料库", warehouse_type="RAW", status="ACTIVE") + self.finished_wh = Warehouse(id=2, warehouse_code="WH-FG", warehouse_name="成品库", warehouse_type="FINISHED", status="ACTIVE") + self.scrap_wh = Warehouse(id=3, warehouse_code="WH-SCRAP", warehouse_name="废料库", warehouse_type="SCRAP", status="ACTIVE") + self.db.add_all([self.raw_item, self.product, self.raw_wh, self.finished_wh, self.scrap_wh]) + self.db.flush() + + self.bom = Bom(id=1, bom_code="BOM-001", product_item_id=2, version_no="V1.0", is_default=1, status="ACTIVE") + self.bom_item = BomItem( + id=1, + bom_id=1, + seq_no=1, + material_item_id=1, + usage_type="WEIGHT", + net_weight_per_piece_kg=Decimal("0.50"), + gross_weight_per_piece_kg=Decimal("0.60"), + loss_rate=Decimal("0.1667"), + ) + self.department = Department(id=1, dept_code="PROD", dept_name="生产部", dept_type="PRODUCTION", status="ACTIVE") + self.process1 = Process(id=1, process_code="P1", process_name="开平", process_type="INTERNAL", status="ACTIVE") + self.process2 = Process(id=2, process_code="P2", process_name="冲压", process_type="INTERNAL", status="ACTIVE") + self.work_center = WorkCenter(id=1, center_code="WC-001", center_name="车间", dept_id=1, center_type="INTERNAL", capacity_hours_per_day=Decimal("8"), status="ACTIVE") + self.route = ProcessRoute(id=1, route_code="RT-001", route_name="钢制碗工艺", product_item_id=2, version_no="V1.0", is_default=1, status="ACTIVE") + self.route_op1 = ProcessRouteOperation(id=1, route_id=1, seq_no=1, process_id=1, operation_name="1序", work_center_id=1, std_cycle_seconds=Decimal("10"), status="ACTIVE") + self.route_op2 = ProcessRouteOperation(id=2, route_id=1, seq_no=2, process_id=2, operation_name="2序", work_center_id=1, std_cycle_seconds=Decimal("10"), status="ACTIVE") + self.db.add_all([self.bom, self.bom_item, self.department, self.process1, self.process2, self.work_center, self.route, self.route_op1, self.route_op2]) + + self.source_lot = StockLot( + id=1, + lot_no="JH_冷轧钢板00001_0001", + lot_role="RAW_MATERIAL", + item_id=1, + warehouse_id=1, + source_doc_type="PURCHASE_RECEIPT", + source_doc_id=10, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("500"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("300"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("4"), + quality_status="PASS", + status="AVAILABLE", + ) + self.balance = StockBalance( + id=1, + item_id=1, + warehouse_id=1, + qty_on_hand=Decimal("0"), + weight_on_hand_kg=Decimal("300"), + qty_available=Decimal("0"), + weight_available_kg=Decimal("300"), + qty_allocated=Decimal("0"), + weight_allocated_kg=Decimal("0"), + avg_unit_cost=Decimal("4"), + ) + self.work_order = WorkOrder( + id=1, + work_order_no="WO-001", + work_order_type="NORMAL", + product_item_id=2, + route_id=1, + bom_id=1, + planned_qty=Decimal("333"), + released_qty=Decimal("333"), + finished_qty=Decimal("0"), + scrap_qty=Decimal("0"), + status="RELEASED", + ) + self.material_row = WorkOrderMaterial( + id=1, + work_order_id=1, + bom_item_id=1, + material_item_id=1, + required_qty=Decimal("0"), + required_weight_kg=Decimal("200"), + issued_qty=Decimal("0"), + issued_weight_kg=Decimal("200"), + returned_qty=Decimal("0"), + returned_weight_kg=Decimal("0"), + status="ISSUED", + ) + self.issue = WorkOrderMaterialIssue( + id=1, + work_order_material_id=1, + work_order_id=1, + material_item_id=1, + source_lot_id=1, + material_sub_batch_no="JH_冷轧钢板00001_0001", + issued_qty=Decimal("0"), + issued_weight_kg=Decimal("200"), + unit_cost=Decimal("4"), + amount=Decimal("800"), + issue_time=datetime.now(), + ) + self.op1 = WorkOrderOperation( + id=1, + work_order_id=1, + route_operation_id=1, + seq_no=1, + operation_name="1序", + work_center_id=1, + planned_qty=Decimal("333"), + report_qty=Decimal("210"), + good_qty=Decimal("206"), + scrap_qty=Decimal("4"), + status="RUNNING", + ) + self.op2 = WorkOrderOperation( + id=2, + work_order_id=1, + route_operation_id=2, + seq_no=2, + operation_name="2序", + work_center_id=1, + planned_qty=Decimal("333"), + report_qty=Decimal("200"), + good_qty=Decimal("198"), + scrap_qty=Decimal("2"), + status="RUNNING", + ) + self.db.add_all([self.source_lot, self.balance, self.work_order, self.material_row, self.issue, self.op1, self.op2]) + self.db.commit() + + def tearDown(self) -> None: + self.db.close() + + def test_preview_calculates_defaults_from_finished_qty(self) -> None: + preview = preview_production_work_order_inbound(self.db, work_order_id=1, finished_qty=Decimal("198")) + + self.assertEqual(preview.work_order_no, "WO-001") + self.assertEqual(preview.source_lot_no, "JH_冷轧钢板00001_0001") + self.assertEqual(preview.finished_qty, 198) + self.assertEqual(preview.issued_weight_kg, 200) + self.assertEqual(preview.returned_weight_kg, 0) + self.assertEqual(preview.final_operation_good_qty, 198) + self.assertEqual(preview.reported_scrap_qty, 6) + self.assertEqual(preview.theoretical_surplus_weight_kg, 81.2) + self.assertEqual(preview.theoretical_scrap_weight_kg, 24) + + def test_preview_defaults_finished_qty_from_issued_weight_reference(self) -> None: + preview = preview_production_work_order_inbound(self.db, work_order_id=1) + + self.assertEqual(preview.reference_finished_qty, 333) + self.assertEqual(preview.remaining_reference_finished_qty, 333) + self.assertEqual(preview.finished_qty, 333) + self.assertEqual(preview.theoretical_surplus_weight_kg, 0.2) + + def test_submit_requires_deviation_remark_when_finished_qty_exceeds_reference_by_15_percent(self) -> None: + payload = ProductionWorkOrderInboundCreate( + work_order_id=1, + finished_qty=500, + surplus_weight_kg=0, + scrap_weight_kg=24, + ) + + with self.assertRaises(HTTPException) as raised: + create_production_work_order_inbound(self.db, payload=payload, operator_user_id=1) + + self.assertEqual(raised.exception.status_code, 400) + self.assertEqual(raised.exception.detail, "成品入库数量超过本工单参考生产数量上限15%,请填写偏差说明") + + def test_submit_requires_deviation_remark_when_surplus_or_scrap_differs_more_than_15_percent(self) -> None: + payload = ProductionWorkOrderInboundCreate( + work_order_id=1, + finished_qty=198, + surplus_weight_kg=60, + scrap_weight_kg=24, + ) + + with self.assertRaises(HTTPException) as raised: + create_production_work_order_inbound(self.db, payload=payload, operator_user_id=1) + + self.assertEqual(raised.exception.status_code, 400) + self.assertEqual(raised.exception.detail, "余料或废料入库重量与理论值偏差超过上下15%,请填写偏差说明") + + def test_submit_posts_finished_surplus_and_scrap_in_one_transaction(self) -> None: + payload = ProductionWorkOrderInboundCreate( + work_order_id=1, + finished_qty=198, + surplus_weight_kg=81.2, + scrap_weight_kg=24, + ) + + result = create_production_work_order_inbound(self.db, payload=payload, operator_user_id=7) + + self.assertEqual(result.finished_qty, 198) + self.assertEqual(result.surplus_weight_kg, 81.2) + self.assertEqual(result.scrap_weight_kg, 24) + txns = self.db.scalars(select(InventoryTxn).order_by(InventoryTxn.id)).all() + self.assertEqual([txn.txn_type for txn in txns], ["FG_IN", "PRODUCTION_SURPLUS_IN", "PRODUCTION_SCRAP_IN"]) + self.assertEqual(txns[0].operator_user_id, 7) + self.assertEqual(txns[0].source_doc_type, "FG_RECEIPT") + self.assertEqual(txns[1].source_doc_type, "WORK_ORDER") + self.assertEqual(txns[1].lot_id, 1) + self.assertEqual(float(txns[1].weight_change_kg), 81.2) + self.assertEqual(txns[2].source_doc_type, "WORK_ORDER") + self.assertEqual(txns[2].source_doc_id, 1) + + source_lot = self.db.get(StockLot, 1) + work_order = self.db.get(WorkOrder, 1) + material_row = self.db.get(WorkOrderMaterial, 1) + finished_lot = self.db.get(StockLot, result.finished_lot_id) + scrap_lot = self.db.get(StockLot, result.scrap_lot_id) + self.assertEqual(float(source_lot.remaining_weight_kg), 381.2) + self.assertEqual(float(work_order.finished_qty), 198) + self.assertEqual(float(material_row.returned_weight_kg), 81.2) + self.assertEqual(finished_lot.warehouse_id, 2) + self.assertEqual(finished_lot.source_material_lot_id, 1) + self.assertEqual(float(finished_lot.unit_cost), 2.4) + self.assertEqual(scrap_lot.warehouse_id, 3) + self.assertEqual(scrap_lot.source_material_lot_id, 1) + self.assertEqual(scrap_lot.quality_status, "SCRAP") + + def test_submit_settles_only_when_requested_and_confirmed(self) -> None: + payload = ProductionWorkOrderInboundCreate( + work_order_id=1, + finished_qty=198, + surplus_weight_kg=81.2, + scrap_weight_kg=24, + settle_work_order=True, + ) + + with self.assertRaises(HTTPException) as raised: + create_production_work_order_inbound(self.db, payload=payload, operator_user_id=1) + self.assertEqual(raised.exception.status_code, 400) + self.assertEqual(raised.exception.detail, "结单前必须确认本次入库明细及结单影响") + self.assertEqual(self.db.get(WorkOrder, 1).status, "RELEASED") + + payload.settle_confirmed = True + result = create_production_work_order_inbound(self.db, payload=payload, operator_user_id=1) + + work_order = self.db.get(WorkOrder, 1) + self.assertTrue(result.settled) + self.assertEqual(work_order.status, "SETTLED") + self.assertIsNotNone(work_order.actual_end_time) + self.assertIn("生产工单入库已结单", work_order.remark) + + def test_submit_without_settle_flags_does_not_settle(self) -> None: + payload = ProductionWorkOrderInboundCreate( + work_order_id=1, + finished_qty=198, + surplus_weight_kg=81.2, + scrap_weight_kg=24, + ) + + result = create_production_work_order_inbound(self.db, payload=payload, operator_user_id=1) + + work_order = self.db.get(WorkOrder, 1) + self.assertFalse(result.settled) + self.assertEqual(work_order.status, "RELEASED") + self.assertIsNone(work_order.actual_end_time) + + def test_preview_rejects_work_order_with_more_than_one_source_lot(self) -> None: + other_lot = StockLot( + id=2, + lot_no="JH_冷轧钢板00002_0001", + lot_role="RAW_MATERIAL", + item_id=1, + warehouse_id=1, + source_doc_type="PURCHASE_RECEIPT", + source_doc_id=11, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("100"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("100"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("4"), + quality_status="PASS", + status="AVAILABLE", + ) + other_issue = WorkOrderMaterialIssue( + id=2, + work_order_material_id=1, + work_order_id=1, + material_item_id=1, + source_lot_id=2, + material_sub_batch_no="JH_冷轧钢板00002_0001", + issued_qty=Decimal("0"), + issued_weight_kg=Decimal("10"), + unit_cost=Decimal("4"), + amount=Decimal("40"), + issue_time=datetime.now(), + ) + self.db.add_all([other_lot, other_issue]) + self.db.commit() + + with self.assertRaises(HTTPException) as raised: + preview_production_work_order_inbound(self.db, work_order_id=1, finished_qty=Decimal("198")) + + self.assertEqual(raised.exception.status_code, 400) + self.assertEqual(raised.exception.detail, "该工单存在多个材料来源库存批次,不能进行生产工单入库") + + def test_submit_rejects_locked_involved_warehouse_before_posting_inventory(self) -> None: + stocktake = Stocktake( + stocktake_no="PK-20260608-001", + status="LOCKED", + started_by_user_id=1, + started_at=datetime.now(), + remark="成品库盘库", + ) + self.db.add(stocktake) + self.db.flush() + self.db.add( + StocktakeWarehouse( + stocktake_id=stocktake.id, + warehouse_id=self.finished_wh.id, + warehouse_type="FINISHED", + warehouse_name=self.finished_wh.warehouse_name, + status="LOCKED", + ) + ) + self.db.commit() + payload = ProductionWorkOrderInboundCreate( + work_order_id=1, + finished_qty=198, + surplus_weight_kg=81.2, + scrap_weight_kg=24, + ) + + with self.assertRaises(HTTPException) as raised: + create_production_work_order_inbound(self.db, payload=payload, operator_user_id=1) + + self.assertEqual(raised.exception.status_code, 423) + self.assertIn("成品库正在盘库", raised.exception.detail) + txns = self.db.scalars(select(InventoryTxn)).all() + self.assertEqual(txns, []) + self.assertEqual(float(self.db.get(WorkOrder, 1).finished_qty), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_production_work_order_ledger.py b/backend/tests/test_production_work_order_ledger.py new file mode 100644 index 0000000..dbc76ae --- /dev/null +++ b/backend/tests/test_production_work_order_ledger.py @@ -0,0 +1,1065 @@ +from __future__ import annotations + +import unittest +from datetime import datetime +from decimal import Decimal +from types import SimpleNamespace + +from fastapi import HTTPException +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Bom, BomItem, Item, StockBalance, Warehouse # noqa: E402 +from app.models.operations import ( # noqa: E402 + InventoryTxn, + Process, + ProcessRoute, + ProcessRouteOperation, + StockLot, + WorkCenter, + WorkOrder, + WorkOrderMaterial, + WorkOrderMaterialIssue, + WorkOrderOperation, +) +from app.models.org import Department # noqa: E402 +from app.schemas.operations import ( # noqa: E402 + CompletionReceiptCreate, + CompletionReceiptItemCreate, + WarehouseInboundCreate, +) + + +class Context: + user = SimpleNamespace(id=1) + + +class ProductionWorkOrderLedgerTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + raw_connection = engine.raw_connection() + raw_connection.create_function("greatest", -1, lambda *values: max(value for value in values if value is not None)) + raw_connection.close() + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + + self.raw_item = Item(id=1, item_code="RM-001", item_name="冷轧钢板", item_type="RAW_MATERIAL", unit_weight_kg=Decimal("1"), status="ACTIVE") + self.product = Item(id=2, item_code="FG-001", item_name="钢制碗", item_type="FINISHED_GOOD", unit_weight_kg=Decimal("0.50"), status="ACTIVE") + self.raw_wh = Warehouse(id=1, warehouse_code="WH-RAW", warehouse_name="原材料库", warehouse_type="RAW", status="ACTIVE") + self.finished_wh = Warehouse(id=2, warehouse_code="WH-FG", warehouse_name="成品库", warehouse_type="FINISHED", status="ACTIVE") + self.scrap_wh = Warehouse(id=3, warehouse_code="WH-SCRAP", warehouse_name="废料库", warehouse_type="SCRAP", status="ACTIVE") + self.db.add_all([self.raw_item, self.product, self.raw_wh, self.finished_wh, self.scrap_wh]) + self.db.flush() + + self.bom = Bom(id=1, bom_code="BOM-001", product_item_id=2, version_no="V1.0", is_default=1, status="ACTIVE") + self.bom_item = BomItem( + id=1, + bom_id=1, + seq_no=1, + material_item_id=1, + usage_type="WEIGHT", + net_weight_per_piece_kg=Decimal("0.50"), + gross_weight_per_piece_kg=Decimal("0.60"), + loss_rate=Decimal("0.1667"), + ) + self.department = Department(id=1, dept_code="PROD", dept_name="生产部", dept_type="PRODUCTION", status="ACTIVE") + self.process1 = Process(id=1, process_code="P1", process_name="开平", process_type="INTERNAL", status="ACTIVE") + self.process2 = Process(id=2, process_code="P2", process_name="冲压", process_type="INTERNAL", status="ACTIVE") + self.work_center = WorkCenter(id=1, center_code="WC-001", center_name="车间", dept_id=1, center_type="INTERNAL", capacity_hours_per_day=Decimal("8"), status="ACTIVE") + self.route = ProcessRoute(id=1, route_code="RT-001", route_name="钢制碗工艺", product_item_id=2, version_no="V1.0", is_default=1, status="ACTIVE") + self.route_op1 = ProcessRouteOperation(id=1, route_id=1, seq_no=1, process_id=1, operation_name="1序", work_center_id=1, std_cycle_seconds=Decimal("10"), status="ACTIVE") + self.route_op2 = ProcessRouteOperation(id=2, route_id=1, seq_no=2, process_id=2, operation_name="2序", work_center_id=1, std_cycle_seconds=Decimal("10"), status="ACTIVE") + self.db.add_all([self.bom, self.bom_item, self.department, self.process1, self.process2, self.work_center, self.route, self.route_op1, self.route_op2]) + + self.source_lot = StockLot( + id=1, + lot_no="JH_冷轧钢板00001_0001", + lot_role="RAW_MATERIAL", + item_id=1, + warehouse_id=1, + source_doc_type="PURCHASE_RECEIPT", + source_doc_id=10, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("500"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("300"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("4"), + quality_status="PASS", + status="AVAILABLE", + ) + self.balance = StockBalance( + id=1, + item_id=1, + warehouse_id=1, + qty_on_hand=Decimal("0"), + weight_on_hand_kg=Decimal("300"), + qty_available=Decimal("0"), + weight_available_kg=Decimal("300"), + qty_allocated=Decimal("0"), + weight_allocated_kg=Decimal("0"), + avg_unit_cost=Decimal("4"), + ) + self.work_order = WorkOrder( + id=1, + work_order_no="WO-001", + work_order_type="NORMAL", + product_item_id=2, + route_id=1, + bom_id=1, + planned_qty=Decimal("333"), + released_qty=Decimal("333"), + finished_qty=Decimal("0"), + scrap_qty=Decimal("0"), + status="RELEASED", + ) + self.material_row = WorkOrderMaterial( + id=1, + work_order_id=1, + bom_item_id=1, + material_item_id=1, + required_qty=Decimal("0"), + required_weight_kg=Decimal("200"), + issued_qty=Decimal("0"), + issued_weight_kg=Decimal("200"), + returned_qty=Decimal("0"), + returned_weight_kg=Decimal("0"), + status="ISSUED", + ) + self.issue = WorkOrderMaterialIssue( + id=1, + work_order_material_id=1, + work_order_id=1, + material_item_id=1, + source_lot_id=1, + material_sub_batch_no="JH_冷轧钢板00001_0001", + issued_qty=Decimal("0"), + issued_weight_kg=Decimal("200"), + unit_cost=Decimal("4"), + amount=Decimal("800"), + issue_time=datetime.now(), + ) + self.op1 = WorkOrderOperation( + id=1, + work_order_id=1, + route_operation_id=1, + seq_no=1, + operation_name="1序", + work_center_id=1, + planned_qty=Decimal("333"), + report_qty=Decimal("210"), + good_qty=Decimal("206"), + scrap_qty=Decimal("4"), + status="RUNNING", + ) + self.op2 = WorkOrderOperation( + id=2, + work_order_id=1, + route_operation_id=2, + seq_no=2, + operation_name="2序", + work_center_id=1, + planned_qty=Decimal("333"), + report_qty=Decimal("200"), + good_qty=Decimal("198"), + scrap_qty=Decimal("2"), + status="RUNNING", + ) + self.db.add_all([self.source_lot, self.balance, self.work_order, self.material_row, self.issue, self.op1, self.op2]) + self.db.commit() + + def tearDown(self) -> None: + self.db.close() + + def test_limit_snapshot_uses_max_report_qty_for_used_weight_and_sum_scrap_qty_for_scrap_weight(self) -> None: + from app.services.production_work_order_ledger import get_work_order_limit_snapshot + + snapshot = get_work_order_limit_snapshot(self.db, 1) + + self.assertEqual(float(snapshot.issued_weight_kg), 200) + self.assertEqual(float(snapshot.used_material_weight_kg), 126) + self.assertEqual(float(snapshot.returned_material_weight_kg), 0) + self.assertEqual(float(snapshot.max_surplus_inbound_weight_kg), 74) + self.assertEqual(float(snapshot.final_operation_good_qty), 198) + self.assertEqual(float(snapshot.finished_received_qty), 0) + self.assertEqual(float(snapshot.max_finished_inbound_qty), 198) + self.assertEqual(float(snapshot.total_operation_scrap_qty), 6) + self.assertEqual(float(snapshot.max_scrap_inbound_weight_kg), 24.0) + self.assertEqual(float(snapshot.tolerance_finished_inbound_qty), 227.7) + self.assertEqual(float(snapshot.tolerance_scrap_inbound_weight_kg), 27.6) + self.assertEqual(float(snapshot.tolerance_surplus_inbound_weight_kg), 85.1) + + def test_scrap_limit_ignores_work_order_surplus_inbound_transactions(self) -> None: + from app.services.production_work_order_ledger import get_work_order_limit_snapshot + + self.db.add( + InventoryTxn( + id=1, + txn_no="TXN-SURPLUS-001", + txn_type="PRODUCTION_SURPLUS_IN", + item_id=1, + warehouse_id=1, + lot_id=1, + qty_change=Decimal("0"), + weight_change_kg=Decimal("20"), + unit_cost=Decimal("4"), + amount=Decimal("80"), + source_doc_type="WORK_ORDER", + source_doc_id=1, + biz_time=datetime.now(), + operator_user_id=1, + remark="生产余料入库", + ) + ) + self.db.commit() + + snapshot = get_work_order_limit_snapshot(self.db, 1) + + self.assertEqual(float(snapshot.max_scrap_inbound_weight_kg), 24.0) + + def test_scrap_tolerance_remaining_uses_cumulative_upper_limit(self) -> None: + from app.services.production_work_order_ledger import get_work_order_limit_snapshot + + self.db.add( + InventoryTxn( + id=1, + txn_no="TXN-SCRAP-001", + txn_type="PRODUCTION_SCRAP_IN", + item_id=1, + warehouse_id=3, + lot_id=1, + qty_change=Decimal("0"), + weight_change_kg=Decimal("10"), + unit_cost=Decimal("4"), + amount=Decimal("40"), + source_doc_type="WORK_ORDER", + source_doc_id=1, + biz_time=datetime.now(), + operator_user_id=1, + remark="生产废料入库", + ) + ) + self.db.commit() + + snapshot = get_work_order_limit_snapshot(self.db, 1) + + self.assertEqual(float(snapshot.max_scrap_inbound_weight_kg), 14.0) + self.assertEqual(float(snapshot.tolerance_scrap_inbound_weight_kg), 17.6) + + def test_surplus_tolerance_remaining_uses_cumulative_upper_limit(self) -> None: + from app.services.production_work_order_ledger import get_work_order_limit_snapshot + + self.material_row.returned_weight_kg = Decimal("40") + self.db.add(self.material_row) + self.db.commit() + + snapshot = get_work_order_limit_snapshot(self.db, 1) + + self.assertEqual(float(snapshot.max_surplus_inbound_weight_kg), 34.0) + self.assertEqual(float(snapshot.tolerance_surplus_inbound_weight_kg), 45.1) + + def test_weight_profile_skips_zero_gross_weight_bom_rows(self) -> None: + from app.services.production_work_order_ledger import get_work_order_limit_snapshot + + self.db.add( + BomItem( + id=2, + bom_id=1, + seq_no=0, + material_item_id=1, + usage_type="WEIGHT", + net_weight_per_piece_kg=Decimal("0"), + gross_weight_per_piece_kg=Decimal("0"), + loss_rate=Decimal("0"), + ) + ) + self.db.commit() + + snapshot = get_work_order_limit_snapshot(self.db, 1) + + self.assertEqual(float(snapshot.gross_weight_per_piece_kg), 0.6) + self.assertEqual(float(snapshot.used_material_weight_kg), 126) + + def test_ledger_rows_include_material_operation_and_limit_details(self) -> None: + from app.services.production_work_order_ledger import get_work_order_ledger_rows + + self.work_order.remark = "生产废料已结单" + self.db.add(self.work_order) + self.db.commit() + + rows = get_work_order_ledger_rows(self.db, limit=20) + + self.assertEqual(len(rows), 1) + row = rows[0] + self.assertEqual(row.work_order_no, "WO-001") + self.assertEqual(row.product_name, "钢制碗") + self.assertEqual(row.remark, "生产废料已结单") + self.assertEqual(row.material_summary, "冷轧钢板") + self.assertEqual(row.source_lot_summary, "JH_冷轧钢板00001_0001") + self.assertEqual(float(row.planned_qty), 333) + self.assertEqual(float(row.issued_weight_kg), 200) + self.assertEqual(float(row.used_material_weight_kg), 126) + self.assertEqual(float(row.returned_material_weight_kg), 0) + self.assertEqual(float(row.finished_received_qty), 0) + self.assertEqual(len(row.operations), 2) + self.assertEqual(row.operations[0].operation_name, "1序") + self.assertEqual(float(row.operations[0].good_qty), 206) + self.assertEqual(float(row.operations[0].scrap_qty), 4) + self.assertEqual(float(row.limits.max_scrap_inbound_weight_kg), 24.0) + + def test_ledger_material_rows_show_returned_weight_by_source_lot(self) -> None: + from app.services.production_work_order_ledger import get_work_order_ledger_rows + + source_lot_2 = StockLot( + id=2, + lot_no="JH_冷轧钢板00001_0002", + lot_role="RAW_MATERIAL", + item_id=1, + warehouse_id=1, + source_doc_type="PURCHASE_RECEIPT", + source_doc_id=11, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("500"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("300"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("4"), + quality_status="PASS", + status="AVAILABLE", + ) + self.issue.issued_weight_kg = Decimal("100") + self.issue.amount = Decimal("400") + issue_2 = WorkOrderMaterialIssue( + id=2, + work_order_material_id=1, + work_order_id=1, + material_item_id=1, + source_lot_id=2, + material_sub_batch_no="JH_冷轧钢板00001_0002", + issued_qty=Decimal("0"), + issued_weight_kg=Decimal("100"), + unit_cost=Decimal("4"), + amount=Decimal("400"), + issue_time=datetime.now(), + ) + self.material_row.returned_weight_kg = Decimal("138.2") + self.db.add_all( + [ + source_lot_2, + issue_2, + self.issue, + self.material_row, + InventoryTxn( + id=1, + txn_no="TXN-SURPLUS-001", + txn_type="PRODUCTION_SURPLUS_IN", + item_id=1, + warehouse_id=1, + lot_id=1, + qty_change=Decimal("0"), + weight_change_kg=Decimal("38.2"), + unit_cost=Decimal("4"), + amount=Decimal("152.8"), + source_doc_type="WORK_ORDER", + source_doc_id=1, + source_line_id=1, + biz_time=datetime.now(), + operator_user_id=1, + remark="生产余料入库", + ), + InventoryTxn( + id=2, + txn_no="TXN-SURPLUS-002", + txn_type="PRODUCTION_SURPLUS_IN", + item_id=1, + warehouse_id=1, + lot_id=2, + qty_change=Decimal("0"), + weight_change_kg=Decimal("100"), + unit_cost=Decimal("4"), + amount=Decimal("400"), + source_doc_type="WORK_ORDER", + source_doc_id=1, + source_line_id=2, + biz_time=datetime.now(), + operator_user_id=1, + remark="生产余料入库", + ), + ] + ) + self.db.commit() + + rows = get_work_order_ledger_rows(self.db, limit=20) + + material_rows = sorted(rows[0].materials, key=lambda row: row.source_lot_no) + self.assertEqual([row.source_lot_no for row in material_rows], ["JH_冷轧钢板00001_0001", "JH_冷轧钢板00001_0002"]) + self.assertEqual([float(row.returned_weight_kg) for row in material_rows], [38.2, 100.0]) + self.assertEqual(float(rows[0].returned_material_weight_kg), 138.2) + + def test_finished_inbound_accepts_15_percent_tolerance_and_rejects_above_tolerance(self) -> None: + from app.api.routes.production import create_completion_receipt + + with self.assertRaises(HTTPException) as cm: + create_completion_receipt( + CompletionReceiptCreate( + work_order_id=1, + warehouse_id=2, + receiver_employee_id=None, + items=[CompletionReceiptItemCreate(product_item_id=2, receipt_qty=227.701, receipt_weight_kg=0)], + ), + context=Context(), + db=self.db, + ) + self.assertIn("超过允许入库上限", cm.exception.detail) + self.assertEqual(cm.exception.status_code, 400) + + accepted = create_completion_receipt( + CompletionReceiptCreate( + work_order_id=1, + warehouse_id=2, + receiver_employee_id=None, + items=[CompletionReceiptItemCreate(product_item_id=2, receipt_qty=227.7, receipt_weight_kg=0)], + ), + context=Context(), + db=self.db, + ) + + self.assertEqual(float(accepted.total_receipt_qty), 227.7) + + def test_finished_inbound_tolerance_is_validated_against_total_receipt_qty(self) -> None: + from app.api.routes.production import create_completion_receipt + + accepted = create_completion_receipt( + CompletionReceiptCreate( + work_order_id=1, + warehouse_id=2, + receiver_employee_id=None, + items=[ + CompletionReceiptItemCreate(product_item_id=2, receipt_qty=100, receipt_weight_kg=0), + CompletionReceiptItemCreate(product_item_id=2, receipt_qty=127.7, receipt_weight_kg=0), + ], + ), + context=Context(), + db=self.db, + ) + + self.assertEqual(float(accepted.total_receipt_qty), 227.7) + self.assertEqual(accepted.line_count, 2) + + def test_finished_settle_keeps_scrap_and_surplus_entries_available_until_each_is_settled(self) -> None: + from app.api.routes.inventory import create_warehouse_inbound + from app.api.routes.production import create_completion_receipt + + create_completion_receipt( + CompletionReceiptCreate( + work_order_id=1, + warehouse_id=2, + receiver_employee_id=None, + items=[CompletionReceiptItemCreate(product_item_id=2, receipt_qty=198, receipt_weight_kg=0)], + settle_work_order=True, + ), + context=Context(), + db=self.db, + ) + + self.db.refresh(self.work_order) + self.assertEqual(self.work_order.status, "SETTLED") + + scrap_row = create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SCRAP_IN", + item_id=1, + warehouse_id=3, + inbound_weight_kg=1, + source_doc_type="WORK_ORDER", + source_doc_id=1, + remark="生产废料补录", + ), + context=Context(), + db=self.db, + ) + self.assertEqual(scrap_row.source_doc_id, 1) + + surplus_row = create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SURPLUS", + item_id=1, + warehouse_id=1, + source_lot_id=1, + inbound_weight_kg=1, + source_doc_type="WORK_ORDER", + source_doc_id=1, + remark="生产余料补录", + ), + context=Context(), + db=self.db, + ) + self.assertEqual(surplus_row.lot_id, 1) + + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SCRAP_IN", + item_id=1, + warehouse_id=3, + inbound_weight_kg=23, + source_doc_type="WORK_ORDER", + source_doc_id=1, + remark="生产废料结单", + settle_work_order=True, + ), + context=Context(), + db=self.db, + ) + + self.db.refresh(self.work_order) + self.assertEqual(self.work_order.status, "SETTLED") + self.assertIn("生产废料已结单", self.work_order.remark) + + with self.assertRaises(HTTPException) as scrap_exc: + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SCRAP_IN", + item_id=1, + warehouse_id=3, + inbound_weight_kg=0.1, + source_doc_type="WORK_ORDER", + source_doc_id=1, + remark="生产废料再次补录", + ), + context=Context(), + db=self.db, + ) + self.assertIn("生产废料入库选择的生产工单已完成本项结单", scrap_exc.exception.detail) + + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SURPLUS", + item_id=1, + warehouse_id=1, + source_lot_id=1, + inbound_weight_kg=73, + source_doc_type="WORK_ORDER", + source_doc_id=1, + remark="生产余料结单", + settle_work_order=True, + ), + context=Context(), + db=self.db, + ) + + self.db.refresh(self.work_order) + self.assertEqual(self.work_order.status, "SETTLED") + self.assertIn("生产废料已结单", self.work_order.remark) + self.assertIn("生产余料已结单", self.work_order.remark) + + with self.assertRaises(HTTPException) as surplus_exc: + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SURPLUS", + item_id=1, + warehouse_id=1, + source_lot_id=1, + inbound_weight_kg=0.1, + source_doc_type="WORK_ORDER", + source_doc_id=1, + remark="生产余料再次补录", + ), + context=Context(), + db=self.db, + ) + self.assertIn("生产余料入库选择的生产工单已完成本项结单", surplus_exc.exception.detail) + + def test_production_scrap_inbound_requires_work_order_and_respects_tolerance(self) -> None: + from app.api.routes.inventory import create_warehouse_inbound + + row = create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SCRAP_IN", + item_id=1, + warehouse_id=3, + inbound_weight_kg=27.6, + source_doc_type="WORK_ORDER", + source_doc_id=1, + source_material_sub_batch_no="JH_冷轧钢板00001_0001", + source_material_summary="WO-001生产废料", + remark="生产废料入库", + ), + context=Context(), + db=self.db, + ) + + lot = self.db.get(StockLot, row.lot_id) + txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == row.lot_id)) + self.assertEqual(lot.source_doc_type, "WORK_ORDER") + self.assertEqual(lot.source_doc_id, 1) + self.assertEqual(txn.source_doc_type, "WORK_ORDER") + self.assertEqual(txn.source_doc_id, 1) + + with self.assertRaises(HTTPException) as cm: + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SCRAP_IN", + item_id=1, + warehouse_id=3, + inbound_weight_kg=0.001, + source_doc_type="WORK_ORDER", + source_doc_id=1, + remark="再次超额入库", + ), + context=Context(), + db=self.db, + ) + self.assertIn("超过允许入库上限", cm.exception.detail) + self.assertEqual(cm.exception.status_code, 400) + + def test_production_scrap_non_settle_uses_cumulative_tolerance_upper_limit(self) -> None: + from app.api.routes.inventory import create_warehouse_inbound + + self.db.add( + InventoryTxn( + id=1, + txn_no="TXN-SCRAP-001", + txn_type="PRODUCTION_SCRAP_IN", + item_id=1, + warehouse_id=3, + lot_id=1, + qty_change=Decimal("0"), + weight_change_kg=Decimal("10"), + unit_cost=Decimal("4"), + amount=Decimal("40"), + source_doc_type="WORK_ORDER", + source_doc_id=1, + biz_time=datetime.now(), + operator_user_id=1, + remark="生产废料入库", + ) + ) + self.db.commit() + + row = create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SCRAP_IN", + item_id=1, + warehouse_id=3, + inbound_weight_kg=17.6, + source_doc_type="WORK_ORDER", + source_doc_id=1, + remark="生产废料继续入库", + ), + context=Context(), + db=self.db, + ) + + self.assertEqual(row.source_doc_type, "WORK_ORDER") + self.assertEqual(row.source_doc_id, 1) + + def test_production_scrap_non_settle_rejects_when_cumulative_exceeds_tolerance_upper_limit(self) -> None: + from app.api.routes.inventory import create_warehouse_inbound + + self.db.add( + InventoryTxn( + id=1, + txn_no="TXN-SCRAP-001", + txn_type="PRODUCTION_SCRAP_IN", + item_id=1, + warehouse_id=3, + lot_id=1, + qty_change=Decimal("0"), + weight_change_kg=Decimal("10"), + unit_cost=Decimal("4"), + amount=Decimal("40"), + source_doc_type="WORK_ORDER", + source_doc_id=1, + biz_time=datetime.now(), + operator_user_id=1, + remark="生产废料入库", + ) + ) + self.db.commit() + + with self.assertRaises(HTTPException) as cm: + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SCRAP_IN", + item_id=1, + warehouse_id=3, + inbound_weight_kg=17.601, + source_doc_type="WORK_ORDER", + source_doc_id=1, + remark="生产废料继续入库", + ), + context=Context(), + db=self.db, + ) + + self.assertEqual(cm.exception.status_code, 400) + self.assertIn("累计入库废料重量超过允许入库上限", cm.exception.detail) + + def test_production_scrap_settle_marks_scrap_done_but_keeps_work_order_open(self) -> None: + from app.api.routes.inventory import create_warehouse_inbound + + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SCRAP_IN", + item_id=1, + warehouse_id=3, + inbound_weight_kg=27.6, + source_doc_type="WORK_ORDER", + source_doc_id=1, + remark="生产废料入库", + settle_work_order=True, + ), + context=Context(), + db=self.db, + ) + + self.db.refresh(self.work_order) + self.assertNotEqual(self.work_order.status, "SETTLED") + self.assertIn("生产废料已结单", self.work_order.remark) + + def test_production_scrap_settle_does_not_block_later_surplus_settle(self) -> None: + from app.api.routes.inventory import create_warehouse_inbound + + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SCRAP_IN", + item_id=1, + warehouse_id=3, + inbound_weight_kg=27.6, + source_doc_type="WORK_ORDER", + source_doc_id=1, + remark="生产废料入库", + settle_work_order=True, + ), + context=Context(), + db=self.db, + ) + + self.db.refresh(self.work_order) + self.assertNotEqual(self.work_order.status, "SETTLED") + + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SURPLUS", + item_id=1, + warehouse_id=1, + source_lot_id=1, + inbound_weight_kg=85.1, + source_doc_type="WORK_ORDER", + source_doc_id=1, + remark="生产余料归还", + settle_work_order=True, + ), + context=Context(), + db=self.db, + ) + + self.db.refresh(self.work_order) + self.assertEqual(self.work_order.status, "SETTLED") + + def test_production_scrap_settle_requires_remark_when_total_is_outside_tolerance(self) -> None: + from app.api.routes.inventory import create_warehouse_inbound + + with self.assertRaises(HTTPException) as cm: + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SCRAP_IN", + item_id=1, + warehouse_id=3, + inbound_weight_kg=20.399, + source_doc_type="WORK_ORDER", + source_doc_id=1, + remark="生产废料入库", + settle_work_order=True, + ), + context=Context(), + db=self.db, + ) + + self.assertEqual(cm.exception.status_code, 400) + self.assertIn("填写结单说明", cm.exception.detail) + + def test_production_surplus_returns_to_original_lot_and_updates_work_order_returned_weight(self) -> None: + from app.api.routes.inventory import create_warehouse_inbound + + row = create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SURPLUS", + item_id=1, + warehouse_id=1, + source_lot_id=1, + inbound_weight_kg=85.1, + source_doc_type="WORK_ORDER", + source_doc_id=1, + remark="生产余料归还", + ), + context=Context(), + db=self.db, + ) + + self.db.refresh(self.source_lot) + self.db.refresh(self.material_row) + txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "PRODUCTION_SURPLUS_IN")) + self.assertEqual(row.lot_id, 1) + self.assertEqual(float(self.source_lot.remaining_weight_kg), 385.1) + self.assertEqual(float(self.material_row.returned_weight_kg), 85.1) + self.assertEqual(txn.source_doc_type, "WORK_ORDER") + self.assertEqual(txn.source_doc_id, 1) + self.assertEqual(txn.source_line_id, self.source_lot.id) + + def test_production_surplus_non_settle_uses_cumulative_tolerance_upper_limit(self) -> None: + from app.api.routes.inventory import create_warehouse_inbound + + self.material_row.returned_weight_kg = Decimal("40") + self.db.add(self.material_row) + self.db.commit() + + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SURPLUS", + item_id=1, + warehouse_id=1, + source_lot_id=1, + inbound_weight_kg=45.1, + source_doc_type="WORK_ORDER", + source_doc_id=1, + remark="生产余料继续归还", + ), + context=Context(), + db=self.db, + ) + + self.db.refresh(self.material_row) + self.assertEqual(float(self.material_row.returned_weight_kg), 85.1) + + def test_production_surplus_non_settle_rejects_when_cumulative_return_exceeds_tolerance_upper_limit(self) -> None: + from app.api.routes.inventory import create_warehouse_inbound + + self.material_row.returned_weight_kg = Decimal("40") + self.db.add(self.material_row) + self.db.commit() + + with self.assertRaises(HTTPException) as cm: + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SURPLUS", + item_id=1, + warehouse_id=1, + source_lot_id=1, + inbound_weight_kg=45.101, + source_doc_type="WORK_ORDER", + source_doc_id=1, + remark="生产余料继续归还", + ), + context=Context(), + db=self.db, + ) + + self.assertEqual(cm.exception.status_code, 400) + self.assertIn("累计归还重量超过允许入库上限", cm.exception.detail) + + def test_production_surplus_settle_marks_surplus_done_but_keeps_work_order_open(self) -> None: + from app.api.routes.inventory import create_warehouse_inbound + + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SURPLUS", + item_id=1, + warehouse_id=1, + source_lot_id=1, + inbound_weight_kg=85.1, + source_doc_type="WORK_ORDER", + source_doc_id=1, + remark="生产余料归还", + settle_work_order=True, + ), + context=Context(), + db=self.db, + ) + + self.db.refresh(self.work_order) + self.db.refresh(self.material_row) + self.assertNotEqual(self.work_order.status, "SETTLED") + self.assertIn("生产余料已结单", self.work_order.remark) + self.assertEqual(float(self.material_row.returned_weight_kg), 85.1) + + def test_production_surplus_settle_does_not_block_later_scrap_settle(self) -> None: + from app.api.routes.inventory import create_warehouse_inbound + + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SURPLUS", + item_id=1, + warehouse_id=1, + source_lot_id=1, + inbound_weight_kg=85.1, + source_doc_type="WORK_ORDER", + source_doc_id=1, + remark="生产余料归还", + settle_work_order=True, + ), + context=Context(), + db=self.db, + ) + + self.db.refresh(self.work_order) + self.assertNotEqual(self.work_order.status, "SETTLED") + + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SCRAP_IN", + item_id=1, + warehouse_id=3, + inbound_weight_kg=27.6, + source_doc_type="WORK_ORDER", + source_doc_id=1, + remark="生产废料入库", + settle_work_order=True, + ), + context=Context(), + db=self.db, + ) + + self.db.refresh(self.work_order) + self.assertEqual(self.work_order.status, "SETTLED") + + def test_production_surplus_settle_requires_remark_when_return_is_outside_tolerance(self) -> None: + from app.api.routes.inventory import create_warehouse_inbound + + with self.assertRaises(HTTPException) as cm: + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SURPLUS", + item_id=1, + warehouse_id=1, + source_lot_id=1, + inbound_weight_kg=85.101, + source_doc_type="WORK_ORDER", + source_doc_id=1, + remark="生产余料归还", + settle_work_order=True, + ), + context=Context(), + db=self.db, + ) + + self.assertEqual(cm.exception.status_code, 400) + self.assertIn("填写结单说明", cm.exception.detail) + + def test_production_surplus_settle_accepts_outside_tolerance_with_remark(self) -> None: + from app.api.routes.inventory import create_warehouse_inbound + + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SURPLUS", + item_id=1, + warehouse_id=1, + source_lot_id=1, + inbound_weight_kg=85.101, + source_doc_type="WORK_ORDER", + source_doc_id=1, + remark="生产余料归还", + settle_work_order=True, + settle_remark="实际称重偏高,现场已复核", + ), + context=Context(), + db=self.db, + ) + + self.db.refresh(self.work_order) + self.db.refresh(self.material_row) + self.assertNotEqual(self.work_order.status, "SETTLED") + self.assertIn("生产余料已结单", self.work_order.remark) + self.assertEqual(float(self.material_row.returned_weight_kg), 85.101) + self.assertIn("实际称重偏高", self.work_order.remark) + + def test_settled_work_order_rejects_production_surplus_inbound(self) -> None: + from app.api.routes.inventory import create_warehouse_inbound + + self.work_order.status = "SETTLED" + self.db.add(self.work_order) + self.db.commit() + + with self.assertRaises(HTTPException) as cm: + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SURPLUS", + item_id=1, + warehouse_id=1, + source_lot_id=1, + inbound_weight_kg=1, + source_doc_type="WORK_ORDER", + source_doc_id=1, + remark="生产余料归还", + ), + context=Context(), + db=self.db, + ) + + self.assertEqual(cm.exception.status_code, 400) + self.assertIn("已结单", cm.exception.detail) + + def test_settled_work_order_rejects_production_scrap_inbound(self) -> None: + from app.api.routes.inventory import create_warehouse_inbound + + self.work_order.status = "SETTLED" + self.db.add(self.work_order) + self.db.commit() + + with self.assertRaises(HTTPException) as cm: + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SCRAP_IN", + item_id=1, + warehouse_id=3, + inbound_weight_kg=1, + source_doc_type="WORK_ORDER", + source_doc_id=1, + remark="生产废料入库", + ), + context=Context(), + db=self.db, + ) + + self.assertEqual(cm.exception.status_code, 400) + self.assertIn("已结单", cm.exception.detail) + + def test_settled_work_order_rejects_finished_inbound(self) -> None: + from app.api.routes.production import create_completion_receipt + + self.work_order.status = "SETTLED" + self.db.add(self.work_order) + self.db.commit() + + with self.assertRaises(HTTPException) as cm: + create_completion_receipt( + CompletionReceiptCreate( + work_order_id=1, + warehouse_id=2, + receiver_employee_id=None, + items=[CompletionReceiptItemCreate(product_item_id=2, receipt_qty=1, receipt_weight_kg=0)], + ), + context=Context(), + db=self.db, + ) + + self.assertEqual(cm.exception.status_code, 400) + self.assertIn("已结单", cm.exception.detail) diff --git a/backend/tests/test_purchase_order_document_archive.py b/backend/tests/test_purchase_order_document_archive.py new file mode 100644 index 0000000..2d25a77 --- /dev/null +++ b/backend/tests/test_purchase_order_document_archive.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +import unittest +from datetime import date, datetime +from unittest.mock import patch + +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_big_integer_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.document_archive # noqa: E402,F401 +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.api.routes.purchase import create_purchase_order, list_purchase_orders, update_purchase_order # noqa: E402 +from app.models.base import Base # noqa: E402 +from app.models.document_archive import DocumentArchive # noqa: E402 +from app.models.master_data import Item, Material # noqa: E402 +from app.models.operations import Supplier # noqa: E402 +from app.schemas.document_archives import DocumentArchiveGenerateResult # noqa: E402 +from app.schemas.operations import PurchaseOrderCreate, PurchaseOrderItemCreate, PurchaseOrderUpdate # noqa: E402 + + +class PurchaseOrderDocumentArchiveTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + now = datetime.now() + self.supplier = Supplier( + id=1, + supplier_code="SUP-001", + supplier_name="测试供应商", + short_name="供应商", + address="宁波市测试路 1 号", + lead_time_days=0, + default_tax_rate=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.material_item = Item( + id=10, + item_code="RM-001", + item_name="测试原材料", + item_type="RAW_MATERIAL", + specification="A-100", + material_grade="42CrMo", + unit_weight_kg=1, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.material = Material( + id=100, + item_id=self.material_item.id, + default_supplier_id=self.supplier.id, + purchase_calc_mode="WEIGHT", + min_purchase_qty=0, + purchase_multiple_qty=0, + purchase_multiple_weight_kg=0, + lead_time_days=0, + quality_rule=None, + created_at=now, + updated_at=now, + ) + self.db.add_all([self.supplier, self.material_item, self.material]) + self.db.commit() + + def tearDown(self) -> None: + self.db.close() + + def _payload(self, *, order_weight_kg: float = 12) -> PurchaseOrderCreate: + return PurchaseOrderCreate( + supplier_id=self.supplier.id, + order_date=date(2026, 6, 11), + expected_date=date(2026, 6, 20), + warning_lead_days=0, + purchaser_employee_id=None, + target_warehouse_type="RAW", + sales_order_ids=[], + tax_rate=0.13, + remark="采购归档测试", + items=[ + PurchaseOrderItemCreate( + material_item_id=self.material_item.id, + order_weight_kg=order_weight_kg, + unit_price=100, + remark="测试明细", + ) + ], + ) + + def _archive_result(self, *, business_id: int, document_no: str, archive_version: int = 1) -> DocumentArchiveGenerateResult: + return DocumentArchiveGenerateResult( + business_id=business_id, + document_type="采购订单", + document_no=document_no, + archive_status="已归档", + archive_version=archive_version, + archive_error_message=None, + ) + + def test_create_purchase_order_generates_archive_and_returns_archive_metadata(self) -> None: + def fake_generate_archive( + db: Session, + document_type: str, + business_id: int, + ) -> DocumentArchiveGenerateResult: + db.add( + DocumentArchive( + document_type=document_type, + business_id=business_id, + document_no="采购2026-00001", + archive_version=1, + template_version="单据纸面V1", + file_format="PDF", + file_name="采购2026-00001.pdf", + file_path="/tmp/采购2026-00001.pdf", + file_hash="abc123", + status="已归档", + error_message=None, + created_by=None, + created_at=datetime(2026, 6, 11, 10, 30, 0), + updated_at=datetime(2026, 6, 11, 10, 30, 0), + ) + ) + db.commit() + return self._archive_result(business_id=business_id, document_no="采购2026-00001") + + with ( + patch("app.api.routes.purchase.ensure_employee_has_permission"), + patch("app.api.routes.purchase.generate_document_archive", side_effect=fake_generate_archive) as archive_mock, + ): + result = create_purchase_order(self._payload(), self.db) + + archive_mock.assert_called_once_with(self.db, "采购订单", int(result.purchase_order_id)) + self.assertEqual(result.archive_status, "已归档") + self.assertEqual(result.archive_version, 1) + self.assertIsNone(result.archive_error_message) + + saved_archive = self.db.scalar(select(DocumentArchive).where(DocumentArchive.business_id == result.purchase_order_id)) + self.assertIsNotNone(saved_archive) + + def test_create_purchase_order_returns_failed_archive_metadata_when_archive_generation_raises(self) -> None: + with ( + patch("app.api.routes.purchase.ensure_employee_has_permission"), + patch("app.api.routes.purchase.generate_document_archive", side_effect=RuntimeError("renderer exploded")), + ): + result = create_purchase_order(self._payload(), self.db) + + self.assertIsNotNone(result.purchase_order_id) + self.assertTrue(result.po_no) + self.assertEqual(result.archive_status, "归档失败") + self.assertIsNone(result.archive_version) + self.assertTrue(result.archive_error_message and result.archive_error_message.startswith("归档失败:")) + + def test_list_purchase_orders_uses_pdf_archive_when_same_version_has_other_format_without_duplicates(self) -> None: + with ( + patch("app.api.routes.purchase.ensure_employee_has_permission"), + patch( + "app.api.routes.purchase.generate_document_archive", + return_value=self._archive_result(business_id=1, document_no="采购2026-00001"), + ), + ): + result = create_purchase_order(self._payload(), self.db) + + self.db.add_all( + [ + DocumentArchive( + document_type="采购订单", + business_id=result.purchase_order_id, + document_no=result.po_no, + archive_version=1, + template_version="单据纸面V1", + file_format="PDF", + file_name="order.pdf", + file_path="/tmp/order.pdf", + file_hash="pdf", + status="已归档", + error_message=None, + created_by=None, + created_at=datetime(2026, 6, 11, 10, 30, 0), + updated_at=datetime(2026, 6, 11, 10, 30, 0), + ), + DocumentArchive( + document_type="采购订单", + business_id=result.purchase_order_id, + document_no=result.po_no, + archive_version=1, + template_version="单据纸面V1", + file_format="DOCX", + file_name="order.docx", + file_path="/tmp/order.docx", + file_hash="docx", + status="DOCX不应显示", + error_message="wrong format", + created_by=None, + created_at=datetime(2026, 6, 11, 10, 35, 0), + updated_at=datetime(2026, 6, 11, 10, 35, 0), + ), + ] + ) + self.db.commit() + + rows = list_purchase_orders(limit=200, db=self.db) + + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0].archive_status, "已归档") + self.assertEqual(rows[0].archive_version, 1) + self.assertIsNone(rows[0].archive_error_message) + + def test_update_purchase_order_generates_archive_and_returns_archive_metadata(self) -> None: + with ( + patch("app.api.routes.purchase.ensure_employee_has_permission"), + patch( + "app.api.routes.purchase.generate_document_archive", + return_value=self._archive_result(business_id=1, document_no="采购2026-00001"), + ), + ): + created = create_purchase_order(self._payload(), self.db) + + update_payload = PurchaseOrderUpdate.model_validate(self._payload(order_weight_kg=18).model_dump()) + with ( + patch("app.api.routes.purchase.ensure_employee_has_permission"), + patch( + "app.api.routes.purchase.generate_document_archive", + return_value=self._archive_result( + business_id=created.purchase_order_id, + document_no=created.po_no, + archive_version=2, + ), + ) as archive_mock, + ): + updated = update_purchase_order(created.purchase_order_id, update_payload, self.db) + + archive_mock.assert_called_once_with(self.db, "采购订单", int(created.purchase_order_id)) + self.assertEqual(updated.archive_status, "已归档") + self.assertEqual(updated.archive_version, 2) + self.assertEqual(updated.total_order_weight_kg, 18) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_purchase_order_multi_sales_links.py b/backend/tests/test_purchase_order_multi_sales_links.py new file mode 100644 index 0000000..e0a2b35 --- /dev/null +++ b/backend/tests/test_purchase_order_multi_sales_links.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import unittest +from datetime import date +from decimal import Decimal + +from fastapi import HTTPException +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.api.routes.purchase import create_purchase_order, update_purchase_order # noqa: E402 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Item, Material # noqa: E402 +from app.models.operations import PurchaseOrder, PurchaseOrderItem, PurchaseOrderSalesOrderLink, Supplier # noqa: E402 +from app.models.sales import Customer, SalesOrder, SalesOrderItem # noqa: E402 +from app.schemas.operations import PurchaseOrderCreate, PurchaseOrderItemCreate, PurchaseOrderUpdate # noqa: E402 + + +class PurchaseOrderMultiSalesLinksTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self.supplier = Supplier(id=1, supplier_code="SUP-MULTI-001", supplier_name="多订单供应商", short_name="多订单供应商", lead_time_days=0, default_tax_rate=Decimal("0"), status="ACTIVE") + self.customer = Customer(id=1, customer_code="CUS-MULTI-001", customer_name="多订单客户", short_name="客户", status="ACTIVE") + self.product = Item(id=1, item_code="FG-MULTI-001", item_name="多订单成品", item_type="FINISHED_GOOD", unit_weight_kg=Decimal("1"), status="ACTIVE") + self.material = Item(id=2, item_code="RM-MULTI-001", item_name="多订单原料", item_type="RAW_MATERIAL", unit_weight_kg=Decimal("1"), status="ACTIVE") + self.db.add_all([self.supplier, self.customer, self.product, self.material]) + self.db.flush() + self.db.add(Material(item_id=self.material.id, default_supplier_id=self.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)) + self.order1 = SalesOrder(id=1, order_no="销售2026-00001", customer_id=self.customer.id, order_date=date.today(), status="OPEN") + self.order2 = SalesOrder(id=2, order_no="销售2026-00002", customer_id=self.customer.id, order_date=date.today(), status="OPEN") + self.item1 = SalesOrderItem(id=1, sales_order_id=1, line_no=1, product_item_id=self.product.id, order_qty=Decimal("100"), delivered_qty=Decimal("0"), unit_price=Decimal("10"), line_amount=Decimal("1000"), status="OPEN") + self.item2 = SalesOrderItem(id=2, sales_order_id=2, line_no=1, product_item_id=self.product.id, order_qty=Decimal("200"), delivered_qty=Decimal("0"), unit_price=Decimal("10"), line_amount=Decimal("2000"), status="OPEN") + self.db.add_all([self.order1, self.order2, self.item1, self.item2]) + self.db.commit() + + def tearDown(self) -> None: + self.db.close() + + def test_purchase_order_can_link_multiple_sales_orders(self) -> None: + result = create_purchase_order( + PurchaseOrderCreate( + supplier_id=self.supplier.id, + expected_date=date.today(), + target_warehouse_type="RAW", + sales_order_ids=[self.order1.id, self.order2.id], + items=[PurchaseOrderItemCreate(material_item_id=self.material.id, order_weight_kg=Decimal("300"), unit_price=Decimal("6"))], + ), + db=self.db, + ) + + links = self.db.scalars(select(PurchaseOrderSalesOrderLink).order_by(PurchaseOrderSalesOrderLink.sales_order_id)).all() + self.assertEqual([link.sales_order_id for link in links], [self.order1.id, self.order2.id]) + self.assertEqual(result.source_type, "SALES_ORDER") + self.assertEqual(result.source_order_nos, "销售2026-00001、销售2026-00002") + + def test_purchase_order_recreates_missing_sales_order_link_table(self) -> None: + PurchaseOrderSalesOrderLink.__table__.drop(self.db.get_bind()) + + result = create_purchase_order( + PurchaseOrderCreate( + supplier_id=self.supplier.id, + expected_date=date.today(), + target_warehouse_type="RAW", + sales_order_ids=[self.order1.id], + items=[PurchaseOrderItemCreate(material_item_id=self.material.id, order_weight_kg=Decimal("300"), unit_price=Decimal("6"))], + ), + db=self.db, + ) + + links = self.db.scalars(select(PurchaseOrderSalesOrderLink)).all() + self.assertEqual(result.source_type, "SALES_ORDER") + self.assertEqual([link.sales_order_id for link in links], [self.order1.id]) + + def test_purchase_order_update_replaces_sales_order_links(self) -> None: + result = create_purchase_order( + PurchaseOrderCreate( + supplier_id=self.supplier.id, + expected_date=date.today(), + target_warehouse_type="RAW", + sales_order_ids=[self.order1.id, self.order2.id], + items=[PurchaseOrderItemCreate(material_item_id=self.material.id, order_weight_kg=Decimal("300"), unit_price=Decimal("6"))], + ), + db=self.db, + ) + + updated = update_purchase_order( + result.purchase_order_id, + PurchaseOrderUpdate( + supplier_id=self.supplier.id, + expected_date=date.today(), + target_warehouse_type="RAW", + sales_order_ids=[self.order2.id], + items=[PurchaseOrderItemCreate(material_item_id=self.material.id, order_weight_kg=Decimal("250"), unit_price=Decimal("6"))], + ), + db=self.db, + ) + + links = self.db.scalars(select(PurchaseOrderSalesOrderLink).order_by(PurchaseOrderSalesOrderLink.sales_order_id)).all() + self.assertEqual([link.sales_order_id for link in links], [self.order2.id]) + self.assertEqual(updated.source_type, "SALES_ORDER") + self.assertEqual(updated.source_order_nos, "销售2026-00002") + + def test_purchase_order_update_missing_sales_order_keeps_existing_links_visible(self) -> None: + result = create_purchase_order( + PurchaseOrderCreate( + supplier_id=self.supplier.id, + expected_date=date.today(), + target_warehouse_type="RAW", + sales_order_ids=[self.order1.id], + items=[PurchaseOrderItemCreate(material_item_id=self.material.id, order_weight_kg=Decimal("300"), unit_price=Decimal("6"))], + ), + db=self.db, + ) + + with self.assertRaises(HTTPException) as exc: + update_purchase_order( + result.purchase_order_id, + PurchaseOrderUpdate( + supplier_id=self.supplier.id, + expected_date=date.today(), + target_warehouse_type="RAW", + sales_order_ids=[999], + items=[PurchaseOrderItemCreate(material_item_id=self.material.id, order_weight_kg=Decimal("250"), unit_price=Decimal("6"))], + ), + db=self.db, + ) + + self.assertEqual(exc.exception.status_code, 404) + links = self.db.scalars(select(PurchaseOrderSalesOrderLink).where(PurchaseOrderSalesOrderLink.purchase_order_id == result.purchase_order_id)).all() + self.assertEqual([link.sales_order_id for link in links], [self.order1.id]) + order = self.db.get(PurchaseOrder, result.purchase_order_id) + self.assertEqual(float(order.total_amount), 1800) + items = self.db.scalars(select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == result.purchase_order_id)).all() + self.assertEqual([float(item.order_weight_kg) for item in items], [300]) + + def test_purchase_order_rejects_non_positive_sales_order_ids(self) -> None: + with self.assertRaises(HTTPException) as exc: + create_purchase_order( + PurchaseOrderCreate( + supplier_id=self.supplier.id, + expected_date=date.today(), + target_warehouse_type="RAW", + sales_order_ids=[0], + items=[PurchaseOrderItemCreate(material_item_id=self.material.id, order_weight_kg=Decimal("300"), unit_price=Decimal("6"))], + ), + db=self.db, + ) + + self.assertEqual(exc.exception.status_code, 400) diff --git a/backend/tests/test_purchase_order_no_generation.py b/backend/tests/test_purchase_order_no_generation.py new file mode 100644 index 0000000..8acee89 --- /dev/null +++ b/backend/tests/test_purchase_order_no_generation.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import unittest +from datetime import UTC, date, datetime + +from sqlalchemy import BigInteger, create_engine +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.api.routes.purchase import build_purchase_order_no # noqa: E402 +from app.models.base import Base # noqa: E402 +from app.models.operations import PurchaseOrder, Supplier # noqa: E402 + + +class PurchaseOrderNoGenerationTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self.supplier = Supplier( + id=1, + supplier_code="SUP-001", + supplier_name="测试供应商", + short_name="供应商", + lead_time_days=0, + default_tax_rate=0, + status="ACTIVE", + ) + self.db.add(self.supplier) + self.db.commit() + + def tearDown(self) -> None: + self.db.close() + + def _add_purchase_order(self, po_no: str) -> None: + now = datetime.now(UTC) + self.db.add( + PurchaseOrder( + po_no=po_no, + supplier_id=self.supplier.id, + order_date=date.today(), + expected_date=None, + purchaser_employee_id=None, + tax_rate=0, + total_amount=0, + status="OPEN", + remark=None, + created_at=now, + updated_at=now, + ) + ) + self.db.commit() + + def test_purchase_order_no_starts_from_named_year_sequence(self) -> None: + year = date.today().strftime("%Y") + + self.assertEqual(build_purchase_order_no(self.db, self.supplier), f"采购{year}-00001") + + def test_purchase_order_no_increments_matching_current_year_codes(self) -> None: + year = date.today().strftime("%Y") + previous_year = str(int(year) - 1) + self._add_purchase_order(f"采购{year}-00001") + self._add_purchase_order(f"采购{year}-00009") + self._add_purchase_order(f"采购{previous_year}-00099") + self._add_purchase_order(f"{year}-00099") + self._add_purchase_order("PO-NB-20260523-SUP001-001") + + self.assertEqual(build_purchase_order_no(self.db, self.supplier), f"采购{year}-00010") + + def test_purchase_order_no_ignores_malformed_same_year_codes(self) -> None: + year = date.today().strftime("%Y") + self._add_purchase_order(f"采购{year}-001") + self._add_purchase_order(f"采购{year}-000001") + self._add_purchase_order(f"采购{year}-ABCDE") + + self.assertEqual(build_purchase_order_no(self.db, self.supplier), f"采购{year}-00001") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_purchase_receipt_document_archive.py b/backend/tests/test_purchase_receipt_document_archive.py new file mode 100644 index 0000000..0663b88 --- /dev/null +++ b/backend/tests/test_purchase_receipt_document_archive.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +import unittest +from datetime import date, datetime +from unittest.mock import patch + +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_big_integer_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.document_archive # noqa: E402,F401 +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.api.routes.purchase import create_purchase_order, create_purchase_receipt, lock_purchase_order # noqa: E402 +from app.models.base import Base # noqa: E402 +from app.models.document_archive import DocumentArchive # noqa: E402 +from app.models.master_data import Item, Material, Warehouse # noqa: E402 +from app.models.operations import PurchaseOrderItem, Supplier # noqa: E402 +from app.models.org import Department, Employee, User # noqa: E402 +from app.schemas.document_archives import DocumentArchiveGenerateResult # noqa: E402 +from app.schemas.operations import ( # noqa: E402 + PurchaseOrderCreate, + PurchaseOrderItemCreate, + PurchaseReceiptCreate, + PurchaseReceiptItemCreate, +) +from app.services.auth import AuthContext # noqa: E402 + + +class PurchaseReceiptDocumentArchiveTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self._seed_master_data() + + def tearDown(self) -> None: + self.db.close() + + def _seed_master_data(self) -> None: + now = datetime(2026, 6, 11, 9, 0, 0) + self.department = Department( + id=1, + dept_code="D-WH-001", + dept_name="仓储部", + dept_type="WAREHOUSE", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.employee = Employee( + id=1, + employee_code="EMP-WH-001", + employee_name="仓库接收员", + dept_id=1, + mobile="13800000000", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.user = User( + id=1, + username="receipt_tester", + password_hash="x", + employee_id=1, + dept_id=1, + nickname="到货测试员", + is_super_admin=1, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.supplier = Supplier( + id=1, + supplier_code="SUP-RC-001", + supplier_name="到货供应商", + short_name="到货供应商", + lead_time_days=0, + default_tax_rate=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.material_item = Item( + id=10, + item_code="RM-RC-001", + item_name="到货原材料", + item_type="RAW_MATERIAL", + specification="A-200", + material_grade="45#", + unit_weight_kg=1, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.material = Material( + id=100, + item_id=self.material_item.id, + default_supplier_id=self.supplier.id, + purchase_calc_mode="WEIGHT", + min_purchase_qty=0, + purchase_multiple_qty=0, + purchase_multiple_weight_kg=0, + lead_time_days=0, + quality_rule=None, + created_at=now, + updated_at=now, + ) + self.warehouse = Warehouse( + id=1, + warehouse_code="WH-RAW", + warehouse_name="原材料库", + warehouse_type="RAW", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add_all([ + self.department, + self.employee, + self.user, + self.supplier, + self.material_item, + self.material, + self.warehouse, + ]) + self.db.commit() + + def _auth_context(self) -> AuthContext: + return AuthContext( + user=self.user, + employee=self.employee, + department=self.department, + role_codes=["ADMIN"], + role_names=["管理员"], + permission_codes=[], + ) + + def _create_locked_purchase_order(self) -> tuple[int, PurchaseOrderItem]: + with ( + patch("app.api.routes.purchase.ensure_employee_has_permission"), + patch( + "app.api.routes.purchase.generate_document_archive", + return_value=DocumentArchiveGenerateResult( + business_id=1, + document_type="采购订单", + document_no="采购2026-00001", + archive_status="已归档", + archive_version=1, + archive_error_message=None, + ), + ), + ): + order = create_purchase_order( + PurchaseOrderCreate( + supplier_id=self.supplier.id, + order_date=date(2026, 6, 11), + expected_date=date(2026, 6, 20), + target_warehouse_type="RAW", + items=[ + PurchaseOrderItemCreate( + material_item_id=self.material_item.id, + order_weight_kg=120, + unit_price=6.8, + ) + ], + ), + db=self.db, + ) + lock_purchase_order(order.purchase_order_id, db=self.db) + + po_item = self.db.scalar(select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == order.purchase_order_id)) + self.assertIsNotNone(po_item) + return int(order.purchase_order_id), po_item + + def _receipt_payload(self, purchase_order_id: int, po_item: PurchaseOrderItem) -> PurchaseReceiptCreate: + return PurchaseReceiptCreate( + purchase_order_id=purchase_order_id, + warehouse_id=self.warehouse.id, + receipt_date=datetime(2026, 6, 11, 10, 0, 0), + receiver_employee_id=self.employee.id, + waybill_no="WB-RC-001", + freight_amount=88.8, + remark="到货归档测试", + items=[ + PurchaseReceiptItemCreate( + purchase_order_item_id=po_item.id, + material_item_id=self.material_item.id, + received_weight_kg=60, + unit_cost=6.8, + ) + ], + ) + + def _archive_result(self, *, business_id: int, document_no: str) -> DocumentArchiveGenerateResult: + return DocumentArchiveGenerateResult( + business_id=business_id, + document_type="到货入库单", + document_no=document_no, + archive_status="已归档", + archive_version=1, + archive_error_message=None, + ) + + def test_create_purchase_receipt_generates_archive_and_returns_archive_metadata(self) -> None: + purchase_order_id, po_item = self._create_locked_purchase_order() + + def fake_generate_archive( + db: Session, + document_type: str, + business_id: int, + created_by: int | None = None, + ) -> DocumentArchiveGenerateResult: + db.add( + DocumentArchive( + document_type=document_type, + business_id=business_id, + document_no="到货2026-00001", + archive_version=1, + template_version="单据纸面V1", + file_format="PDF", + file_name="到货2026-00001.pdf", + file_path="/tmp/到货2026-00001.pdf", + file_hash="receipt123", + status="已归档", + error_message=None, + created_by=created_by, + created_at=datetime(2026, 6, 11, 10, 30, 0), + updated_at=datetime(2026, 6, 11, 10, 30, 0), + ) + ) + db.commit() + return self._archive_result(business_id=business_id, document_no="到货2026-00001") + + with ( + patch("app.api.routes.purchase.ensure_employee_has_permission"), + patch("app.api.routes.purchase.generate_document_archive", side_effect=fake_generate_archive) as archive_mock, + ): + result = create_purchase_receipt(self._receipt_payload(purchase_order_id, po_item), context=self._auth_context(), db=self.db) + + archive_mock.assert_called_once_with(self.db, "到货入库单", int(result.receipt_id), created_by=1) + self.assertEqual(result.archive_status, "已归档") + self.assertEqual(result.archive_version, 1) + self.assertIsNone(result.archive_error_message) + + saved_archive = self.db.scalar( + select(DocumentArchive).where( + DocumentArchive.document_type == "到货入库单", + DocumentArchive.business_id == result.receipt_id, + ) + ) + self.assertIsNotNone(saved_archive) + + def test_create_purchase_receipt_returns_failed_archive_metadata_when_archive_generation_raises(self) -> None: + purchase_order_id, po_item = self._create_locked_purchase_order() + + with ( + patch("app.api.routes.purchase.ensure_employee_has_permission"), + patch("app.api.routes.purchase.generate_document_archive", side_effect=RuntimeError("renderer exploded")), + ): + result = create_purchase_receipt(self._receipt_payload(purchase_order_id, po_item), context=self._auth_context(), db=self.db) + + self.assertIsNotNone(result.receipt_id) + self.assertTrue(result.receipt_no) + self.assertEqual(result.archive_status, "归档失败") + self.assertIsNone(result.archive_version) + self.assertTrue(result.archive_error_message and result.archive_error_message.startswith("归档失败:")) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_purchase_receipt_no_generation.py b/backend/tests/test_purchase_receipt_no_generation.py new file mode 100644 index 0000000..c0b8205 --- /dev/null +++ b/backend/tests/test_purchase_receipt_no_generation.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import unittest +from datetime import UTC, date, datetime + +from sqlalchemy import BigInteger, create_engine +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.api.routes.purchase import build_purchase_receipt_no # noqa: E402 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Warehouse # noqa: E402 +from app.models.operations import PurchaseOrder, PurchaseReceipt, Supplier # noqa: E402 + + +class PurchaseReceiptNoGenerationTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self.supplier = Supplier( + id=1, + supplier_code="SUP-RECEIPT-001", + supplier_name="入库编号供应商", + short_name="入库编号供应商", + lead_time_days=0, + default_tax_rate=0, + status="ACTIVE", + ) + self.warehouse = Warehouse( + id=1, + warehouse_code="WH-RM", + warehouse_name="原材料库", + warehouse_type="RAW", + status="ACTIVE", + ) + self.order = PurchaseOrder( + id=1, + po_no="采购2026-00001", + supplier_id=1, + order_date=date.today(), + expected_date=None, + purchaser_employee_id=None, + tax_rate=0, + total_amount=0, + status="LOCKED", + remark=None, + ) + self.db.add_all([self.supplier, self.warehouse, self.order]) + self.db.commit() + + def tearDown(self) -> None: + self.db.close() + + def _add_receipt(self, receipt_no: str) -> None: + now = datetime.now(UTC) + self.db.add( + PurchaseReceipt( + receipt_no=receipt_no, + purchase_order_id=self.order.id, + warehouse_id=self.warehouse.id, + receipt_date=now, + receiver_employee_id=None, + supplier_delivery_no=None, + status="PENDING_QC", + remark=None, + created_at=now, + updated_at=now, + ) + ) + self.db.commit() + + def test_purchase_receipt_no_starts_from_named_year_sequence(self) -> None: + year = date.today().strftime("%Y") + + self.assertEqual(build_purchase_receipt_no(self.db, self.order), f"入库{year}-00001") + + def test_purchase_receipt_no_increments_matching_current_year_codes(self) -> None: + year = date.today().strftime("%Y") + previous_year = str(int(year) - 1) + self._add_receipt(f"入库{year}-00001") + self._add_receipt(f"入库{year}-00009") + self._add_receipt(f"入库{previous_year}-00099") + self._add_receipt(f"RCV-NB-{year}0523-PO-001") + + self.assertEqual(build_purchase_receipt_no(self.db, self.order), f"入库{year}-00010") + + def test_purchase_receipt_no_ignores_malformed_same_year_codes(self) -> None: + year = date.today().strftime("%Y") + self._add_receipt(f"入库{year}-001") + self._add_receipt(f"入库{year}-000001") + self._add_receipt(f"入库{year}-ABCDE") + + self.assertEqual(build_purchase_receipt_no(self.db, self.order), f"入库{year}-00001") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_quality_inspection_document_archive.py b/backend/tests/test_quality_inspection_document_archive.py new file mode 100644 index 0000000..db3c732 --- /dev/null +++ b/backend/tests/test_quality_inspection_document_archive.py @@ -0,0 +1,331 @@ +from __future__ import annotations + +import unittest +from datetime import date, datetime +from unittest.mock import patch + +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_big_integer_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.document_archive # noqa: E402,F401 +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.api.routes.quality import inspect_receipt_item # noqa: E402 +from app.models.base import Base # noqa: E402 +from app.models.document_archive import DocumentArchive # noqa: E402 +from app.models.master_data import Item, StockBalance, Warehouse # noqa: E402 +from app.models.operations import PurchaseOrder, PurchaseOrderItem, PurchaseReceipt, PurchaseReceiptItem, StockLot, Supplier # noqa: E402 +from app.models.org import Department, Employee, User # noqa: E402 +from app.schemas.document_archives import DocumentArchiveGenerateResult # noqa: E402 +from app.schemas.operations import QualityInspectionAction # noqa: E402 +from app.services.auth import AuthContext # noqa: E402 + + +class QualityInspectionDocumentArchiveTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self._seed_inspection_data() + + def tearDown(self) -> None: + self.db.close() + + def _seed_inspection_data(self) -> None: + now = datetime(2026, 6, 11, 9, 0, 0) + self.department = Department( + id=1, + dept_code="D-QC-001", + dept_name="质检部", + dept_type="QUALITY", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.employee = Employee( + id=1, + employee_code="EMP-QC-001", + employee_name="质检员王工", + dept_id=1, + mobile="13800000001", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.user = User( + id=1, + username="quality_tester", + password_hash="x", + employee_id=1, + dept_id=1, + nickname="质检测试员", + is_super_admin=1, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.supplier = Supplier( + id=10, + supplier_code="SUP-QC-001", + supplier_name="质检供应商", + short_name="质检供应商", + lead_time_days=0, + default_tax_rate=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.material_item = Item( + id=20, + item_code="RM-QC-001", + item_name="质检原材料", + item_type="RAW_MATERIAL", + specification="D60", + material_grade="45#", + unit_weight_kg=1, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.warehouse = Warehouse( + id=30, + warehouse_code="WH-RAW", + warehouse_name="原材料库", + warehouse_type="RAW", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.purchase_order = PurchaseOrder( + id=40, + po_no="采购2026-00001", + supplier_id=self.supplier.id, + order_date=date(2026, 6, 11), + expected_date=date(2026, 6, 20), + warning_lead_days=0, + purchaser_employee_id=None, + target_warehouse_type="RAW", + tax_rate=0.13, + total_amount=660, + status="PENDING_QC", + remark=None, + created_at=now, + updated_at=now, + ) + self.purchase_order_item = PurchaseOrderItem( + id=50, + purchase_order_id=self.purchase_order.id, + line_no=1, + material_item_id=self.material_item.id, + source_demand_id=None, + order_qty=0, + order_weight_kg=100, + received_qty=0, + received_weight_kg=0, + unit_price=6.6, + line_amount=660, + status="OPEN", + remark=None, + created_at=now, + updated_at=now, + ) + self.receipt = PurchaseReceipt( + id=60, + receipt_no="入库2026-00001", + purchase_order_id=self.purchase_order.id, + warehouse_id=self.warehouse.id, + receipt_date=datetime(2026, 6, 12, 10, 0, 0), + receiver_employee_id=None, + supplier_delivery_no=None, + logistics_waybill_no="YD-QC-001", + logistics_freight_amount=50, + logistics_photo_url=None, + status="PENDING_QC", + remark=None, + created_at=now, + updated_at=now, + ) + self.receipt_item = PurchaseReceiptItem( + id=70, + receipt_id=self.receipt.id, + line_no=1, + purchase_order_item_id=self.purchase_order_item.id, + material_item_id=self.material_item.id, + location_id=None, + lot_no="YL0001", + merge_to_lot_id=None, + received_qty=0, + received_weight_kg=100, + accepted_qty=0, + accepted_weight_kg=0, + unit_cost=6.6, + status="PENDING_QC", + remark=None, + created_at=now, + updated_at=now, + ) + self.stock_lot = StockLot( + id=80, + lot_no=self.receipt_item.lot_no, + parent_lot_id=None, + lot_role="INBOUND_RAW", + material_sub_batch_no=None, + item_id=self.material_item.id, + warehouse_id=self.warehouse.id, + location_id=None, + source_doc_type="PURCHASE_RECEIPT", + source_doc_id=self.receipt.id, + source_line_id=self.receipt_item.id, + source_material_lot_id=None, + source_material_sub_batch_no=None, + source_material_summary=None, + inbound_qty=0, + inbound_weight_kg=100, + remaining_qty=0, + remaining_weight_kg=100, + locked_qty=0, + locked_weight_kg=100, + unit_cost=6.6, + production_date=None, + expire_date=None, + quality_status="PENDING_QC", + logistics_waybill_no="YD-QC-001", + logistics_freight_amount=50, + logistics_photo_url=None, + status="LOCKED", + remark="到货待检", + created_at=now, + updated_at=now, + ) + self.balance = StockBalance( + id=90, + item_id=self.material_item.id, + warehouse_id=self.warehouse.id, + location_id=None, + qty_on_hand=0, + weight_on_hand_kg=100, + qty_available=0, + weight_available_kg=0, + qty_allocated=0, + weight_allocated_kg=0, + avg_unit_cost=6.6, + created_at=now, + updated_at=now, + ) + self.db.add_all([ + self.department, + self.employee, + self.user, + self.supplier, + self.material_item, + self.warehouse, + self.purchase_order, + self.purchase_order_item, + self.receipt, + self.receipt_item, + self.stock_lot, + self.balance, + ]) + self.db.commit() + + def _auth_context(self) -> AuthContext: + return AuthContext( + user=self.user, + employee=self.employee, + department=self.department, + role_codes=["ADMIN"], + role_names=["管理员"], + permission_codes=[], + ) + + def _archive_result(self, *, business_id: int) -> DocumentArchiveGenerateResult: + return DocumentArchiveGenerateResult( + business_id=business_id, + document_type="质量校验单", + document_no="质检-入库2026-00001-1", + archive_status="已归档", + archive_version=1, + archive_error_message=None, + ) + + def test_inspect_receipt_item_generates_quality_archive_and_returns_metadata(self) -> None: + def fake_generate_archive( + db: Session, + document_type: str, + business_id: int, + created_by: int | None = None, + ) -> DocumentArchiveGenerateResult: + db.add( + DocumentArchive( + document_type=document_type, + business_id=business_id, + document_no="质检-入库2026-00001-1", + archive_version=1, + template_version="单据纸面V1", + file_format="PDF", + file_name="质检-入库2026-00001-1.pdf", + file_path="/tmp/质检-入库2026-00001-1.pdf", + file_hash="quality123", + status="已归档", + error_message=None, + created_by=created_by, + created_at=datetime(2026, 6, 12, 10, 30, 0), + updated_at=datetime(2026, 6, 12, 10, 30, 0), + ) + ) + db.commit() + return self._archive_result(business_id=business_id) + + with patch("app.api.routes.quality.generate_document_archive", side_effect=fake_generate_archive) as archive_mock: + result = inspect_receipt_item( + self.receipt_item.id, + QualityInspectionAction(result="PASS", remark="表面无划伤"), + context=self._auth_context(), + db=self.db, + ) + + archive_mock.assert_called_once_with(self.db, "质量校验单", int(self.receipt_item.id), created_by=1) + self.assertEqual(result.status, "PASSED") + self.assertEqual(result.quality_status, "PASS") + self.assertEqual(result.archive_status, "已归档") + self.assertEqual(result.archive_version, 1) + self.assertIsNone(result.archive_error_message) + + saved_archive = self.db.scalar( + select(DocumentArchive).where( + DocumentArchive.document_type == "质量校验单", + DocumentArchive.business_id == self.receipt_item.id, + ) + ) + self.assertIsNotNone(saved_archive) + + def test_inspect_receipt_item_returns_failed_archive_metadata_when_archive_generation_raises(self) -> None: + with patch("app.api.routes.quality.generate_document_archive", side_effect=RuntimeError("renderer exploded")): + result = inspect_receipt_item( + self.receipt_item.id, + QualityInspectionAction(result="REJECT", remark="硬度不合格"), + context=self._auth_context(), + db=self.db, + ) + + self.assertEqual(result.status, "REJECTED") + self.assertEqual(result.archive_status, "归档失败") + self.assertIsNone(result.archive_version) + self.assertTrue(result.archive_error_message and result.archive_error_message.startswith("归档失败:")) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_raw_material_return_outbound.py b/backend/tests/test_raw_material_return_outbound.py new file mode 100644 index 0000000..8aa8141 --- /dev/null +++ b/backend/tests/test_raw_material_return_outbound.py @@ -0,0 +1,760 @@ +from __future__ import annotations + +import unittest +from datetime import UTC, datetime +from decimal import Decimal + +from sqlalchemy import BigInteger, create_engine, event, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.document_archive # noqa: E402,F401 +import app.models.master_data # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.document_archive import DocumentArchive # noqa: E402 +from app.models.master_data import Item, StockBalance, Warehouse # noqa: E402 +from app.models.operations import ( # noqa: E402 + InventoryTxn, + PurchaseOrder, + PurchaseOrderItem, + PurchaseReceipt, + PurchaseReceiptItem, + PurchaseReturn, + PurchaseReturnItem, + StockLot, + Supplier, + WarehouseLocation, +) +from app.models.org import Department, Employee, User # noqa: E402 +from app.services.auth import AuthContext # noqa: E402 +from app.services.document_archives import ARCHIVE_STATUS_READY, DOCUMENT_TYPE_WAREHOUSE_OPERATION # noqa: E402 + + +class RawMaterialReturnOutboundTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + event.listen(engine, "connect", self._enable_sqlite_foreign_keys) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self._seed() + + def tearDown(self) -> None: + self.db.close() + + @staticmethod + def _enable_sqlite_foreign_keys(dbapi_connection, _connection_record) -> None: + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + def _seed(self) -> None: + now = datetime.now(UTC) + department = Department( + id=1, + dept_code="D-WH", + dept_name="仓库部", + dept_type="WAREHOUSE", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + employee = Employee( + id=1, + employee_code="EMP-WH-001", + employee_name="仓管员", + dept_id=1, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + user = User( + id=1, + username="tester", + password_hash="x", + employee_id=1, + dept_id=1, + nickname="测试员", + is_super_admin=1, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + supplier = Supplier( + id=1, + supplier_code="SUP-RAW-001", + supplier_name="原材料供应商", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + warehouse = Warehouse( + id=1, + warehouse_code="WH-RAW-01", + warehouse_name="原材料库", + warehouse_type="RAW", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + location = WarehouseLocation( + id=1, + warehouse_id=1, + location_code="RM-A-01", + location_name="原料主库位", + is_default=1, + is_locked=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + item = Item( + id=1, + item_code="RAW-00001", + item_name="冷轧钢板", + item_type="RAW_MATERIAL", + unit_weight_kg=Decimal("1"), + safety_stock_weight_kg=Decimal("0"), + scrap_sale_price=Decimal("0"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + purchase_order = PurchaseOrder( + id=1, + po_no="PO-2026-00001", + supplier_id=1, + order_date=now.date(), + expected_date=now.date(), + target_warehouse_type="RAW", + tax_rate=Decimal("0"), + total_amount=Decimal("500"), + status="CONFIRMED", + created_at=now, + updated_at=now, + ) + purchase_order_item = PurchaseOrderItem( + id=1, + purchase_order_id=1, + line_no=1, + material_item_id=1, + order_qty=Decimal("100"), + order_weight_kg=Decimal("100"), + received_qty=Decimal("100"), + received_weight_kg=Decimal("100"), + unit_price=Decimal("5"), + line_amount=Decimal("500"), + status="RECEIVED", + created_at=now, + updated_at=now, + ) + purchase_receipt = PurchaseReceipt( + id=1, + receipt_no="RCV-2026-00001", + purchase_order_id=1, + warehouse_id=1, + receipt_date=now, + supplier_delivery_no="DLV-001", + status="CONFIRMED", + created_at=now, + updated_at=now, + ) + purchase_receipt_item = PurchaseReceiptItem( + id=1, + receipt_id=1, + line_no=1, + purchase_order_item_id=1, + material_item_id=1, + location_id=1, + lot_no="LOT-RAW-001", + received_qty=Decimal("100"), + received_weight_kg=Decimal("100"), + accepted_qty=Decimal("100"), + accepted_weight_kg=Decimal("100"), + unit_cost=Decimal("5"), + status="PASSED", + created_at=now, + updated_at=now, + ) + stock_lot = StockLot( + id=1, + lot_no="LOT-RAW-001", + lot_role="INVENTORY", + item_id=1, + warehouse_id=1, + location_id=1, + source_doc_type="PURCHASE_RECEIPT", + source_doc_id=1, + source_line_id=1, + inbound_qty=Decimal("100"), + inbound_weight_kg=Decimal("100"), + remaining_qty=Decimal("100"), + remaining_weight_kg=Decimal("100"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("5"), + quality_status="PASS", + status="AVAILABLE", + created_at=now, + updated_at=now, + ) + stock_balance = StockBalance( + id=1, + item_id=1, + warehouse_id=1, + location_id=1, + qty_on_hand=Decimal("100"), + weight_on_hand_kg=Decimal("100"), + qty_available=Decimal("100"), + weight_available_kg=Decimal("100"), + qty_allocated=Decimal("0"), + weight_allocated_kg=Decimal("0"), + avg_unit_cost=Decimal("5"), + created_at=now, + updated_at=now, + ) + self.db.add_all([department, supplier, warehouse, item]) + self.db.flush() + self.db.add_all([employee, user, location, purchase_order]) + self.db.flush() + self.db.add_all([purchase_order_item, purchase_receipt]) + self.db.flush() + self.db.add_all([purchase_receipt_item, stock_lot, stock_balance]) + self.db.commit() + + def test_purchase_return_tables_exist(self) -> None: + now = datetime.now(UTC) + purchase_return = PurchaseReturn( + id=1, + return_no="退货2026-00001", + purchase_order_id=1, + warehouse_id=1, + return_date=now, + operator_user_id=1, + logistics_waybill_no="YT123", + logistics_freight_amount=None, + logistics_photo_url=None, + reason="性能不合格", + status="CONFIRMED", + remark="测试退货", + created_at=now, + updated_at=now, + ) + self.db.add(purchase_return) + self.db.flush() + + purchase_return_item = PurchaseReturnItem( + id=1, + purchase_return_id=purchase_return.id, + line_no=1, + purchase_order_item_id=1, + material_item_id=1, + lot_id=1, + return_weight_kg=Decimal("20"), + unit_cost=Decimal("5"), + amount=Decimal("100"), + reason="性能不合格", + status="CONFIRMED", + created_at=now, + updated_at=now, + ) + self.db.add(purchase_return_item) + self.db.commit() + + saved = self.db.get(PurchaseReturn, purchase_return.id) + saved_item = self.db.get(PurchaseReturnItem, purchase_return_item.id) + + self.assertEqual(saved.status, "CONFIRMED") + self.assertEqual(float(saved_item.return_weight_kg), 20) + + def _context(self) -> AuthContext: + user = self.db.get(User, 1) + employee = self.db.get(Employee, 1) + department = self.db.get(Department, 1) + return AuthContext( + user=user, + employee=employee, + department=department, + role_codes=["ADMIN"], + role_names=["管理员"], + permission_codes=[], + ) + + def test_raw_material_return_outbound_deducts_lot_balance_and_creates_records(self) -> None: + from app.api.routes.inventory import create_raw_material_return_outbound + from app.schemas.operations import RawMaterialReturnOutboundCreate, RawMaterialReturnOutboundItemCreate + + payload = RawMaterialReturnOutboundCreate( + warehouse_id=1, + waybill_no="YT123456", + freight_amount=None, + reason="性能不合格", + remark="退回供应商", + items=[ + RawMaterialReturnOutboundItemCreate( + lot_id=1, + purchase_order_item_id=1, + return_weight_kg=80, + reason="硬度不达标", + ) + ], + ) + + result = create_raw_material_return_outbound(payload, context=self._context(), db=self.db) + + self.assertEqual(result.status, "CONFIRMED") + self.assertEqual(result.logistics_waybill_no, "YT123456") + self.assertEqual(float(result.total_return_weight_kg), 80) + self.assertEqual(len(result.items), 1) + self.assertEqual(result.archive_status, ARCHIVE_STATUS_READY) + self.assertEqual(result.archive_document_type, DOCUMENT_TYPE_WAREHOUSE_OPERATION) + self.assertIsNotNone(result.archive_business_id) + + self.db.expire_all() + lot = self.db.get(StockLot, 1) + balance = self.db.get(StockBalance, 1) + self.assertEqual(float(lot.remaining_weight_kg), 20) + self.assertEqual(float(balance.weight_on_hand_kg), 20) + self.assertEqual(float(balance.weight_available_kg), 20) + + txn = self.db.query(InventoryTxn).filter_by(txn_type="PURCHASE_RETURN_OUT").one() + self.assertEqual(txn.source_doc_type, "PURCHASE_RETURN") + self.assertEqual(txn.source_doc_id, result.purchase_return_id) + self.assertEqual(txn.source_line_id, result.items[0].purchase_return_item_id) + self.assertEqual(float(txn.weight_change_kg), -80) + self.assertEqual(txn.logistics_waybill_no, "YT123456") + archive = self.db.scalar( + select(DocumentArchive).where( + DocumentArchive.document_type == DOCUMENT_TYPE_WAREHOUSE_OPERATION, + DocumentArchive.business_id == txn.id, + ) + ) + self.assertIsNotNone(archive) + self.assertEqual(archive.status, ARCHIVE_STATUS_READY) + + def test_purchase_order_query_preserves_status_and_marks_has_return(self) -> None: + from app.api.routes.inventory import create_raw_material_return_outbound + from app.schemas.operations import RawMaterialReturnOutboundCreate, RawMaterialReturnOutboundItemCreate + from app.services.operations import get_purchase_order_items_query, get_purchase_order_query + + create_raw_material_return_outbound( + RawMaterialReturnOutboundCreate( + warehouse_id=1, + waybill_no="YT123456", + reason="性能不合格", + items=[ + RawMaterialReturnOutboundItemCreate( + lot_id=1, + purchase_order_item_id=1, + return_weight_kg=80, + ) + ], + ), + context=self._context(), + db=self.db, + ) + + order_row = self.db.execute(get_purchase_order_query(limit=1).where(PurchaseOrder.id == 1)).mappings().first() + item_row = self.db.execute(get_purchase_order_items_query(limit=1, purchase_order_id=1)).mappings().first() + + self.assertEqual(order_row["display_status"], "RECEIVED") + self.assertTrue(order_row["has_return"]) + self.assertEqual(float(order_row["total_return_weight_kg"]), 80) + self.assertTrue(item_row["has_return"]) + self.assertEqual(float(item_row["returned_weight_kg"]), 80) + self.assertEqual(float(item_row["net_received_weight_kg"]), 20) + + def test_raw_material_return_requires_waybill_or_photo(self) -> None: + from fastapi import HTTPException + + from app.api.routes.inventory import create_raw_material_return_outbound + from app.schemas.operations import RawMaterialReturnOutboundCreate, RawMaterialReturnOutboundItemCreate + + payload = RawMaterialReturnOutboundCreate( + warehouse_id=1, + freight_amount=None, + reason="性能不合格", + items=[ + RawMaterialReturnOutboundItemCreate( + lot_id=1, + purchase_order_item_id=1, + return_weight_kg=80, + ) + ], + ) + + with self.assertRaises(HTTPException) as exc: + create_raw_material_return_outbound(payload, context=self._context(), db=self.db) + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("运单号或上传辅助照片", str(exc.exception.detail)) + + def test_raw_material_return_rejects_weight_above_remaining(self) -> None: + from fastapi import HTTPException + + from app.api.routes.inventory import create_raw_material_return_outbound + from app.schemas.operations import RawMaterialReturnOutboundCreate, RawMaterialReturnOutboundItemCreate + + payload = RawMaterialReturnOutboundCreate( + warehouse_id=1, + order_photo_url="/inventory/logistics-photos/test.jpg", + freight_amount=None, + reason="性能不合格", + items=[ + RawMaterialReturnOutboundItemCreate( + lot_id=1, + purchase_order_item_id=1, + return_weight_kg=101, + ) + ], + ) + + with self.assertRaises(HTTPException) as exc: + create_raw_material_return_outbound(payload, context=self._context(), db=self.db) + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("退货重量不能超过批次可用重量", str(exc.exception.detail)) + + def test_raw_material_return_rejects_weight_above_unlocked_available_weight(self) -> None: + from fastapi import HTTPException + + from app.api.routes.inventory import create_raw_material_return_outbound + from app.schemas.operations import RawMaterialReturnOutboundCreate, RawMaterialReturnOutboundItemCreate + + lot = self.db.get(StockLot, 1) + lot.locked_weight_kg = Decimal("30") + self.db.commit() + + payload = RawMaterialReturnOutboundCreate( + warehouse_id=1, + order_photo_url="/inventory/logistics-photos/test.jpg", + freight_amount=None, + reason="性能不合格", + items=[ + RawMaterialReturnOutboundItemCreate( + lot_id=1, + purchase_order_item_id=1, + return_weight_kg=80, + ) + ], + ) + + with self.assertRaises(HTTPException) as exc: + create_raw_material_return_outbound(payload, context=self._context(), db=self.db) + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("退货重量不能超过批次可用重量", str(exc.exception.detail)) + + def test_raw_material_return_conditional_deduction_rechecks_locked_weight_after_validation(self) -> None: + from fastapi import HTTPException + from unittest.mock import patch + + from app.api.routes import inventory as inventory_routes + from app.schemas.operations import RawMaterialReturnOutboundCreate, RawMaterialReturnOutboundItemCreate + + payload = RawMaterialReturnOutboundCreate( + warehouse_id=1, + order_photo_url="/inventory/logistics-photos/test.jpg", + freight_amount=None, + reason="性能不合格", + items=[ + RawMaterialReturnOutboundItemCreate( + lot_id=1, + purchase_order_item_id=1, + return_weight_kg=80, + ) + ], + ) + + def lock_lot_after_validation(_db: Session) -> str: + lot = self.db.get(StockLot, 1) + lot.locked_weight_kg = Decimal("30") + self.db.flush() + return "退货2026-00099" + + with patch.object(inventory_routes, "_build_purchase_return_no", side_effect=lock_lot_after_validation): + with self.assertRaises(HTTPException) as exc: + inventory_routes.create_raw_material_return_outbound(payload, context=self._context(), db=self.db) + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("退货重量不能超过批次可用重量", str(exc.exception.detail)) + self.assertEqual(self.db.query(PurchaseReturn).count(), 0) + + def test_raw_material_return_rechecks_purchase_line_returnable_after_validation(self) -> None: + from fastapi import HTTPException + from unittest.mock import patch + + from app.api.routes import inventory as inventory_routes + from app.schemas.operations import RawMaterialReturnOutboundCreate, RawMaterialReturnOutboundItemCreate + + payload = RawMaterialReturnOutboundCreate( + warehouse_id=1, + order_photo_url="/inventory/logistics-photos/test.jpg", + freight_amount=None, + reason="性能不合格", + items=[ + RawMaterialReturnOutboundItemCreate( + lot_id=1, + purchase_order_item_id=1, + return_weight_kg=80, + ) + ], + ) + + original_lock = inventory_routes._lock_purchase_order_items_for_return + + def insert_prior_return_after_validation(_db: Session, purchase_order_item_ids: set[int]) -> None: + now = datetime.now(UTC) + purchase_return = PurchaseReturn( + id=10, + return_no="退货2026-00010", + purchase_order_id=1, + warehouse_id=1, + return_date=now, + operator_user_id=1, + reason="性能不合格", + status="CONFIRMED", + created_at=now, + updated_at=now, + ) + purchase_return_item = PurchaseReturnItem( + id=10, + purchase_return_id=10, + line_no=1, + purchase_order_item_id=1, + material_item_id=1, + lot_id=1, + return_weight_kg=Decimal("30"), + unit_cost=Decimal("5"), + amount=Decimal("150"), + reason="性能不合格", + status="CONFIRMED", + created_at=now, + updated_at=now, + ) + self.db.add_all([purchase_return, purchase_return_item]) + self.db.commit() + original_lock(_db, purchase_order_item_ids) + + with patch.object( + inventory_routes, + "_lock_purchase_order_items_for_return", + side_effect=insert_prior_return_after_validation, + ): + with self.assertRaises(HTTPException) as exc: + inventory_routes.create_raw_material_return_outbound(payload, context=self._context(), db=self.db) + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("退货重量不能超过该采购明细可退重量", str(exc.exception.detail)) + + self.db.expire_all() + lot = self.db.get(StockLot, 1) + self.assertEqual(float(lot.remaining_weight_kg), 100) + self.assertEqual(self.db.query(PurchaseReturn).count(), 1) + self.assertEqual(self.db.query(PurchaseReturnItem).count(), 1) + + def test_stock_lot_purchase_links_returns_receipt_purchase_order_item(self) -> None: + from app.services.operations import get_stock_lot_purchase_links + + links = get_stock_lot_purchase_links(self.db, 1) + + self.assertEqual(len(links), 1) + self.assertEqual(links[0]["purchase_order_id"], 1) + self.assertEqual(links[0]["purchase_order_item_id"], 1) + self.assertEqual(links[0]["po_no"], "PO-2026-00001") + self.assertEqual(float(links[0]["accepted_weight_kg"]), 100) + self.assertEqual(float(links[0]["returned_weight_kg"]), 0) + self.assertEqual(float(links[0]["returnable_weight_kg"]), 100) + + def test_stock_lot_purchase_links_excludes_same_lot_no_from_unrelated_receipt(self) -> None: + from app.services.operations import get_stock_lot_purchase_links + + now = datetime.now(UTC) + other_purchase_order = PurchaseOrder( + id=2, + po_no="PO-2026-00002", + supplier_id=1, + order_date=now.date(), + expected_date=now.date(), + target_warehouse_type="RAW", + tax_rate=Decimal("0"), + total_amount=Decimal("300"), + status="CONFIRMED", + created_at=now, + updated_at=now, + ) + other_purchase_order_item = PurchaseOrderItem( + id=2, + purchase_order_id=2, + line_no=1, + material_item_id=1, + order_qty=Decimal("60"), + order_weight_kg=Decimal("60"), + received_qty=Decimal("60"), + received_weight_kg=Decimal("60"), + unit_price=Decimal("5"), + line_amount=Decimal("300"), + status="RECEIVED", + created_at=now, + updated_at=now, + ) + other_receipt = PurchaseReceipt( + id=2, + receipt_no="RCV-2026-00002", + purchase_order_id=2, + warehouse_id=1, + receipt_date=now, + supplier_delivery_no="DLV-002", + status="CONFIRMED", + created_at=now, + updated_at=now, + ) + wrong_receipt_item = PurchaseReceiptItem( + id=2, + receipt_id=2, + line_no=1, + purchase_order_item_id=2, + material_item_id=1, + location_id=1, + lot_no="LOT-RAW-001", + received_qty=Decimal("60"), + received_weight_kg=Decimal("60"), + accepted_qty=Decimal("60"), + accepted_weight_kg=Decimal("60"), + unit_cost=Decimal("5"), + status="PASSED", + created_at=now, + updated_at=now, + ) + self.db.add_all([other_purchase_order, other_purchase_order_item, other_receipt, wrong_receipt_item]) + self.db.commit() + + links = get_stock_lot_purchase_links(self.db, 1) + + self.assertEqual(len(links), 1) + self.assertEqual(links[0]["purchase_order_item_id"], 1) + + def test_stock_lot_purchase_links_caps_returnable_by_prior_returns_and_locked_weight(self) -> None: + from app.services.operations import get_stock_lot_purchase_links + + now = datetime.now(UTC) + purchase_return = PurchaseReturn( + id=1, + return_no="退货2026-00001", + purchase_order_id=1, + warehouse_id=1, + return_date=now, + operator_user_id=1, + reason="性能不合格", + status="CONFIRMED", + created_at=now, + updated_at=now, + ) + purchase_return_item = PurchaseReturnItem( + id=1, + purchase_return_id=1, + line_no=1, + purchase_order_item_id=1, + material_item_id=1, + lot_id=1, + return_weight_kg=Decimal("30"), + unit_cost=Decimal("5"), + amount=Decimal("150"), + reason="性能不合格", + status="CONFIRMED", + created_at=now, + updated_at=now, + ) + stock_lot = self.db.get(StockLot, 1) + stock_lot.remaining_weight_kg = Decimal("100") + stock_lot.locked_weight_kg = Decimal("40") + self.db.add_all([purchase_return, purchase_return_item]) + self.db.commit() + + links = get_stock_lot_purchase_links(self.db, 1) + + self.assertEqual(len(links), 1) + self.assertEqual(float(links[0]["accepted_weight_kg"]), 100) + self.assertEqual(float(links[0]["returned_weight_kg"]), 30) + self.assertEqual(float(links[0]["returnable_weight_kg"]), 60) + + stock_lot.locked_weight_kg = Decimal("0") + self.db.commit() + + links = get_stock_lot_purchase_links(self.db, 1) + + self.assertEqual(float(links[0]["returnable_weight_kg"]), 70) + + stock_lot.locked_weight_kg = Decimal("120") + self.db.commit() + + links = get_stock_lot_purchase_links(self.db, 1) + + self.assertEqual(float(links[0]["returnable_weight_kg"]), 0) + + def test_stock_lot_purchase_links_ignore_canceled_return_items(self) -> None: + from app.services.operations import get_stock_lot_purchase_links + + now = datetime.now(UTC) + purchase_return = PurchaseReturn( + id=1, + return_no="退货2026-00001", + purchase_order_id=1, + warehouse_id=1, + return_date=now, + operator_user_id=1, + reason="性能不合格", + status="CONFIRMED", + created_at=now, + updated_at=now, + ) + canceled_return_item = PurchaseReturnItem( + id=1, + purchase_return_id=1, + line_no=1, + purchase_order_item_id=1, + material_item_id=1, + lot_id=1, + return_weight_kg=Decimal("30"), + unit_cost=Decimal("5"), + amount=Decimal("150"), + reason="性能不合格", + status="CANCELED", + created_at=now, + updated_at=now, + ) + self.db.add_all([purchase_return, canceled_return_item]) + self.db.commit() + + links = get_stock_lot_purchase_links(self.db, 1) + + self.assertEqual(float(links[0]["returned_weight_kg"]), 0) + self.assertEqual(float(links[0]["returnable_weight_kg"]), 100) + + def test_logistics_can_require_waybill_or_photo_without_freight(self) -> None: + from app.services.logistics import normalize_logistics_fields + + waybill, freight, photo_url = normalize_logistics_fields( + None, + None, + required=True, + freight_required=False, + order_photo_url="/inventory/logistics-photos/test.jpg", + ) + + self.assertIsNone(waybill) + self.assertIsNone(freight) + self.assertEqual(photo_url, "/inventory/logistics-photos/test.jpg") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_raw_material_weight_only_flow.py b/backend/tests/test_raw_material_weight_only_flow.py new file mode 100644 index 0000000..66a9560 --- /dev/null +++ b/backend/tests/test_raw_material_weight_only_flow.py @@ -0,0 +1,638 @@ +from __future__ import annotations + +import unittest +from datetime import date, datetime +from decimal import Decimal + +from fastapi import HTTPException +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.api.routes.purchase import create_purchase_order, create_purchase_receipt, lock_purchase_order # noqa: E402 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Bom, BomItem, Item, Material, Product, StockBalance, Warehouse # noqa: E402 +from app.models.operations import ( # noqa: E402 + InventoryTxn, + ProcessRoute, + PurchaseOrderItem, + PurchaseReceiptItem, + StockLot, + Supplier, + WorkOrder, + WorkOrderMaterial, + WorkOrderMaterialIssue, + WorkOrderOperation, +) +from app.models.org import Department, Employee, User # noqa: E402 +from app.schemas.operations import PurchaseOrderCreate, PurchaseOrderItemCreate, PurchaseReceiptCreate, PurchaseReceiptItemCreate # noqa: E402 +from app.services.auth import AuthContext # noqa: E402 +from app.services.operations import issue_item_from_lots, sync_work_order_reference_qty # noqa: E402 + + +class RawMaterialWeightOnlyFlowTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self._seed_master_data() + + def tearDown(self) -> None: + self.db.close() + + def _seed_master_data(self) -> None: + self.department = Department(id=1, dept_code="D-WH", dept_name="仓库部", dept_type="WAREHOUSE", status="ACTIVE") + self.employee = Employee(id=1, employee_code="EMP-WH-001", employee_name="仓管员", dept_id=1, status="ACTIVE") + self.user = User( + id=1, + username="tester", + password_hash="x", + employee_id=1, + dept_id=1, + nickname="测试员", + is_super_admin=1, + status="ACTIVE", + ) + self.material_item = Item( + id=1, + item_code="RM-WEIGHT-001", + item_name="重量口径原材料", + item_type="RAW_MATERIAL", + unit_weight_kg=Decimal("1.000000"), + status="ACTIVE", + ) + self.material = Material( + item_id=1, + default_supplier_id=1, + 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, + ) + self.supplier = Supplier( + id=1, + supplier_code="SUP-WEIGHT-001", + supplier_name="重量供应商", + short_name="重量供应商", + lead_time_days=0, + default_tax_rate=Decimal("0"), + status="ACTIVE", + ) + self.warehouse = Warehouse(id=1, warehouse_code="WH-RM", warehouse_name="原材料库", warehouse_type="RAW", status="ACTIVE") + self.db.add_all([ + self.department, + self.employee, + self.user, + self.material_item, + self.material, + self.supplier, + self.warehouse, + ]) + self.db.commit() + + def _auth_context(self) -> AuthContext: + return AuthContext( + user=self.user, + employee=self.employee, + department=self.department, + role_codes=["ADMIN"], + role_names=["管理员"], + permission_codes=[], + ) + + def _create_locked_raw_purchase_order(self, *, order_weight_kg: Decimal = Decimal("200"), unit_price: Decimal = Decimal("3.5")): + order = create_purchase_order( + PurchaseOrderCreate( + supplier_id=self.supplier.id, + expected_date=date.today(), + items=[ + PurchaseOrderItemCreate( + material_item_id=self.material_item.id, + order_weight_kg=order_weight_kg, + unit_price=unit_price, + ) + ], + ), + db=self.db, + ) + po_item = self.db.scalar(select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == order.purchase_order_id)) + lock_purchase_order(order.purchase_order_id, db=self.db) + return order, po_item + + def _raw_receipt_payload( + self, + order, + po_item, + *, + received_weight_kg: Decimal = Decimal("100"), + unit_cost: Decimal = Decimal("3.5"), + waybill_no: str | None = "YD-CG-001", + order_photo_url: str | None = None, + freight_amount: Decimal | None = Decimal("12.5"), + ) -> PurchaseReceiptCreate: + return PurchaseReceiptCreate( + purchase_order_id=order.purchase_order_id, + warehouse_id=self.warehouse.id, + receiver_employee_id=self.employee.id, + waybill_no=waybill_no, + order_photo_url=order_photo_url, + freight_amount=freight_amount, + items=[ + PurchaseReceiptItemCreate( + purchase_order_item_id=po_item.id, + material_item_id=self.material_item.id, + received_weight_kg=received_weight_kg, + unit_cost=unit_cost, + ) + ], + ) + + def test_raw_purchase_receipt_requires_logistics_info(self) -> None: + order, po_item = self._create_locked_raw_purchase_order() + + with self.assertRaises(HTTPException) as exc: + create_purchase_receipt( + self._raw_receipt_payload(order, po_item, waybill_no="", order_photo_url="", freight_amount=None), + context=self._auth_context(), + db=self.db, + ) + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("运单号或上传辅助照片", exc.exception.detail) + self.assertIn("运费", exc.exception.detail) + + def test_raw_purchase_receipt_allows_photo_instead_of_waybill_and_records_logistics(self) -> None: + order, po_item = self._create_locked_raw_purchase_order() + + create_purchase_receipt( + self._raw_receipt_payload( + order, + po_item, + waybill_no="", + order_photo_url="/inventory/logistics-photos/purchase-in.jpg", + freight_amount=Decimal("18.5"), + ), + context=self._auth_context(), + db=self.db, + ) + + lot = self.db.scalar(select(StockLot)) + txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "PURCHASE_IN")) + + self.assertIsNone(lot.logistics_waybill_no) + self.assertEqual(lot.logistics_photo_url, "/inventory/logistics-photos/purchase-in.jpg") + self.assertEqual(float(lot.logistics_freight_amount), 18.5) + self.assertIsNone(txn.logistics_waybill_no) + self.assertEqual(txn.logistics_photo_url, "/inventory/logistics-photos/purchase-in.jpg") + self.assertEqual(float(txn.logistics_freight_amount), 18.5) + + def test_purchase_receipt_writes_raw_material_weight_only(self) -> None: + order = create_purchase_order( + PurchaseOrderCreate( + supplier_id=self.supplier.id, + expected_date=date.today(), + items=[ + PurchaseOrderItemCreate( + material_item_id=self.material_item.id, + order_weight_kg=Decimal("200"), + unit_price=Decimal("3.5"), + ) + ], + ), + db=self.db, + ) + po_item = self.db.scalar(select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == order.purchase_order_id)) + self.assertEqual(float(po_item.order_qty), 0) + self.assertEqual(float(po_item.order_weight_kg), 200) + + lock_purchase_order(order.purchase_order_id, db=self.db) + create_purchase_receipt( + PurchaseReceiptCreate( + purchase_order_id=order.purchase_order_id, + warehouse_id=self.warehouse.id, + receiver_employee_id=self.employee.id, + waybill_no="YD-CG-RAW-001", + freight_amount=Decimal("10"), + items=[ + PurchaseReceiptItemCreate( + purchase_order_item_id=po_item.id, + material_item_id=self.material_item.id, + received_weight_kg=Decimal("250"), + unit_cost=Decimal("3.5"), + ) + ], + ), + context=self._auth_context(), + db=self.db, + ) + + self.db.refresh(po_item) + receipt_item = self.db.scalar(select(PurchaseReceiptItem)) + lot = self.db.scalar(select(StockLot)) + balance = self.db.scalar(select(StockBalance)) + txn = self.db.scalar(select(InventoryTxn)) + + self.assertEqual(float(po_item.received_qty), 0) + self.assertEqual(float(po_item.received_weight_kg), 250) + self.assertEqual(float(receipt_item.received_qty), 0) + self.assertEqual(float(receipt_item.received_weight_kg), 250) + self.assertEqual(float(lot.inbound_qty), 0) + self.assertEqual(float(lot.remaining_qty), 0) + self.assertEqual(float(lot.locked_qty), 0) + self.assertEqual(float(lot.inbound_weight_kg), 250) + self.assertEqual(float(balance.qty_on_hand), 0) + self.assertEqual(float(balance.qty_available), 0) + self.assertEqual(float(balance.weight_on_hand_kg), 250) + self.assertEqual(float(txn.qty_change), 0) + self.assertEqual(float(txn.weight_change_kg), 250) + + def test_purchase_receipt_ignores_legacy_merge_payload_and_creates_new_inventory_lot(self) -> None: + order = create_purchase_order( + PurchaseOrderCreate( + supplier_id=self.supplier.id, + expected_date=date.today(), + items=[ + PurchaseOrderItemCreate( + material_item_id=self.material_item.id, + order_weight_kg=Decimal("500"), + unit_price=Decimal("4"), + ) + ], + ), + db=self.db, + ) + po_item = self.db.scalar(select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == order.purchase_order_id)) + lock_purchase_order(order.purchase_order_id, db=self.db) + + create_purchase_receipt( + PurchaseReceiptCreate( + purchase_order_id=order.purchase_order_id, + warehouse_id=self.warehouse.id, + receiver_employee_id=self.employee.id, + waybill_no="YD-CG-RAW-002", + freight_amount=Decimal("10"), + items=[ + PurchaseReceiptItemCreate( + purchase_order_item_id=po_item.id, + material_item_id=self.material_item.id, + received_weight_kg=Decimal("300"), + unit_cost=Decimal("4"), + ) + ], + ), + context=self._auth_context(), + db=self.db, + ) + first_lot = self.db.scalar(select(StockLot)) + self.assertEqual(float(first_lot.unit_cost), 4) + + create_purchase_receipt( + PurchaseReceiptCreate( + purchase_order_id=order.purchase_order_id, + warehouse_id=self.warehouse.id, + receiver_employee_id=self.employee.id, + waybill_no="YD-CG-RAW-003", + freight_amount=Decimal("10"), + items=[ + PurchaseReceiptItemCreate( + purchase_order_item_id=po_item.id, + material_item_id=self.material_item.id, + merge_to_lot_id=first_lot.id, + received_weight_kg=Decimal("200"), + unit_cost=Decimal("99"), + ) + ], + ), + context=self._auth_context(), + db=self.db, + ) + + self.db.refresh(first_lot) + second_lot = self.db.scalars(select(StockLot).order_by(StockLot.id)).all()[1] + receipt_items = self.db.scalars(select(PurchaseReceiptItem).order_by(PurchaseReceiptItem.id)).all() + txns = self.db.scalars(select(InventoryTxn).order_by(InventoryTxn.id)).all() + + self.assertEqual(float(first_lot.inbound_weight_kg), 300) + self.assertEqual(float(first_lot.unit_cost), 4) + self.assertEqual(second_lot.lot_no, "YL0002") + self.assertEqual(float(second_lot.inbound_weight_kg), 200) + self.assertEqual(float(second_lot.unit_cost), 99) + self.assertIsNone(receipt_items[1].merge_to_lot_id) + self.assertEqual(receipt_items[1].lot_no, "YL0002") + self.assertEqual(float(receipt_items[1].unit_cost), 99) + self.assertEqual(float(txns[1].unit_cost), 99) + self.assertEqual(float(txns[1].amount), 19800) + + def test_purchase_receipt_ignores_manual_legacy_lot_no(self) -> None: + order = create_purchase_order( + PurchaseOrderCreate( + supplier_id=self.supplier.id, + expected_date=date.today(), + items=[ + PurchaseOrderItemCreate( + material_item_id=self.material_item.id, + order_weight_kg=Decimal("500"), + unit_price=Decimal("4"), + ) + ], + ), + db=self.db, + ) + po_item = self.db.scalar(select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == order.purchase_order_id)) + lock_purchase_order(order.purchase_order_id, db=self.db) + + create_purchase_receipt( + PurchaseReceiptCreate( + purchase_order_id=order.purchase_order_id, + warehouse_id=self.warehouse.id, + receiver_employee_id=self.employee.id, + waybill_no="YD-CG-RAW-004", + freight_amount=Decimal("10"), + items=[ + PurchaseReceiptItemCreate( + purchase_order_item_id=po_item.id, + material_item_id=self.material_item.id, + lot_no="YL-NB-20260530-RMWEIGHT-OLD-L01", + received_weight_kg=Decimal("300"), + unit_cost=Decimal("4"), + ) + ], + ), + context=self._auth_context(), + db=self.db, + ) + + lot = self.db.scalar(select(StockLot)) + receipt_item = self.db.scalar(select(PurchaseReceiptItem)) + + self.assertEqual(lot.lot_no, "YL0001") + self.assertEqual(receipt_item.lot_no, "YL0001") + + def test_split_arrivals_create_sequential_inventory_lots_for_same_purchase_line(self) -> None: + order = create_purchase_order( + PurchaseOrderCreate( + supplier_id=self.supplier.id, + expected_date=date.today(), + items=[ + PurchaseOrderItemCreate( + material_item_id=self.material_item.id, + order_weight_kg=Decimal("500"), + unit_price=Decimal("4"), + ) + ], + ), + db=self.db, + ) + po_item = self.db.scalar(select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == order.purchase_order_id)) + lock_purchase_order(order.purchase_order_id, db=self.db) + + create_purchase_receipt( + PurchaseReceiptCreate( + purchase_order_id=order.purchase_order_id, + warehouse_id=self.warehouse.id, + receiver_employee_id=self.employee.id, + waybill_no="YD-CG-RAW-005", + freight_amount=Decimal("10"), + items=[ + PurchaseReceiptItemCreate( + purchase_order_item_id=po_item.id, + material_item_id=self.material_item.id, + received_weight_kg=Decimal("300"), + unit_cost=Decimal("4"), + ) + ], + ), + context=self._auth_context(), + db=self.db, + ) + create_purchase_receipt( + PurchaseReceiptCreate( + purchase_order_id=order.purchase_order_id, + warehouse_id=self.warehouse.id, + receiver_employee_id=self.employee.id, + waybill_no="YD-CG-RAW-006", + freight_amount=Decimal("10"), + items=[ + PurchaseReceiptItemCreate( + purchase_order_item_id=po_item.id, + material_item_id=self.material_item.id, + received_weight_kg=Decimal("250"), + unit_cost=Decimal("4.5"), + ) + ], + ), + context=self._auth_context(), + db=self.db, + ) + + lots = self.db.scalars(select(StockLot).order_by(StockLot.id)).all() + receipt_items = self.db.scalars(select(PurchaseReceiptItem).order_by(PurchaseReceiptItem.id)).all() + self.assertEqual([lot.lot_no for lot in lots], ["YL0001", "YL0002"]) + self.assertEqual(float(receipt_items[0].unit_cost), 4) + self.assertEqual(float(receipt_items[1].unit_cost), 4.5) + self.assertEqual(float(lots[0].unit_cost), 4) + self.assertEqual(float(lots[1].unit_cost), 4.5) + + def test_material_issue_ignores_historical_raw_quantity_pollution(self) -> None: + lot = StockLot( + lot_no="RML-POLLUTED-001", + lot_role="INBOUND_RAW", + item_id=self.material_item.id, + warehouse_id=self.warehouse.id, + source_doc_type="TEST", + source_doc_id=1, + inbound_qty=Decimal("4000"), + inbound_weight_kg=Decimal("3000"), + remaining_qty=Decimal("4000"), + remaining_weight_kg=Decimal("3000"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("2"), + quality_status="PASS", + status="AVAILABLE", + ) + balance = StockBalance( + item_id=self.material_item.id, + warehouse_id=self.warehouse.id, + qty_on_hand=Decimal("4000"), + weight_on_hand_kg=Decimal("3000"), + qty_available=Decimal("4000"), + weight_available_kg=Decimal("3000"), + qty_allocated=Decimal("0"), + weight_allocated_kg=Decimal("0"), + avg_unit_cost=Decimal("2"), + ) + self.db.add_all([lot, balance]) + self.db.commit() + + issued_qty, issued_weight, amount = issue_item_from_lots( + self.db, + item_id=self.material_item.id, + required_qty=Decimal("999"), + required_weight=Decimal("200"), + enforce_qty=True, + enforce_weight=True, + source_doc_id=1, + source_line_id=None, + biz_time=datetime.now(), + operator_user_id=self.user.id, + remark="测试按重量领料", + ) + self.db.commit() + + self.db.refresh(lot) + self.db.refresh(balance) + txn = self.db.scalar(select(InventoryTxn)) + + self.assertEqual(float(issued_qty), 0) + self.assertEqual(float(issued_weight), 200) + self.assertEqual(float(amount), 400) + self.assertEqual(float(lot.remaining_qty), 0) + self.assertEqual(float(lot.remaining_weight_kg), 2800) + self.assertEqual(float(balance.qty_on_hand), 0) + self.assertEqual(float(balance.qty_available), 0) + self.assertEqual(float(balance.weight_on_hand_kg), 2800) + self.assertEqual(float(txn.qty_change), 0) + self.assertEqual(float(txn.weight_change_kg), -200) + + def test_work_order_reference_qty_resyncs_from_issued_weight_and_bom_gross_weight(self) -> None: + product_item = Item( + id=2, + item_code="FG-WEIGHT-001", + item_name="重量口径成品", + item_type="FINISHED_GOOD", + unit_weight_kg=Decimal("1.000000"), + status="ACTIVE", + ) + product = Product( + item_id=2, + version_no="V1.0", + standard_output_qty=Decimal("0"), + allowed_scrap_rate=Decimal("0"), + ) + bom = Bom( + id=1, + bom_code="BOM-WEIGHT-001", + product_item_id=2, + version_no="V1.0", + is_default=1, + status="ACTIVE", + ) + bom_item = BomItem( + id=1, + bom_id=1, + seq_no=1, + material_item_id=self.material_item.id, + usage_type="WEIGHT", + qty_per_piece=Decimal("1"), + net_weight_per_piece_kg=Decimal("1.000000"), + gross_weight_per_piece_kg=Decimal("1.500000"), + loss_rate=Decimal("0.3333"), + ) + route = ProcessRoute( + id=1, + route_code="ROUTE-WEIGHT-001", + route_name="重量测试路线", + product_item_id=2, + version_no="V1.0", + is_default=1, + status="ACTIVE", + ) + work_order = WorkOrder( + id=1, + work_order_no="WO-STale-001", + work_order_type="NORMAL", + product_item_id=2, + route_id=1, + bom_id=1, + planned_qty=Decimal("52"), + released_qty=Decimal("52"), + finished_qty=Decimal("0"), + scrap_qty=Decimal("0"), + status="RELEASED", + ) + material_row = WorkOrderMaterial( + id=1, + work_order_id=1, + bom_item_id=1, + material_item_id=self.material_item.id, + required_qty=Decimal("0"), + required_weight_kg=Decimal("200"), + issued_qty=Decimal("0"), + issued_weight_kg=Decimal("200"), + returned_qty=Decimal("0"), + returned_weight_kg=Decimal("0"), + status="ISSUED", + ) + issue_row = WorkOrderMaterialIssue( + id=1, + work_order_material_id=1, + work_order_id=1, + material_item_id=self.material_item.id, + source_lot_id=1, + material_sub_batch_no="JH_RM-WEIGHT-001_0001", + issued_qty=Decimal("0"), + issued_weight_kg=Decimal("200"), + unit_cost=Decimal("2"), + amount=Decimal("400"), + issue_time=datetime.now(), + ) + operation = WorkOrderOperation( + id=1, + work_order_id=1, + route_operation_id=1, + seq_no=1, + operation_name="1序", + work_center_id=1, + planned_qty=Decimal("52"), + report_qty=Decimal("0"), + good_qty=Decimal("0"), + scrap_qty=Decimal("0"), + rework_qty=Decimal("0"), + status="PENDING", + ) + source_lot = StockLot( + id=1, + lot_no="RML-ISSUE-001", + lot_role="INBOUND_RAW", + item_id=self.material_item.id, + warehouse_id=self.warehouse.id, + source_doc_type="TEST", + source_doc_id=1, + inbound_weight_kg=Decimal("200"), + remaining_weight_kg=Decimal("0"), + unit_cost=Decimal("2"), + quality_status="PASS", + status="DEPLETED", + ) + self.db.add_all([product_item, product, bom, bom_item, route, work_order, material_row, source_lot, issue_row, operation]) + self.db.commit() + + changed = sync_work_order_reference_qty(self.db, work_order.id) + self.db.commit() + + self.db.refresh(work_order) + self.db.refresh(operation) + self.assertTrue(changed) + self.assertEqual(float(work_order.planned_qty), 133) + self.assertEqual(float(work_order.released_qty), 133) + self.assertEqual(float(operation.planned_qty), 133) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_return_warehouse_flow.py b/backend/tests/test_return_warehouse_flow.py new file mode 100644 index 0000000..45ca478 --- /dev/null +++ b/backend/tests/test_return_warehouse_flow.py @@ -0,0 +1,1651 @@ +from __future__ import annotations + +import unittest +from datetime import UTC, datetime, timedelta +from io import BytesIO + +from openpyxl import Workbook +from openpyxl import load_workbook +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.document_archive # noqa: E402,F401 +import app.models.master_data # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.document_archive import DocumentArchive # noqa: E402 +from app.models.master_data import Warehouse # noqa: E402 +from app.services.document_archives import ARCHIVE_STATUS_READY, DOCUMENT_TYPE_WAREHOUSE_OPERATION # noqa: E402 + + +class ReturnWarehouseFlowTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + + def tearDown(self) -> None: + self.db.close() + + def seed_delivery_trace(self) -> dict[str, int]: + from decimal import Decimal + + from app.models.master_data import Item + from app.models.master_data import Bom + from app.models.org import Department, Employee + from app.models.operations import ( + Delivery, + DeliveryItem, + ProcessRoute, + ProcessRouteOperation, + StockLot, + WarehouseLocation, + WorkCenter, + ) + from app.models.sales import Customer, SalesOrder, SalesOrderItem + + now = datetime.now(UTC) + department = Department( + id=1, + dept_code="D-001", + dept_name="生产部", + dept_type="PRODUCTION", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + employee = Employee( + id=1, + employee_code="EMP-001", + employee_name="返工员工", + dept_id=1, + mobile="13800000001", + is_operator=1, + is_workshop_staff=1, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + customer = Customer(id=1, customer_code="CUS-001", customer_name="测试客户", short_name="测试客户", status="ACTIVE") + return_wh = Warehouse( + id=1, + warehouse_code="WH-RETURN-01", + warehouse_name="退货库", + warehouse_type="RETURN", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + finished_wh = Warehouse( + id=2, + warehouse_code="WH-FG-01", + warehouse_name="成品库", + warehouse_type="FINISHED", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + scrap_wh = Warehouse( + id=3, + warehouse_code="WH-SCRAP-01", + warehouse_name="废料库", + warehouse_type="SCRAP", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + return_loc = WarehouseLocation( + id=1, + warehouse_id=1, + location_code="RTN-F-01", + location_name="退货暂存位", + is_default=1, + is_locked=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + scrap_loc = WarehouseLocation( + id=2, + warehouse_id=3, + location_code="SCRAP-01", + location_name="废料暂存位", + is_default=1, + is_locked=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + material = Item( + id=1, + item_code="原材料00001", + item_name="冷轧钢板", + item_type="RAW_MATERIAL", + unit_weight_kg=Decimal("1"), + scrap_sale_price=Decimal("1.2"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + product = Item( + id=2, + item_code="产品需规00001", + item_name="冲压支架", + item_type="FINISHED_GOOD", + unit_weight_kg=Decimal("2.5"), + scrap_sale_price=Decimal("1.8"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + sales_order = SalesOrder( + id=1, + order_no="SO-20260531-001", + customer_id=1, + order_date=now.date(), + total_amount=360, + status="POSTED", + created_at=now, + updated_at=now, + ) + sales_order_item = SalesOrderItem( + id=1, + sales_order_id=1, + line_no=1, + product_item_id=2, + order_qty=30, + delivered_qty=30, + unit_price=12, + line_amount=360, + status="POSTED", + created_at=now, + updated_at=now, + ) + material_lot = StockLot( + id=1, + lot_no="JH_原材料00001_0001", + lot_role="INBOUND_RAW", + item_id=1, + warehouse_id=2, + location_id=None, + source_doc_type="OPENING_STOCK", + source_doc_id=1, + inbound_qty=0, + inbound_weight_kg=500, + remaining_qty=0, + remaining_weight_kg=0, + locked_qty=0, + locked_weight_kg=0, + unit_cost=5, + quality_status="PASS", + status="DEPLETED", + created_at=now, + updated_at=now, + ) + finished_lot = StockLot( + id=2, + lot_no="FG-20260531-0001", + lot_role="INBOUND_FINISHED", + item_id=2, + warehouse_id=2, + location_id=None, + source_doc_type="FG_RECEIPT", + source_doc_id=1, + source_line_id=1, + source_material_lot_id=1, + source_material_sub_batch_no="JH_原材料00001_0001-S01", + source_material_summary="冷轧钢板/JH_原材料00001_0001-S01", + inbound_qty=100, + inbound_weight_kg=250, + remaining_qty=70, + remaining_weight_kg=175, + locked_qty=0, + locked_weight_kg=0, + unit_cost=8, + quality_status="PASS", + status="AVAILABLE", + created_at=now, + updated_at=now, + ) + delivery = Delivery( + id=1, + delivery_no="发货2026-00001", + sales_order_id=1, + customer_id=1, + warehouse_id=2, + delivery_date=now, + status="POSTED", + created_at=now, + updated_at=now, + ) + delivery_item = DeliveryItem( + id=1, + delivery_id=1, + line_no=1, + sales_order_item_id=1, + product_item_id=2, + lot_id=2, + delivery_qty=30, + delivery_weight_kg=75, + unit_price=12, + line_amount=360, + status="POSTED", + created_at=now, + updated_at=now, + ) + bom = Bom( + id=1, + bom_code="BOM-001", + product_item_id=2, + version_no="V1.0", + is_default=1, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + work_center = WorkCenter( + id=1, + center_code="WC-001", + center_name="返工工位", + dept_id=1, + center_type="REWORK", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + route = ProcessRoute( + id=1, + route_code="RT-001", + route_name="返工路线", + product_item_id=2, + version_no="V1.0", + is_default=1, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + route_operation = ProcessRouteOperation( + id=1, + route_id=1, + seq_no=1, + process_id=1, + work_center_id=1, + operation_name="返工", + std_setup_minutes=0, + std_cycle_seconds=0, + std_labor_cost=0, + std_machine_cost=0, + std_outsource_cost=0, + allowed_scrap_rate=0, + report_required=1, + is_key_operation=1, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add_all( + [ + customer, + department, + employee, + return_wh, + finished_wh, + scrap_wh, + return_loc, + scrap_loc, + material, + product, + sales_order, + sales_order_item, + material_lot, + finished_lot, + delivery, + delivery_item, + bom, + work_center, + route, + route_operation, + ] + ) + self.db.commit() + return { + "customer_id": 1, + "return_warehouse_id": 1, + "finished_warehouse_id": 2, + "scrap_warehouse_id": 3, + "product_item_id": 2, + "material_lot_id": 1, + "finished_lot_id": 2, + "delivery_id": 1, + "delivery_item_id": 1, + "sales_order_id": 1, + "sales_order_item_id": 1, + } + + def test_jiaheng_warehouses_include_return_warehouse(self) -> None: + from app.api.routes.master_data import _ensure_jiaheng_warehouses + + _ensure_jiaheng_warehouses(self.db) + + warehouse = self.db.scalar(select(Warehouse).where(Warehouse.warehouse_type == "RETURN")) + self.assertIsNotNone(warehouse) + self.assertEqual(warehouse.warehouse_code, "WH-RETURN-01") + self.assertEqual(warehouse.warehouse_name, "退货库") + + def test_return_opening_template_exports_sample_rows_with_quantity(self) -> None: + from app.api.routes.inventory import build_opening_inventory_template + + workbook = build_opening_inventory_template(self.db, "RETURN") + output = BytesIO() + workbook.save(output) + loaded = load_workbook(BytesIO(output.getvalue()), read_only=True, data_only=True) + rows = list(loaded["退货库期初导入"].iter_rows(values_only=True)) + headers = list(rows[0]) + + self.assertIn("数量", headers) + self.assertEqual(rows[1][headers.index("仓库类型")], "退货库") + self.assertEqual(rows[1][headers.index("仓库名称")], "退货库") + self.assertIn("示例-RETURN-", rows[1][headers.index("批次号")]) + self.assertGreater(rows[1][headers.index("数量")], 0) + self.assertGreater(rows[1][headers.index("重量(kg)")], 0) + + def test_return_opening_import_requires_positive_quantity(self) -> None: + from app.api.routes.inventory import OPENING_INVENTORY_HEADERS, import_opening_inventory_workbook + from app.models.master_data import Item + from app.models.operations import WarehouseLocation + + now = datetime.now(UTC) + warehouse = Warehouse( + id=1, + warehouse_code="WH-RETURN-01", + warehouse_name="退货库", + warehouse_type="RETURN", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + location = WarehouseLocation( + id=1, + warehouse_id=1, + location_code="RTN-F-01", + location_name="退货暂存位", + is_default=1, + is_locked=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + product = Item( + id=1, + item_code="产品需规00001", + item_name="返工待处理五金支架", + item_type="FINISHED_GOOD", + unit_weight_kg=1, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add_all([warehouse, location, product]) + self.db.commit() + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "退货库期初导入" + worksheet.append(OPENING_INVENTORY_HEADERS) + worksheet.append(["退货库", "退货库", "退货暂存位", "产品需规00001", "返工待处理五金支架", "RETURN-OPEN-001", 0, 50, 12.5, "历史退货", "数量为空不允许"]) + output = BytesIO() + workbook.save(output) + + with self.assertRaises(ValueError) as exc: + import_opening_inventory_workbook(self.db, "RETURN", output.getvalue(), user_id=1) + + self.assertIn("退货库数量必须大于0", str(exc.exception)) + + def test_return_opening_import_rejects_raw_material_items(self) -> None: + from app.api.routes.inventory import OPENING_INVENTORY_HEADERS, import_opening_inventory_workbook + from app.models.master_data import Item + from app.models.operations import WarehouseLocation + + now = datetime.now(UTC) + warehouse = Warehouse( + id=1, + warehouse_code="WH-RETURN-01", + warehouse_name="退货库", + warehouse_type="RETURN", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + location = WarehouseLocation( + id=1, + warehouse_id=1, + location_code="RTN-F-01", + location_name="退货暂存位", + is_default=1, + is_locked=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + raw_material = Item( + id=1, + item_code="原材料00001", + item_name="冷轧钢板", + item_type="RAW_MATERIAL", + unit_weight_kg=1, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add_all([warehouse, location, raw_material]) + self.db.commit() + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "退货库期初导入" + worksheet.append(OPENING_INVENTORY_HEADERS) + worksheet.append(["退货库", "退货库", "退货暂存位", "原材料00001", "冷轧钢板", "RETURN-RAW-001", 1, 50, 12.5, "历史退货", "原材料不允许进入退货库"]) + output = BytesIO() + workbook.save(output) + + with self.assertRaises(Exception) as exc: + import_opening_inventory_workbook(self.db, "RETURN", output.getvalue(), user_id=1) + + self.assertIn("退货库只能选择客户退回且可返工的产品类物料", str(exc.exception)) + + def test_return_stocktake_is_not_weight_only_even_for_dirty_raw_item(self) -> None: + from app.services.stocktake import stocktake_weight_only + + self.assertFalse(stocktake_weight_only("RETURN", "RAW_MATERIAL")) + + def test_return_item_query_exposes_delivery_and_source_material_trace(self) -> None: + from decimal import Decimal + + from app.models.master_data import Item + from app.models.operations import Delivery, DeliveryItem, ReturnItem, ReturnOrder, StockLot, WarehouseLocation + from app.models.sales import Customer + from app.services.operations import get_return_items_query + + now = datetime.now(UTC) + customer = Customer(id=1, customer_code="CUS-001", customer_name="测试客户", status="ACTIVE") + return_wh = Warehouse( + id=1, + warehouse_code="WH-RETURN-01", + warehouse_name="退货库", + warehouse_type="RETURN", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + finished_wh = Warehouse( + id=2, + warehouse_code="WH-FG-01", + warehouse_name="成品库", + warehouse_type="FINISHED", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + return_loc = WarehouseLocation( + id=1, + warehouse_id=1, + location_code="RTN-F-01", + location_name="退货暂存位", + is_default=1, + is_locked=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + material = Item( + id=1, + item_code="原材料00001", + item_name="冷轧钢板", + item_type="RAW_MATERIAL", + unit_weight_kg=Decimal("1"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + product = Item( + id=2, + item_code="产品需规00001", + item_name="冲压支架", + item_type="FINISHED_GOOD", + unit_weight_kg=Decimal("2.5"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + material_lot = StockLot( + id=1, + lot_no="JH_原材料00001_0001", + lot_role="INBOUND_RAW", + item_id=1, + warehouse_id=2, + location_id=None, + source_doc_type="OPENING_STOCK", + source_doc_id=1, + inbound_qty=0, + inbound_weight_kg=500, + remaining_qty=0, + remaining_weight_kg=0, + locked_qty=0, + locked_weight_kg=0, + unit_cost=5, + quality_status="PASS", + status="DEPLETED", + created_at=now, + updated_at=now, + ) + finished_lot = StockLot( + id=2, + lot_no="FG-20260531-0001", + lot_role="INBOUND_FINISHED", + item_id=2, + warehouse_id=2, + location_id=None, + source_doc_type="FG_RECEIPT", + source_doc_id=1, + source_line_id=1, + source_material_lot_id=1, + source_material_sub_batch_no="JH_原材料00001_0001-S01", + source_material_summary="冷轧钢板", + inbound_qty=100, + inbound_weight_kg=250, + remaining_qty=0, + remaining_weight_kg=0, + locked_qty=0, + locked_weight_kg=0, + unit_cost=8, + quality_status="PASS", + status="DEPLETED", + created_at=now, + updated_at=now, + ) + delivery = Delivery( + id=1, + delivery_no="发货2026-00001", + customer_id=1, + warehouse_id=2, + delivery_date=now, + status="POSTED", + created_at=now, + updated_at=now, + ) + delivery_item = DeliveryItem( + id=1, + delivery_id=1, + line_no=1, + product_item_id=2, + lot_id=2, + delivery_qty=30, + delivery_weight_kg=75, + unit_price=12, + line_amount=360, + status="POSTED", + created_at=now, + updated_at=now, + ) + return_order = ReturnOrder( + id=1, + return_no="RTN-001", + customer_id=1, + delivery_id=1, + return_date=now, + status="CREATED", + created_at=now, + updated_at=now, + ) + return_item = ReturnItem( + id=1, + return_order_id=1, + line_no=1, + delivery_item_id=1, + product_item_id=2, + source_lot_id=2, + return_qty=5, + return_weight_kg=Decimal("12.5"), + disposition_status="PENDING", + created_at=now, + updated_at=now, + ) + self.db.add_all( + [ + customer, + return_wh, + finished_wh, + return_loc, + material, + product, + material_lot, + finished_lot, + delivery, + delivery_item, + return_order, + return_item, + ] + ) + self.db.commit() + + row = self.db.execute(get_return_items_query(limit=10)).mappings().first() + + self.assertEqual(row["delivery_item_id"], 1) + self.assertEqual(row["delivery_no"], "发货2026-00001") + self.assertEqual(row["source_lot_no"], "FG-20260531-0001") + self.assertEqual(row["source_material_lot_no"], "JH_原材料00001_0001") + self.assertEqual(row["source_material_sub_batch_no"], "JH_原材料00001_0001") + self.assertEqual(row["source_material_name"], "冷轧钢板") + + def test_return_inbound_requires_delivery_item_and_creates_locked_return_lot(self) -> None: + from decimal import Decimal + + from app.api.routes.returns import create_return_order + from app.models.operations import InventoryTxn, ReturnItem, StockLot + from app.schemas.operations import ReturnItemCreate, ReturnOrderCreate + from fastapi import HTTPException + + class Context: + user = type("User", (), {"id": 1})() + + seeded = self.seed_delivery_trace() + + with self.assertRaises(HTTPException) as exc: + create_return_order( + ReturnOrderCreate( + customer_id=seeded["customer_id"], + delivery_id=seeded["delivery_id"], + warehouse_id=seeded["return_warehouse_id"], + items=[ + ReturnItemCreate( + product_item_id=seeded["product_item_id"], + return_qty=5, + return_weight_kg=Decimal("12.5"), + ) + ], + ), + context=Context(), + db=self.db, + ) + self.assertEqual(exc.exception.status_code, 400) + self.assertEqual(exc.exception.detail, "退货入库必须选择发货批次") + + result = create_return_order( + ReturnOrderCreate( + customer_id=seeded["customer_id"], + delivery_id=seeded["delivery_id"], + warehouse_id=seeded["return_warehouse_id"], + return_reason="客户反馈尺寸异常,可返工", + items=[ + ReturnItemCreate( + delivery_item_id=seeded["delivery_item_id"], + product_item_id=seeded["product_item_id"], + return_qty=5, + return_weight_kg=Decimal("12.5"), + ) + ], + ), + context=Context(), + db=self.db, + ) + + return_item = self.db.scalar(select(ReturnItem)) + return_lot = self.db.scalar(select(StockLot).where(StockLot.lot_role == "RETURNED_FINISHED")) + return_in_txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "RETURN_IN")) + + self.assertEqual(result.archive_status, ARCHIVE_STATUS_READY) + self.assertEqual(result.archive_document_type, DOCUMENT_TYPE_WAREHOUSE_OPERATION) + self.assertIsNotNone(return_in_txn) + self.assertEqual(result.archive_business_id, return_in_txn.id) + archive = self.db.scalar( + select(DocumentArchive).where( + DocumentArchive.document_type == DOCUMENT_TYPE_WAREHOUSE_OPERATION, + DocumentArchive.business_id == return_in_txn.id, + ) + ) + self.assertIsNotNone(archive) + self.assertEqual(archive.status, ARCHIVE_STATUS_READY) + self.assertIsNotNone(return_item) + self.assertEqual(return_item.delivery_item_id, seeded["delivery_item_id"]) + self.assertEqual(return_item.sales_order_item_id, seeded["sales_order_item_id"]) + self.assertEqual(return_item.source_lot_id, seeded["finished_lot_id"]) + self.assertIsNotNone(return_lot) + self.assertEqual(return_lot.parent_lot_id, seeded["finished_lot_id"]) + self.assertEqual(return_lot.source_material_lot_id, seeded["material_lot_id"]) + self.assertEqual(return_lot.source_material_sub_batch_no, "JH_原材料00001_0001-S01") + self.assertEqual(return_lot.source_material_summary, "冷轧钢板/JH_原材料00001_0001-S01") + self.assertEqual(return_lot.status, "LOCKED") + self.assertEqual(return_lot.quality_status, "PENDING") + + def test_return_inbound_rejects_wrong_warehouse_and_over_return(self) -> None: + from decimal import Decimal + + from app.api.routes.returns import create_return_order + from app.models.master_data import Warehouse + from app.schemas.operations import ReturnItemCreate, ReturnOrderCreate + from fastapi import HTTPException + + class Context: + user = type("User", (), {"id": 1})() + + seeded = self.seed_delivery_trace() + + with self.assertRaises(HTTPException) as wrong_warehouse: + create_return_order( + ReturnOrderCreate( + customer_id=seeded["customer_id"], + delivery_id=seeded["delivery_id"], + warehouse_id=seeded["scrap_warehouse_id"], + items=[ + ReturnItemCreate( + delivery_item_id=seeded["delivery_item_id"], + product_item_id=seeded["product_item_id"], + return_qty=1, + return_weight_kg=Decimal("2.5"), + ) + ], + ), + context=Context(), + db=self.db, + ) + self.assertEqual(wrong_warehouse.exception.status_code, 400) + self.assertIn("退货入库只能进入退货库", wrong_warehouse.exception.detail) + + with self.assertRaises(HTTPException) as negative_qty: + create_return_order( + ReturnOrderCreate( + customer_id=seeded["customer_id"], + delivery_id=seeded["delivery_id"], + warehouse_id=seeded["return_warehouse_id"], + items=[ + ReturnItemCreate( + delivery_item_id=seeded["delivery_item_id"], + product_item_id=seeded["product_item_id"], + return_qty=-1, + return_weight_kg=Decimal("2.5"), + ) + ], + ), + context=Context(), + db=self.db, + ) + self.assertEqual(negative_qty.exception.status_code, 400) + self.assertIn("退货数量必须大于0", negative_qty.exception.detail) + + with self.assertRaises(HTTPException) as over_return: + create_return_order( + ReturnOrderCreate( + customer_id=seeded["customer_id"], + delivery_id=seeded["delivery_id"], + warehouse_id=seeded["return_warehouse_id"], + items=[ + ReturnItemCreate( + delivery_item_id=seeded["delivery_item_id"], + product_item_id=seeded["product_item_id"], + return_qty=31, + return_weight_kg=Decimal("77.5"), + ) + ], + ), + context=Context(), + db=self.db, + ) + self.assertEqual(over_return.exception.status_code, 400) + self.assertIn("超过发货批次可退数量", over_return.exception.detail) + + def test_return_inbound_rejects_inactive_return_warehouse(self) -> None: + from decimal import Decimal + + from app.api.routes.returns import create_return_order + from app.schemas.operations import ReturnItemCreate, ReturnOrderCreate + from fastapi import HTTPException + + class Context: + user = type("User", (), {"id": 1})() + + seeded = self.seed_delivery_trace() + warehouse = self.db.get(Warehouse, seeded["return_warehouse_id"]) + warehouse.status = "INACTIVE" + self.db.add(warehouse) + self.db.commit() + + with self.assertRaises(HTTPException) as exc: + create_return_order( + ReturnOrderCreate( + customer_id=seeded["customer_id"], + delivery_id=seeded["delivery_id"], + warehouse_id=seeded["return_warehouse_id"], + items=[ + ReturnItemCreate( + delivery_item_id=seeded["delivery_item_id"], + product_item_id=seeded["product_item_id"], + return_qty=1, + return_weight_kg=Decimal("2.5"), + ) + ], + ), + context=Context(), + db=self.db, + ) + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("退货仓未启用", exc.exception.detail) + + def test_return_scrap_inbound_goes_directly_to_scrap_warehouse(self) -> None: + from decimal import Decimal + + from app.api.routes.returns import create_return_scrap_inbound + from app.models.master_data import StockBalance + from app.models.operations import InventoryTxn, ReturnDisposition, ReturnItem, ReturnOrder, StockLot + from app.schemas.operations import ReturnScrapInboundCreate, ReturnScrapInboundItemCreate + + class Context: + user = type("User", (), {"id": 1})() + + seeded = self.seed_delivery_trace() + + result = create_return_scrap_inbound( + ReturnScrapInboundCreate( + customer_id=seeded["customer_id"], + delivery_id=seeded["delivery_id"], + warehouse_id=seeded["scrap_warehouse_id"], + return_reason="客户退回判定报废", + items=[ + ReturnScrapInboundItemCreate( + delivery_item_id=seeded["delivery_item_id"], + product_item_id=seeded["product_item_id"], + scrap_weight_kg=Decimal("25"), + ) + ], + ), + context=Context(), + db=self.db, + ) + + return_order = self.db.scalar(select(ReturnOrder)) + return_item = self.db.scalar(select(ReturnItem)) + scrap_lot = self.db.scalar(select(StockLot).where(StockLot.lot_role == "CUSTOMER_RETURN_SCRAP")) + balance = self.db.scalar(select(StockBalance).where(StockBalance.warehouse_id == seeded["scrap_warehouse_id"])) + txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "RETURN_SCRAP_IN")) + disposition = self.db.scalar(select(ReturnDisposition)) + + self.assertIsNotNone(return_order) + self.assertEqual(result.archive_status, ARCHIVE_STATUS_READY) + self.assertEqual(result.archive_document_type, DOCUMENT_TYPE_WAREHOUSE_OPERATION) + self.assertIsNotNone(txn) + self.assertEqual(result.archive_business_id, txn.id) + archive = self.db.scalar( + select(DocumentArchive).where( + DocumentArchive.document_type == DOCUMENT_TYPE_WAREHOUSE_OPERATION, + DocumentArchive.business_id == txn.id, + ) + ) + self.assertIsNotNone(archive) + self.assertEqual(archive.status, ARCHIVE_STATUS_READY) + self.assertEqual(return_order.status, "CLOSED") + self.assertEqual(return_item.disposition_status, "CLOSED") + self.assertEqual(return_item.return_qty, Decimal("10.000000")) + self.assertEqual(return_item.return_weight_kg, Decimal("25.000000")) + self.assertEqual(scrap_lot.warehouse_id, seeded["scrap_warehouse_id"]) + self.assertEqual(scrap_lot.parent_lot_id, seeded["finished_lot_id"]) + self.assertEqual(scrap_lot.source_material_lot_id, seeded["material_lot_id"]) + self.assertEqual(scrap_lot.source_material_sub_batch_no, "JH_原材料00001_0001-S01") + self.assertEqual(scrap_lot.quality_status, "SCRAP") + self.assertEqual(scrap_lot.status, "AVAILABLE") + self.assertEqual(balance.qty_on_hand, Decimal("0.000000")) + self.assertEqual(balance.qty_available, Decimal("0.000000")) + self.assertEqual(balance.weight_on_hand_kg, Decimal("25.000000")) + self.assertEqual(txn.lot_id, scrap_lot.id) + self.assertEqual(txn.weight_change_kg, Decimal("25.000000")) + self.assertEqual(disposition.return_item_id, return_item.id) + self.assertEqual(disposition.disposition_type, "SCRAP") + self.assertEqual(disposition.status, "CLOSED") + self.assertEqual(disposition.scrap_weight_kg, Decimal("25.000000")) + self.assertEqual(disposition.scrap_sale_amount, Decimal("45.00")) + + def test_return_scrap_inbound_rejects_over_return_weight(self) -> None: + from decimal import Decimal + + from app.api.routes.returns import create_return_scrap_inbound + from app.schemas.operations import ReturnScrapInboundCreate, ReturnScrapInboundItemCreate + from fastapi import HTTPException + + class Context: + user = type("User", (), {"id": 1})() + + seeded = self.seed_delivery_trace() + + with self.assertRaises(HTTPException) as exc: + create_return_scrap_inbound( + ReturnScrapInboundCreate( + customer_id=seeded["customer_id"], + delivery_id=seeded["delivery_id"], + warehouse_id=seeded["scrap_warehouse_id"], + return_reason="客户退回判定报废", + items=[ + ReturnScrapInboundItemCreate( + delivery_item_id=seeded["delivery_item_id"], + product_item_id=seeded["product_item_id"], + scrap_qty=30, + scrap_weight_kg=Decimal("80"), + ) + ], + ), + context=Context(), + db=self.db, + ) + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("超过发货批次可退重量", exc.exception.detail) + + def test_return_scrap_inbound_rejects_inactive_scrap_warehouse(self) -> None: + from decimal import Decimal + + from app.api.routes.returns import create_return_scrap_inbound + from app.schemas.operations import ReturnScrapInboundCreate, ReturnScrapInboundItemCreate + from fastapi import HTTPException + + class Context: + user = type("User", (), {"id": 1})() + + seeded = self.seed_delivery_trace() + warehouse = self.db.get(Warehouse, seeded["scrap_warehouse_id"]) + warehouse.status = "INACTIVE" + self.db.add(warehouse) + self.db.commit() + + with self.assertRaises(HTTPException) as exc: + create_return_scrap_inbound( + ReturnScrapInboundCreate( + customer_id=seeded["customer_id"], + delivery_id=seeded["delivery_id"], + warehouse_id=seeded["scrap_warehouse_id"], + items=[ + ReturnScrapInboundItemCreate( + delivery_item_id=seeded["delivery_item_id"], + product_item_id=seeded["product_item_id"], + scrap_qty=1, + scrap_weight_kg=Decimal("2.5"), + ) + ], + ), + context=Context(), + db=self.db, + ) + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("废料仓未启用", exc.exception.detail) + + def test_return_disposition_rejects_scrap_after_scrap_moved_to_scrap_warehouse(self) -> None: + from decimal import Decimal + + from app.api.routes.returns import create_return_disposition, create_return_order + from app.schemas.operations import ReturnDispositionCreate, ReturnItemCreate, ReturnOrderCreate + from fastapi import HTTPException + + class Context: + user = type("User", (), {"id": 1})() + + seeded = self.seed_delivery_trace() + create_return_order( + ReturnOrderCreate( + customer_id=seeded["customer_id"], + delivery_id=seeded["delivery_id"], + warehouse_id=seeded["return_warehouse_id"], + return_reason="客户反馈尺寸异常,可返工", + items=[ + ReturnItemCreate( + delivery_item_id=seeded["delivery_item_id"], + product_item_id=seeded["product_item_id"], + return_qty=5, + return_weight_kg=Decimal("12.5"), + ) + ], + ), + context=Context(), + db=self.db, + ) + + with self.assertRaises(HTTPException) as exc: + create_return_disposition( + ReturnDispositionCreate(return_item_id=1, disposition_type="SCRAP", scrap_weight_kg=Decimal("12.5")), + context=Context(), + db=self.db, + ) + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("退货库处置只支持返工", exc.exception.detail) + + def test_return_rework_outbound_deducts_return_lot_and_creates_rework_order(self) -> None: + from decimal import Decimal + + from app.api.routes.returns import create_return_order, create_return_rework_outbound + from app.models.master_data import StockBalance + from app.models.operations import InventoryTxn, ReturnDisposition, StockLot + from app.schemas.operations import ReturnDispositionCreate, ReturnItemCreate, ReturnOrderCreate + + class Context: + user = type("User", (), {"id": 1})() + + seeded = self.seed_delivery_trace() + create_return_order( + ReturnOrderCreate( + customer_id=seeded["customer_id"], + delivery_id=seeded["delivery_id"], + warehouse_id=seeded["return_warehouse_id"], + return_reason="可返工", + items=[ + ReturnItemCreate( + delivery_item_id=seeded["delivery_item_id"], + product_item_id=seeded["product_item_id"], + return_qty=3, + return_weight_kg=Decimal("7.5"), + ) + ], + ), + context=Context(), + db=self.db, + ) + + result = create_return_rework_outbound( + ReturnDispositionCreate(return_item_id=1, disposition_type="REWORK", extra_cost_amount=18, remark="返工出库"), + context=Context(), + db=self.db, + ) + return_lot = self.db.scalar(select(StockLot).where(StockLot.lot_role == "RETURNED_FINISHED")) + balance = self.db.scalar(select(StockBalance).where(StockBalance.warehouse_id == seeded["return_warehouse_id"])) + txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "RETURN_OUT")) + disposition = self.db.scalar(select(ReturnDisposition)) + + self.assertEqual(result.disposition_type, "REWORK") + self.assertEqual(result.archive_status, ARCHIVE_STATUS_READY) + self.assertEqual(result.archive_document_type, DOCUMENT_TYPE_WAREHOUSE_OPERATION) + self.assertIsNotNone(txn) + self.assertEqual(result.archive_business_id, txn.id) + archive = self.db.scalar( + select(DocumentArchive).where( + DocumentArchive.document_type == DOCUMENT_TYPE_WAREHOUSE_OPERATION, + DocumentArchive.business_id == txn.id, + ) + ) + self.assertIsNotNone(archive) + self.assertEqual(archive.status, ARCHIVE_STATUS_READY) + self.assertIsNotNone(result.rework_work_order_id) + self.assertEqual(return_lot.status, "CLOSED") + self.assertEqual(return_lot.quality_status, "REWORK") + self.assertEqual(return_lot.remaining_qty, Decimal("0.000000")) + self.assertEqual(return_lot.remaining_weight_kg, Decimal("0.000000")) + self.assertEqual(balance.qty_on_hand, Decimal("0.000000")) + self.assertEqual(balance.weight_on_hand_kg, Decimal("0.000000")) + self.assertEqual(txn.qty_change, Decimal("-3.000000")) + self.assertEqual(txn.weight_change_kg, Decimal("-7.500000")) + self.assertEqual(txn.source_doc_id, disposition.id) + self.assertEqual(txn.source_line_id, disposition.id) + self.assertEqual(disposition.extra_cost_amount, Decimal("18.00")) + + def test_return_rework_outbound_creates_miniapp_visible_rework_work_order(self) -> None: + from decimal import Decimal + + from app.api.routes.returns import create_return_order, create_return_rework_outbound + from app.models.operations import ReturnDisposition, WorkOrder + from app.schemas.operations import ReturnDispositionCreate, ReturnItemCreate, ReturnOrderCreate + + class Context: + user = type("User", (), {"id": 1})() + + seeded = self.seed_delivery_trace() + create_return_order( + ReturnOrderCreate( + customer_id=seeded["customer_id"], + delivery_id=seeded["delivery_id"], + warehouse_id=seeded["return_warehouse_id"], + return_reason="客户退回,可返工", + items=[ + ReturnItemCreate( + delivery_item_id=seeded["delivery_item_id"], + product_item_id=seeded["product_item_id"], + return_qty=10, + return_weight_kg=Decimal("25"), + ) + ], + ), + context=Context(), + db=self.db, + ) + + result = create_return_rework_outbound( + ReturnDispositionCreate(return_item_id=1, disposition_type="REWORK", extra_cost_amount=20, remark="返工出库"), + context=Context(), + db=self.db, + ) + + disposition = self.db.get(ReturnDisposition, result.return_disposition_id) + work_order = self.db.get(WorkOrder, disposition.rework_work_order_id) + + self.assertIsNotNone(work_order) + self.assertEqual(work_order.work_order_type, "REWORK") + self.assertEqual(work_order.product_item_id, seeded["product_item_id"]) + self.assertEqual(work_order.planned_qty, Decimal("10.000000")) + self.assertEqual(work_order.released_qty, Decimal("10.000000")) + self.assertRegex(work_order.work_order_no, r"^20\d{2}\d{4}-冲压支架$") + self.assertEqual(work_order.status, "RELEASED") + + def test_finished_rework_inbound_uses_rework_work_order_good_qty(self) -> None: + from decimal import Decimal + + from fastapi import HTTPException + + from app.api.routes.production import create_completion_receipt, create_operation_report + from app.api.routes.returns import create_return_order, create_return_rework_outbound + from app.models.operations import InventoryTxn, StockLot, WorkOrder, WorkOrderOperation + from app.schemas.operations import ( + CompletionReceiptCreate, + CompletionReceiptItemCreate, + OperationReportCreate, + ReturnDispositionCreate, + ReturnItemCreate, + ReturnOrderCreate, + ) + + class Context: + user = type("User", (), {"id": 1})() + + seeded = self.seed_delivery_trace() + create_return_order( + ReturnOrderCreate( + customer_id=seeded["customer_id"], + delivery_id=seeded["delivery_id"], + warehouse_id=seeded["return_warehouse_id"], + return_reason="客户退回,可返工", + items=[ + ReturnItemCreate( + delivery_item_id=seeded["delivery_item_id"], + product_item_id=seeded["product_item_id"], + return_qty=10, + return_weight_kg=Decimal("25"), + ) + ], + ), + context=Context(), + db=self.db, + ) + disposition = create_return_rework_outbound( + ReturnDispositionCreate(return_item_id=1, disposition_type="REWORK", extra_cost_amount=20, remark="返工出库"), + context=Context(), + db=self.db, + ) + work_order = self.db.get(WorkOrder, disposition.rework_work_order_id) + final_operation = self.db.scalars( + select(WorkOrderOperation) + .where(WorkOrderOperation.work_order_id == work_order.id) + .order_by(WorkOrderOperation.seq_no.desc()) + ).first() + + create_operation_report( + OperationReportCreate( + work_order_id=work_order.id, + work_order_operation_id=final_operation.id, + employee_id=1, + item_id=seeded["product_item_id"], + start_time=datetime.now(UTC), + end_time=datetime.now(UTC) + timedelta(minutes=1), + report_qty=10, + good_qty=8, + scrap_qty=2, + ), + db=self.db, + ) + + receipt = create_completion_receipt( + CompletionReceiptCreate( + work_order_id=work_order.id, + warehouse_id=seeded["finished_warehouse_id"], + settle_work_order=False, + remark="返工良品回库", + items=[ + CompletionReceiptItemCreate( + product_item_id=seeded["product_item_id"], + receipt_qty=8, + receipt_weight_kg=0, + ) + ], + ), + context=Context(), + db=self.db, + ) + + lot = self.db.scalar(select(StockLot).where(StockLot.source_doc_id == receipt.completion_receipt_id).order_by(StockLot.id.desc())) + txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == lot.id)) + + self.assertEqual(receipt.total_receipt_qty, 8) + self.assertEqual(lot.inbound_qty, Decimal("8.000000")) + self.assertEqual(lot.source_doc_type, "REWORK_RECEIPT") + self.assertIn("退货返工", lot.source_material_summary) + self.assertEqual(txn.txn_type, "REWORK_FINISHED_IN") + self.assertEqual(txn.qty_change, Decimal("8.000000")) + + with self.assertRaises(HTTPException) as ctx: + create_completion_receipt( + CompletionReceiptCreate( + work_order_id=work_order.id, + warehouse_id=seeded["finished_warehouse_id"], + items=[CompletionReceiptItemCreate(product_item_id=seeded["product_item_id"], receipt_qty=1)], + ), + context=Context(), + db=self.db, + ) + self.assertEqual(ctx.exception.status_code, 400) + self.assertIn("返工工单当前没有可返工入库数量", ctx.exception.detail) + + def test_rework_scrap_inbound_plus_finished_inbound_can_settle_rework_order(self) -> None: + from decimal import Decimal + + from app.api.routes.inventory import create_warehouse_inbound + from app.api.routes.production import create_completion_receipt, create_operation_report + from app.api.routes.returns import create_return_order, create_return_rework_outbound + from app.models.master_data import StockBalance + from app.models.operations import InventoryTxn, StockLot, WorkOrder, WorkOrderOperation + from app.schemas.operations import ( + CompletionReceiptCreate, + CompletionReceiptItemCreate, + OperationReportCreate, + ReturnDispositionCreate, + ReturnItemCreate, + ReturnOrderCreate, + WarehouseInboundCreate, + ) + + class Context: + user = type("User", (), {"id": 1})() + + seeded = self.seed_delivery_trace() + create_return_order( + ReturnOrderCreate( + customer_id=seeded["customer_id"], + delivery_id=seeded["delivery_id"], + warehouse_id=seeded["return_warehouse_id"], + return_reason="客户退回,可返工", + items=[ + ReturnItemCreate( + delivery_item_id=seeded["delivery_item_id"], + product_item_id=seeded["product_item_id"], + return_qty=10, + return_weight_kg=Decimal("25"), + ) + ], + ), + context=Context(), + db=self.db, + ) + disposition = create_return_rework_outbound( + ReturnDispositionCreate(return_item_id=1, disposition_type="REWORK", extra_cost_amount=20, remark="返工出库"), + context=Context(), + db=self.db, + ) + work_order = self.db.get(WorkOrder, disposition.rework_work_order_id) + final_operation = self.db.scalars( + select(WorkOrderOperation) + .where(WorkOrderOperation.work_order_id == work_order.id) + .order_by(WorkOrderOperation.seq_no.desc()) + ).first() + create_operation_report( + OperationReportCreate( + work_order_id=work_order.id, + work_order_operation_id=final_operation.id, + employee_id=1, + item_id=seeded["product_item_id"], + start_time=datetime.now(UTC), + end_time=datetime.now(UTC) + timedelta(minutes=1), + report_qty=10, + good_qty=8, + scrap_qty=2, + ), + db=self.db, + ) + create_completion_receipt( + CompletionReceiptCreate( + work_order_id=work_order.id, + warehouse_id=seeded["finished_warehouse_id"], + items=[CompletionReceiptItemCreate(product_item_id=seeded["product_item_id"], receipt_qty=8)], + ), + context=Context(), + db=self.db, + ) + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="REWORK_SCRAP_IN", + item_id=seeded["product_item_id"], + warehouse_id=seeded["scrap_warehouse_id"], + source_doc_type="WORK_ORDER", + source_doc_id=work_order.id, + inbound_qty=2, + inbound_weight_kg=5, + settle_work_order=True, + settle_remark="返工良品和返工废料均已处理", + ), + context=Context(), + db=self.db, + ) + + self.db.refresh(work_order) + scrap_balance = self.db.scalar(select(StockBalance).where(StockBalance.warehouse_id == seeded["scrap_warehouse_id"])) + scrap_lot = self.db.scalar(select(StockLot).where(StockLot.lot_role == "REWORK_SCRAP")) + scrap_txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "REWORK_SCRAP_IN")) + + self.assertEqual(scrap_lot.inbound_qty, Decimal("2.000000")) + self.assertEqual(scrap_txn.qty_change, Decimal("0.000000")) + self.assertEqual(scrap_txn.weight_change_kg, Decimal("5.000000")) + self.assertEqual(scrap_txn.amount, Decimal("0.00")) + self.assertEqual(scrap_balance.qty_on_hand, Decimal("0.000000")) + self.assertEqual(scrap_balance.weight_on_hand_kg, Decimal("5.000000")) + self.assertEqual(work_order.status, "SETTLED") + self.assertIn("返工工单已结单", work_order.remark) + + def test_miniapp_jh_short_code_binds_to_rework_work_order(self) -> None: + from decimal import Decimal + + from app.api.routes.production import _new_miniapp_resolve_cache, _sync_miniapp_item_to_erp + from app.api.routes.returns import create_return_order, create_return_rework_outbound + from app.models.operations import OperationReport, WorkOrder + from app.schemas.operations import ReturnDispositionCreate, ReturnItemCreate, ReturnOrderCreate + + class Context: + user = type("User", (), {"id": 1})() + + seeded = self.seed_delivery_trace() + create_return_order( + ReturnOrderCreate( + customer_id=seeded["customer_id"], + delivery_id=seeded["delivery_id"], + warehouse_id=seeded["return_warehouse_id"], + return_reason="客户退回,可返工", + items=[ + ReturnItemCreate( + delivery_item_id=seeded["delivery_item_id"], + product_item_id=seeded["product_item_id"], + return_qty=10, + return_weight_kg=Decimal("25"), + ) + ], + ), + context=Context(), + db=self.db, + ) + disposition = create_return_rework_outbound( + ReturnDispositionCreate(return_item_id=1, disposition_type="REWORK", remark="返工出库"), + context=Context(), + db=self.db, + ) + work_order = self.db.get(WorkOrder, disposition.rework_work_order_id) + short_code = f"JH{int(work_order.work_order_no[4:8]):04d}" + row = { + "source_report_id": 9001, + "source_item_id": 900101, + "project_no": "产品需规00001", + "product_name": "冲压支架", + "process_name": "返工", + "raw_material_batch_no": short_code, + "employee_phone": "13800000001", + "employee_name": "返工员工", + "attendance_point_name": "", + "good_qty": 8, + "defect_qty": 0, + "miniapp_scrap_qty": 2, + "standard_beat": 10, + "started_at": datetime.now(UTC), + "end_time": datetime.now(UTC) + timedelta(minutes=1), + "status": "approved", + } + + report = _sync_miniapp_item_to_erp( + self.db, + int(row["source_item_id"]), + required=True, + row=row, + cache=_new_miniapp_resolve_cache(), + ) + + saved = self.db.get(OperationReport, report.id) + self.assertEqual(saved.work_order_id, work_order.id) + self.assertEqual(saved.good_qty, Decimal("8.000000")) + self.assertEqual(saved.scrap_qty, Decimal("2.000000")) + + def test_return_disposition_query_exposes_rework_closed_loop_summary(self) -> None: + from decimal import Decimal + + from app.api.routes.inventory import create_warehouse_inbound + from app.api.routes.production import create_completion_receipt, create_operation_report + from app.api.routes.returns import create_return_order, create_return_rework_outbound + from app.models.operations import WorkOrder, WorkOrderOperation + from app.schemas.operations import ( + CompletionReceiptCreate, + CompletionReceiptItemCreate, + OperationReportCreate, + ReturnDispositionCreate, + ReturnItemCreate, + ReturnOrderCreate, + WarehouseInboundCreate, + ) + from app.services.operations import get_return_dispositions_query + + class Context: + user = type("User", (), {"id": 1})() + + seeded = self.seed_delivery_trace() + create_return_order( + ReturnOrderCreate( + customer_id=seeded["customer_id"], + delivery_id=seeded["delivery_id"], + warehouse_id=seeded["return_warehouse_id"], + return_reason="客户退回,可返工", + items=[ + ReturnItemCreate( + delivery_item_id=seeded["delivery_item_id"], + product_item_id=seeded["product_item_id"], + return_qty=10, + return_weight_kg=Decimal("25"), + ) + ], + ), + context=Context(), + db=self.db, + ) + disposition = create_return_rework_outbound( + ReturnDispositionCreate(return_item_id=1, disposition_type="REWORK", remark="返工出库"), + context=Context(), + db=self.db, + ) + work_order = self.db.get(WorkOrder, disposition.rework_work_order_id) + final_operation = self.db.scalars( + select(WorkOrderOperation) + .where(WorkOrderOperation.work_order_id == work_order.id) + .order_by(WorkOrderOperation.seq_no.desc()) + ).first() + create_operation_report( + OperationReportCreate( + work_order_id=work_order.id, + work_order_operation_id=final_operation.id, + employee_id=1, + item_id=seeded["product_item_id"], + start_time=datetime.now(UTC), + end_time=datetime.now(UTC) + timedelta(minutes=1), + report_qty=10, + good_qty=8, + scrap_qty=2, + ), + db=self.db, + ) + create_completion_receipt( + CompletionReceiptCreate( + work_order_id=work_order.id, + warehouse_id=seeded["finished_warehouse_id"], + items=[CompletionReceiptItemCreate(product_item_id=seeded["product_item_id"], receipt_qty=8)], + ), + context=Context(), + db=self.db, + ) + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="REWORK_SCRAP_IN", + item_id=seeded["product_item_id"], + warehouse_id=seeded["scrap_warehouse_id"], + source_doc_type="WORK_ORDER", + source_doc_id=work_order.id, + inbound_qty=2, + inbound_weight_kg=5, + settle_work_order=True, + ), + context=Context(), + db=self.db, + ) + + row = self.db.execute(get_return_dispositions_query(limit=1)).mappings().first() + + self.assertEqual(row["return_qty"], Decimal("10.000000")) + self.assertEqual(row["rework_report_good_qty"], Decimal("8.000000")) + self.assertEqual(row["rework_report_scrap_qty"], Decimal("2.000000")) + self.assertEqual(row["rework_finished_inbound_qty"], Decimal("8.000000")) + self.assertEqual(row["rework_scrap_inbound_qty"], Decimal("2.000000")) + self.assertEqual(row["remaining_rework_finished_inbound_qty"], Decimal("0.000000")) + self.assertEqual(row["remaining_rework_scrap_inbound_qty"], Decimal("0.000000")) + self.assertEqual(row["rework_close_ready"], True) + self.assertEqual(row["rework_close_status"], "已结单") + + def test_return_rework_outbound_rejects_non_rework_type(self) -> None: + from decimal import Decimal + + from app.api.routes.returns import create_return_order, create_return_rework_outbound + from app.schemas.operations import ReturnDispositionCreate, ReturnItemCreate, ReturnOrderCreate + from fastapi import HTTPException + + class Context: + user = type("User", (), {"id": 1})() + + seeded = self.seed_delivery_trace() + create_return_order( + ReturnOrderCreate( + customer_id=seeded["customer_id"], + delivery_id=seeded["delivery_id"], + warehouse_id=seeded["return_warehouse_id"], + return_reason="可返工", + items=[ + ReturnItemCreate( + delivery_item_id=seeded["delivery_item_id"], + product_item_id=seeded["product_item_id"], + return_qty=3, + return_weight_kg=Decimal("7.5"), + ) + ], + ), + context=Context(), + db=self.db, + ) + + with self.assertRaises(HTTPException) as exc: + create_return_rework_outbound( + ReturnDispositionCreate(return_item_id=1, disposition_type="SCRAP", scrap_weight_kg=Decimal("7.5")), + context=Context(), + db=self.db, + ) + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("退货库出库只支持返工出库", exc.exception.detail) + + def test_return_rework_outbound_rejects_lot_with_inconsistent_balance(self) -> None: + from decimal import Decimal + + from app.api.routes.returns import create_return_order, create_return_rework_outbound + from app.models.operations import StockLot + from app.schemas.operations import ReturnDispositionCreate, ReturnItemCreate, ReturnOrderCreate + from fastapi import HTTPException + + class Context: + user = type("User", (), {"id": 1})() + + seeded = self.seed_delivery_trace() + create_return_order( + ReturnOrderCreate( + customer_id=seeded["customer_id"], + delivery_id=seeded["delivery_id"], + warehouse_id=seeded["return_warehouse_id"], + return_reason="可返工", + items=[ + ReturnItemCreate( + delivery_item_id=seeded["delivery_item_id"], + product_item_id=seeded["product_item_id"], + return_qty=3, + return_weight_kg=Decimal("7.5"), + ) + ], + ), + context=Context(), + db=self.db, + ) + return_lot = self.db.scalar(select(StockLot).where(StockLot.lot_role == "RETURNED_FINISHED")) + return_lot.remaining_qty = Decimal("2") + self.db.add(return_lot) + self.db.commit() + + with self.assertRaises(HTTPException) as exc: + create_return_rework_outbound( + ReturnDispositionCreate(return_item_id=1, disposition_type="REWORK"), + context=Context(), + db=self.db, + ) + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("退货批次数量不足", exc.exception.detail) + + def test_return_rework_outbound_rejects_aggregate_stock_balance_shortage(self) -> None: + from decimal import Decimal + + from app.api.routes.returns import create_return_order, create_return_rework_outbound + from app.models.master_data import StockBalance + from app.schemas.operations import ReturnDispositionCreate, ReturnItemCreate, ReturnOrderCreate + from fastapi import HTTPException + + class Context: + user = type("User", (), {"id": 1})() + + seeded = self.seed_delivery_trace() + create_return_order( + ReturnOrderCreate( + customer_id=seeded["customer_id"], + delivery_id=seeded["delivery_id"], + warehouse_id=seeded["return_warehouse_id"], + return_reason="可返工", + items=[ + ReturnItemCreate( + delivery_item_id=seeded["delivery_item_id"], + product_item_id=seeded["product_item_id"], + return_qty=3, + return_weight_kg=Decimal("7.5"), + ) + ], + ), + context=Context(), + db=self.db, + ) + balance = self.db.scalar(select(StockBalance).where(StockBalance.warehouse_id == seeded["return_warehouse_id"])) + balance.qty_on_hand = Decimal("2") + self.db.add(balance) + self.db.commit() + + with self.assertRaises(HTTPException) as exc: + create_return_rework_outbound( + ReturnDispositionCreate(return_item_id=1, disposition_type="REWORK"), + context=Context(), + db=self.db, + ) + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("库存余额不足", exc.exception.detail) diff --git a/backend/tests/test_sales_order_delivery_trace.py b/backend/tests/test_sales_order_delivery_trace.py new file mode 100644 index 0000000..2dc97f1 --- /dev/null +++ b/backend/tests/test_sales_order_delivery_trace.py @@ -0,0 +1,757 @@ +from __future__ import annotations + +import unittest +from datetime import date, datetime +from decimal import Decimal + +from fastapi import HTTPException +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.document_archive # noqa: E402,F401 +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.api.routes.sales import create_delivery, create_sales_order # noqa: E402 +from app.models.base import Base # noqa: E402 +from app.models.document_archive import DocumentArchive # noqa: E402 +from app.models.master_data import Item, StockBalance, Warehouse # noqa: E402 +from app.models.operations import Delivery, DeliveryItem, InventoryTxn, StockLot # noqa: E402 +from app.models.org import Department, Employee, User # noqa: E402 +from app.models.sales import Customer, SalesOrder, SalesOrderItem # noqa: E402 +from app.schemas.database import SalesOrderCreate, SalesOrderItemCreate # noqa: E402 +from app.schemas.operations import DeliveryCreate, DeliveryItemCreate # noqa: E402 +from app.services.auth import AuthContext # noqa: E402 +from app.services.document_archives import ARCHIVE_STATUS_READY, DOCUMENT_TYPE_WAREHOUSE_OPERATION # noqa: E402 +from app.services.operations import get_delivery_items_query # noqa: E402 +from app.services.sales_planning import get_sales_order_query # noqa: E402 + + +class SalesOrderDeliveryTraceTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self._seed_master_data() + + def tearDown(self) -> None: + self.db.close() + + def _seed_master_data(self) -> None: + self.department = Department(id=1, dept_code="D-SALES", dept_name="销售部", dept_type="SALES", status="ACTIVE") + self.employee = Employee(id=1, employee_code="EMP-001", employee_name="张三", dept_id=1, status="ACTIVE") + self.user = User( + id=1, + username="tester", + password_hash="x", + employee_id=1, + dept_id=1, + nickname="测试员", + is_super_admin=1, + status="ACTIVE", + ) + self.customer = Customer( + id=1, + customer_code="CUS-001", + customer_name="五金客户A", + short_name="客户A", + contact_name="李工", + contact_phone="13800000000", + address="宁波测试路1号", + credit_days=30, + status="ACTIVE", + ) + self.product = Item( + id=1, + item_code="FG-001", + item_name="测试支架", + item_type="FINISHED_GOOD", + unit_weight_kg=Decimal("2.000000"), + safety_stock_weight_kg=Decimal("0"), + status="ACTIVE", + ) + self.raw_material = Item( + id=2, + item_code="MAT-001", + item_name="冷轧钢板", + item_type="RAW_MATERIAL", + specification="1.0*1000", + material_grade="SPCC", + unit_weight_kg=Decimal("1.000000"), + safety_stock_weight_kg=Decimal("0"), + status="ACTIVE", + ) + self.warehouse = Warehouse(id=1, warehouse_code="WH-FG", warehouse_name="成品库", warehouse_type="FINISHED", status="ACTIVE") + self.db.add_all([self.department, self.employee, self.user, self.customer, self.product, self.raw_material, self.warehouse]) + self.db.flush() + + self.raw_lot = StockLot( + id=1, + lot_no="RML-001", + lot_role="INBOUND_RAW", + item_id=self.raw_material.id, + warehouse_id=self.warehouse.id, + source_doc_type="TEST", + source_doc_id=1, + inbound_qty=Decimal("100"), + inbound_weight_kg=Decimal("100"), + remaining_qty=Decimal("60"), + remaining_weight_kg=Decimal("60"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("5"), + quality_status="PASS", + status="AVAILABLE", + ) + self.finished_lot = StockLot( + id=2, + lot_no="FGL-001", + parent_lot_id=self.raw_lot.id, + lot_role="FINISHED_FROM_RAW_BATCH", + item_id=self.product.id, + warehouse_id=self.warehouse.id, + source_doc_type="COMPLETION", + source_doc_id=1, + source_line_id=1, + source_material_lot_id=self.raw_lot.id, + source_material_sub_batch_no="RML-001", + source_material_summary="RML-001 / 冷轧钢板 / 40kg / ¥200", + inbound_qty=Decimal("20"), + inbound_weight_kg=Decimal("40"), + remaining_qty=Decimal("20"), + remaining_weight_kg=Decimal("40"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("10"), + production_date=date.today(), + quality_status="PASS", + status="AVAILABLE", + ) + self.finished_balance = StockBalance( + id=1, + item_id=self.product.id, + warehouse_id=self.warehouse.id, + location_id=None, + qty_on_hand=Decimal("20"), + weight_on_hand_kg=Decimal("40"), + qty_available=Decimal("20"), + weight_available_kg=Decimal("40"), + qty_allocated=Decimal("0"), + weight_allocated_kg=Decimal("0"), + avg_unit_cost=Decimal("10"), + ) + self.db.add_all([self.raw_lot, self.finished_lot, self.finished_balance]) + self.db.commit() + + def _seed_second_finished_lot(self) -> StockLot: + second_lot = StockLot( + id=3, + lot_no="FGL-002", + parent_lot_id=self.raw_lot.id, + lot_role="FINISHED_FROM_RAW_BATCH", + item_id=self.product.id, + warehouse_id=self.warehouse.id, + source_doc_type="COMPLETION", + source_doc_id=2, + source_line_id=2, + source_material_lot_id=self.raw_lot.id, + source_material_sub_batch_no="RML-001", + source_material_summary="RML-001 / 冷轧钢板 / 10kg / ¥50", + inbound_qty=Decimal("5"), + inbound_weight_kg=Decimal("10"), + remaining_qty=Decimal("5"), + remaining_weight_kg=Decimal("10"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("10"), + production_date=date.today(), + quality_status="PASS", + status="AVAILABLE", + ) + self.finished_balance.qty_on_hand += Decimal("5") + self.finished_balance.weight_on_hand_kg += Decimal("10") + self.finished_balance.qty_available += Decimal("5") + self.finished_balance.weight_available_kg += Decimal("10") + self.db.add_all([second_lot, self.finished_balance]) + self.db.commit() + return second_lot + + def _auth_context(self) -> AuthContext: + return AuthContext( + user=self.user, + employee=self.employee, + department=self.department, + role_codes=["ADMIN"], + role_names=["管理员"], + permission_codes=[], + ) + + def _seed_employee_permission( + self, + permission_code: str, + permission_name: str, + module_code: str, + role_code: str, + role_name: str, + ) -> Employee: + from app.models.org import Permission, Role, RolePermission, UserRole + + now = datetime(2026, 6, 8, 9, 0, 0) + permission = self.db.scalar(select(Permission).where(Permission.permission_code == permission_code)) + if not permission: + permission = Permission( + permission_code=permission_code, + permission_name=permission_name, + module_code=module_code, + action_code="VIEW", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add(permission) + self.db.flush() + + role = self.db.scalar(select(Role).where(Role.role_code == role_code)) + if not role: + role = Role( + role_code=role_code, + role_name=role_name, + role_scope="SYSTEM", + status="ACTIVE", + remark=None, + created_at=now, + updated_at=now, + ) + self.db.add(role) + self.db.flush() + + role_permission = self.db.scalar( + select(RolePermission).where( + RolePermission.role_id == role.id, + RolePermission.permission_id == permission.id, + ) + ) + if not role_permission: + self.db.add(RolePermission(role_id=role.id, permission_id=permission.id, created_at=now)) + + user_role = self.db.scalar(select(UserRole).where(UserRole.user_id == self.user.id, UserRole.role_id == role.id)) + if not user_role: + self.db.add(UserRole(user_id=self.user.id, role_id=role.id, created_at=now)) + + self.db.commit() + return self.employee + + def _seed_sales_employee_permission(self) -> Employee: + return self._seed_employee_permission( + "MENU_SALES_PLANNING", + "客户名录/销售订单菜单", + "SALES_PLANNING", + "SALES", + "销售人员", + ) + + def _seed_inventory_ledger_employee_permission(self) -> Employee: + return self._seed_employee_permission( + "MENU_INVENTORY_LEDGER", + "嘉恒仓库", + "INVENTORY_LEDGER", + "WAREHOUSE", + "仓库人员", + ) + + def test_create_sales_order_persists_sales_employee(self) -> None: + sales_employee = self._seed_sales_employee_permission() + + order_result = create_sales_order( + SalesOrderCreate( + customer_id=self.customer.id, + sales_employee_id=sales_employee.id, + promised_date=date.today(), + delivery_address=self.customer.address, + tax_rate=0, + items=[ + SalesOrderItemCreate( + product_item_id=self.product.id, + order_qty=3, + unit_price=12.5, + customer_part_no="C-PART-001", + ) + ], + ), + db=self.db, + ) + + order = self.db.get(SalesOrder, order_result.order_id) + order_row = self.db.execute(get_sales_order_query(limit=10)).mappings().first() + self.assertEqual(order.sales_employee_id, sales_employee.id) + self.assertEqual(order_row["sales_employee_id"], sales_employee.id) + self.assertEqual(order_row["sales_employee_name"], sales_employee.employee_name) + + def test_sales_order_delivery_updates_fulfillment_and_trace_fields(self) -> None: + sales_employee = self._seed_sales_employee_permission() + + order_result = create_sales_order( + SalesOrderCreate( + customer_id=self.customer.id, + sales_employee_id=sales_employee.id, + promised_date=date.today(), + delivery_address=self.customer.address, + tax_rate=0, + items=[ + SalesOrderItemCreate( + product_item_id=self.product.id, + order_qty=10, + unit_price=12.5, + customer_part_no="C-PART-001", + ) + ], + ), + db=self.db, + ) + order_item = self.db.scalar(select(SalesOrderItem).where(SalesOrderItem.sales_order_id == order_result.order_id)) + + create_delivery( + DeliveryCreate( + sales_order_id=order_result.order_id, + customer_id=self.customer.id, + warehouse_id=self.warehouse.id, + consignee_name=self.customer.contact_name, + consignee_phone=self.customer.contact_phone, + delivery_address=self.customer.address, + waybill_no="YD-XS-001", + freight_amount=36.8, + items=[ + DeliveryItemCreate( + sales_order_item_id=order_item.id, + product_item_id=self.product.id, + lot_id=self.finished_lot.id, + delivery_qty=4, + delivery_weight_kg=8, + unit_price=12.5, + ) + ], + ), + context=self._auth_context(), + db=self.db, + ) + + self.db.refresh(order_item) + sales_order = self.db.get(SalesOrder, order_result.order_id) + delivery_item = self.db.scalar(select(DeliveryItem)) + delivery = self.db.scalar(select(Delivery)) + trace_row = self.db.execute(get_delivery_items_query(limit=10)).mappings().first() + stock_balance = self.db.scalar(select(StockBalance).where(StockBalance.item_id == self.product.id)) + + self.assertEqual(delivery.logistics_waybill_no, "YD-XS-001") + self.assertEqual(float(delivery.logistics_freight_amount), 36.8) + self.assertEqual(float(order_item.delivered_qty), 4) + self.assertEqual(order_item.status, "PARTIAL") + self.assertEqual(sales_order.status, "PARTIAL") + self.assertEqual(delivery_item.sales_order_item_id, order_item.id) + self.assertEqual(trace_row["source_material_lot_no"], "RML-001") + self.assertEqual(trace_row["source_material_code"], "MAT-001") + self.assertEqual(trace_row["source_material_name"], "冷轧钢板") + self.assertEqual(trace_row["source_material_sub_batch_no"], "RML-001") + self.assertEqual(trace_row["lot_no"], "FGL-001") + self.assertEqual(float(stock_balance.qty_on_hand), 16) + self.assertEqual(float(stock_balance.qty_available), 16) + + def test_delivery_trace_aliases_legacy_sub_batch_to_source_lot_no(self) -> None: + self.finished_lot.source_material_lot_id = None + self.finished_lot.source_material_sub_batch_no = "JH_MAT001_0001" + self.db.commit() + sales_employee = self._seed_sales_employee_permission() + + order_result = create_sales_order( + SalesOrderCreate( + customer_id=self.customer.id, + sales_employee_id=sales_employee.id, + promised_date=date.today(), + delivery_address=self.customer.address, + tax_rate=0, + items=[ + SalesOrderItemCreate( + product_item_id=self.product.id, + order_qty=1, + unit_price=12.5, + ) + ], + ), + db=self.db, + ) + order_item = self.db.scalar(select(SalesOrderItem).where(SalesOrderItem.sales_order_id == order_result.order_id)) + + create_delivery( + DeliveryCreate( + sales_order_id=order_result.order_id, + customer_id=self.customer.id, + warehouse_id=self.warehouse.id, + consignee_name=self.customer.contact_name, + consignee_phone=self.customer.contact_phone, + delivery_address=self.customer.address, + waybill_no="YD-XS-LEGACY", + freight_amount=10, + items=[ + DeliveryItemCreate( + sales_order_item_id=order_item.id, + product_item_id=self.product.id, + lot_id=self.finished_lot.id, + delivery_qty=1, + delivery_weight_kg=2, + unit_price=12.5, + ) + ], + ), + context=self._auth_context(), + db=self.db, + ) + + trace_row = self.db.execute(get_delivery_items_query(limit=10)).mappings().first() + + self.assertEqual(trace_row["source_material_lot_no"], "JH_MAT001_0001") + self.assertEqual(trace_row["source_material_sub_batch_no"], "JH_MAT001_0001") + + def test_delivery_line_link_infers_sales_order_header(self) -> None: + sales_employee = self._seed_sales_employee_permission() + + order_result = create_sales_order( + SalesOrderCreate( + customer_id=self.customer.id, + sales_employee_id=sales_employee.id, + promised_date=date.today(), + delivery_address=self.customer.address, + tax_rate=0, + items=[ + SalesOrderItemCreate( + product_item_id=self.product.id, + order_qty=6, + unit_price=10, + ) + ], + ), + db=self.db, + ) + order_item = self.db.scalar(select(SalesOrderItem).where(SalesOrderItem.sales_order_id == order_result.order_id)) + + delivery_result = create_delivery( + DeliveryCreate( + customer_id=self.customer.id, + warehouse_id=self.warehouse.id, + waybill_no="YD-XS-002", + freight_amount=20, + items=[ + DeliveryItemCreate( + sales_order_item_id=order_item.id, + product_item_id=self.product.id, + lot_id=self.finished_lot.id, + delivery_qty=2, + delivery_weight_kg=4, + unit_price=10, + ) + ], + ), + context=self._auth_context(), + db=self.db, + ) + + delivery = self.db.get(Delivery, delivery_result.delivery_id) + sales_order = self.db.get(SalesOrder, order_result.order_id) + sales_out_txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "SALES_OUT")) + self.assertEqual(delivery_result.sales_order_id, order_result.order_id) + self.assertIsNotNone(sales_out_txn) + self.assertEqual(delivery_result.archive_status, ARCHIVE_STATUS_READY) + self.assertEqual(delivery_result.archive_document_type, DOCUMENT_TYPE_WAREHOUSE_OPERATION) + self.assertEqual(delivery_result.archive_business_id, sales_out_txn.id) + archive = self.db.scalar( + select(DocumentArchive).where( + DocumentArchive.document_type == DOCUMENT_TYPE_WAREHOUSE_OPERATION, + DocumentArchive.business_id == sales_out_txn.id, + ) + ) + self.assertIsNotNone(archive) + self.assertEqual(archive.status, ARCHIVE_STATUS_READY) + self.assertEqual(delivery.sales_order_id, order_result.order_id) + self.assertEqual(sales_order.status, "PARTIAL") + + def test_delivery_generates_archive_for_each_sales_out_transaction(self) -> None: + second_lot = self._seed_second_finished_lot() + sales_employee = self._seed_sales_employee_permission() + order_result = create_sales_order( + SalesOrderCreate( + customer_id=self.customer.id, + sales_employee_id=sales_employee.id, + promised_date=date.today(), + delivery_address=self.customer.address, + tax_rate=0, + items=[ + SalesOrderItemCreate( + product_item_id=self.product.id, + order_qty=6, + unit_price=10, + ) + ], + ), + db=self.db, + ) + order_item = self.db.scalar(select(SalesOrderItem).where(SalesOrderItem.sales_order_id == order_result.order_id)) + + delivery_result = create_delivery( + DeliveryCreate( + customer_id=self.customer.id, + warehouse_id=self.warehouse.id, + waybill_no="YD-MULTI-LOT", + freight_amount=20, + items=[ + DeliveryItemCreate( + sales_order_item_id=order_item.id, + product_item_id=self.product.id, + lot_id=self.finished_lot.id, + delivery_qty=2, + delivery_weight_kg=4, + unit_price=10, + ), + DeliveryItemCreate( + sales_order_item_id=order_item.id, + product_item_id=self.product.id, + lot_id=second_lot.id, + delivery_qty=1, + delivery_weight_kg=2, + unit_price=10, + ), + ], + ), + context=self._auth_context(), + db=self.db, + ) + + sales_out_txns = self.db.scalars( + select(InventoryTxn).where(InventoryTxn.txn_type == "SALES_OUT").order_by(InventoryTxn.id) + ).all() + self.assertEqual(len(sales_out_txns), 2) + self.assertEqual(delivery_result.archive_business_id, sales_out_txns[0].id) + archive_business_ids = set( + self.db.scalars( + select(DocumentArchive.business_id).where( + DocumentArchive.document_type == DOCUMENT_TYPE_WAREHOUSE_OPERATION, + DocumentArchive.business_id.in_([txn.id for txn in sales_out_txns]), + ) + ).all() + ) + self.assertEqual(archive_business_ids, {txn.id for txn in sales_out_txns}) + + def test_create_delivery_accepts_inventory_ledger_shipper(self) -> None: + shipper = self._seed_inventory_ledger_employee_permission() + + delivery_result = create_delivery( + DeliveryCreate( + customer_id=self.customer.id, + warehouse_id=self.warehouse.id, + shipper_employee_id=shipper.id, + waybill_no="YD-INVENTORY-SHIPPER", + freight_amount=12, + items=[ + DeliveryItemCreate( + product_item_id=self.product.id, + lot_id=self.finished_lot.id, + delivery_qty=1, + delivery_weight_kg=2, + unit_price=0, + ) + ], + ), + context=self._auth_context(), + db=self.db, + ) + + delivery = self.db.get(Delivery, delivery_result.delivery_id) + self.assertEqual(delivery.shipper_employee_id, shipper.id) + + def test_sales_delivery_requires_logistics_info(self) -> None: + sales_employee = self._seed_sales_employee_permission() + + order_result = create_sales_order( + SalesOrderCreate( + customer_id=self.customer.id, + sales_employee_id=sales_employee.id, + promised_date=date.today(), + delivery_address=self.customer.address, + tax_rate=0, + items=[ + SalesOrderItemCreate( + product_item_id=self.product.id, + order_qty=6, + unit_price=10, + ) + ], + ), + db=self.db, + ) + order_item = self.db.scalar(select(SalesOrderItem).where(SalesOrderItem.sales_order_id == order_result.order_id)) + + with self.assertRaises(HTTPException) as exc: + create_delivery( + DeliveryCreate( + customer_id=self.customer.id, + warehouse_id=self.warehouse.id, + items=[ + DeliveryItemCreate( + sales_order_item_id=order_item.id, + product_item_id=self.product.id, + lot_id=self.finished_lot.id, + delivery_qty=2, + delivery_weight_kg=4, + unit_price=10, + ) + ], + ), + context=self._auth_context(), + db=self.db, + ) + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("运单号", exc.exception.detail) + self.assertIn("运费", exc.exception.detail) + + def test_sales_delivery_allows_order_photo_instead_of_waybill(self) -> None: + sales_employee = self._seed_sales_employee_permission() + + order_result = create_sales_order( + SalesOrderCreate( + customer_id=self.customer.id, + sales_employee_id=sales_employee.id, + promised_date=date.today(), + delivery_address=self.customer.address, + tax_rate=0, + items=[ + SalesOrderItemCreate( + product_item_id=self.product.id, + order_qty=6, + unit_price=10, + ) + ], + ), + db=self.db, + ) + order_item = self.db.scalar(select(SalesOrderItem).where(SalesOrderItem.sales_order_id == order_result.order_id)) + + delivery_result = create_delivery( + DeliveryCreate( + customer_id=self.customer.id, + warehouse_id=self.warehouse.id, + order_photo_url="/inventory/logistics-photos/sales-photo.jpg", + freight_amount=20, + items=[ + DeliveryItemCreate( + sales_order_item_id=order_item.id, + product_item_id=self.product.id, + lot_id=self.finished_lot.id, + delivery_qty=2, + delivery_weight_kg=4, + unit_price=10, + ) + ], + ), + context=self._auth_context(), + db=self.db, + ) + + delivery = self.db.get(Delivery, delivery_result.delivery_id) + txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "SALES_OUT")) + self.assertIsNone(delivery.logistics_waybill_no) + self.assertEqual(delivery.logistics_photo_url, "/inventory/logistics-photos/sales-photo.jpg") + self.assertEqual(txn.logistics_photo_url, "/inventory/logistics-photos/sales-photo.jpg") + + def test_direct_customer_delivery_without_sales_order_preserves_trace_and_inventory(self) -> None: + delivery_result = create_delivery( + DeliveryCreate( + customer_id=self.customer.id, + warehouse_id=self.warehouse.id, + consignee_name=self.customer.contact_name, + consignee_phone=self.customer.contact_phone, + delivery_address=self.customer.address, + waybill_no="YD-DIRECT-001", + freight_amount=18, + items=[ + DeliveryItemCreate( + sales_order_item_id=None, + product_item_id=self.product.id, + lot_id=self.finished_lot.id, + delivery_qty=3, + delivery_weight_kg=6, + unit_price=0, + ) + ], + ), + context=self._auth_context(), + db=self.db, + ) + + delivery = self.db.get(Delivery, delivery_result.delivery_id) + delivery_item = self.db.scalar(select(DeliveryItem).where(DeliveryItem.delivery_id == delivery.id)) + trace_row = self.db.execute(get_delivery_items_query(limit=10, delivery_id=delivery.id)).mappings().first() + stock_balance = self.db.scalar(select(StockBalance).where(StockBalance.item_id == self.product.id)) + sales_out_txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "SALES_OUT")) + + self.assertIsNone(delivery.sales_order_id) + self.assertEqual(delivery.customer_id, self.customer.id) + self.assertIsNone(delivery_item.sales_order_item_id) + self.assertEqual(trace_row["source_material_lot_no"], "RML-001") + self.assertEqual(trace_row["source_material_code"], "MAT-001") + self.assertEqual(trace_row["source_material_name"], "冷轧钢板") + self.assertEqual(trace_row["source_material_sub_batch_no"], "RML-001") + self.assertEqual(trace_row["source_material_summary"], "RML-001 / 冷轧钢板 / 40kg / ¥200") + self.assertEqual(float(stock_balance.qty_on_hand), 17) + self.assertEqual(float(stock_balance.qty_available), 17) + self.assertEqual(sales_out_txn.source_doc_type, "DELIVERY") + self.assertEqual(sales_out_txn.source_doc_id, delivery.id) + + def test_sales_delivery_rejects_non_finished_warehouse(self) -> None: + raw_warehouse = Warehouse( + id=2, + warehouse_code="WH-RAW", + warehouse_name="原材料库", + warehouse_type="RAW", + status="ACTIVE", + ) + self.db.add(raw_warehouse) + self.db.commit() + + with self.assertRaises(HTTPException) as exc: + create_delivery( + DeliveryCreate( + customer_id=self.customer.id, + warehouse_id=raw_warehouse.id, + waybill_no="YD-WRONG-WH", + freight_amount=12, + items=[ + DeliveryItemCreate( + product_item_id=self.product.id, + lot_id=self.finished_lot.id, + delivery_qty=1, + delivery_weight_kg=2, + unit_price=0, + ) + ], + ), + context=self._auth_context(), + db=self.db, + ) + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("销售出库必须选择成品库", exc.exception.detail) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_sales_order_document_archive.py b/backend/tests/test_sales_order_document_archive.py new file mode 100644 index 0000000..9c6c053 --- /dev/null +++ b/backend/tests/test_sales_order_document_archive.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +import unittest +from datetime import date, datetime +from unittest.mock import patch + +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_big_integer_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.document_archive # noqa: E402,F401 +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.api.routes.sales import create_sales_order, list_sales_orders # noqa: E402 +from app.models.base import Base # noqa: E402 +from app.models.document_archive import DocumentArchive # noqa: E402 +from app.models.master_data import Item # noqa: E402 +from app.models.sales import Customer # noqa: E402 +from app.schemas.database import SalesOrderCreate, SalesOrderItemCreate # noqa: E402 +from app.schemas.document_archives import DocumentArchiveGenerateResult # noqa: E402 + + +class SalesOrderDocumentArchiveTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + now = datetime.now() + self.customer = Customer( + id=1, + customer_code="CUS-001", + customer_name="测试客户", + short_name="客户", + address="上海市测试路 1 号", + credit_days=30, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.product = Item( + id=10, + item_code="FG-001", + item_name="测试成品", + item_type="成品", + specification="A-100", + unit_weight_kg=1.25, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add_all([self.customer, self.product]) + self.db.commit() + + def tearDown(self) -> None: + self.db.close() + + def _payload(self) -> SalesOrderCreate: + return SalesOrderCreate( + customer_id=self.customer.id, + sales_employee_id=None, + promised_date=date(2026, 6, 20), + delivery_address="上海市测试路 2 号", + tax_rate=0.13, + remark="归档测试", + items=[ + SalesOrderItemCreate( + product_item_id=self.product.id, + order_qty=2, + unit_price=100, + promised_date=date(2026, 6, 20), + customer_part_no="CPN-001", + ) + ], + ) + + def test_create_sales_order_generates_archive_and_returns_archive_metadata(self) -> None: + def fake_generate_archive( + db: Session, + document_type: str, + business_id: int, + ) -> DocumentArchiveGenerateResult: + db.add( + DocumentArchive( + document_type=document_type, + business_id=business_id, + document_no="销售2026-00001", + archive_version=1, + template_version="单据纸面V1", + file_format="PDF", + file_name="销售2026-00001.pdf", + file_path="/tmp/销售2026-00001.pdf", + file_hash="abc123", + status="已归档", + error_message=None, + created_by=None, + ) + ) + db.commit() + return DocumentArchiveGenerateResult( + business_id=business_id, + document_type=document_type, + document_no="销售2026-00001", + archive_status="已归档", + archive_version=1, + archive_error_message=None, + ) + + with ( + patch("app.api.routes.sales.ensure_employee_has_permission"), + patch("app.api.routes.sales.generate_document_archive", side_effect=fake_generate_archive) as archive_mock, + ): + result = create_sales_order(self._payload(), self.db) + + archive_mock.assert_called_once_with(self.db, "销售订单", int(result.order_id)) + self.assertEqual(result.archive_status, "已归档") + self.assertEqual(result.archive_version, 1) + self.assertIsNone(result.archive_error_message) + + saved_archive = self.db.scalar(select(DocumentArchive).where(DocumentArchive.business_id == result.order_id)) + self.assertIsNotNone(saved_archive) + + def test_list_sales_orders_exposes_latest_archive_fields(self) -> None: + with ( + patch("app.api.routes.sales.ensure_employee_has_permission"), + patch( + "app.api.routes.sales.generate_document_archive", + return_value=DocumentArchiveGenerateResult( + business_id=1, + document_type="销售订单", + document_no="销售2026-00001", + archive_status="已归档", + archive_version=1, + archive_error_message=None, + ), + ), + ): + result = create_sales_order(self._payload(), self.db) + + created_at = datetime(2026, 6, 11, 10, 30, 0) + self.db.add_all( + [ + DocumentArchive( + document_type="销售订单", + business_id=result.order_id, + document_no=result.order_no, + archive_version=1, + template_version="单据纸面V1", + file_format="PDF", + file_name="v1.pdf", + file_path="/tmp/v1.pdf", + file_hash="v1", + status="已归档", + error_message=None, + created_by=None, + created_at=created_at, + updated_at=created_at, + ), + DocumentArchive( + document_type="销售订单", + business_id=result.order_id, + document_no=result.order_no, + archive_version=2, + template_version="单据纸面V1", + file_format="PDF", + file_name="v2.pdf", + file_path="/tmp/v2.pdf", + file_hash="v2", + status="归档失败", + error_message="render failed", + created_by=None, + created_at=datetime(2026, 6, 11, 10, 35, 0), + updated_at=datetime(2026, 6, 11, 10, 35, 0), + ), + ] + ) + self.db.commit() + + rows = list_sales_orders(limit=200, db=self.db) + + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0].archive_status, "归档失败") + self.assertEqual(rows[0].archive_version, 2) + self.assertEqual(rows[0].archive_created_at, datetime(2026, 6, 11, 10, 35, 0)) + self.assertEqual(rows[0].archive_error_message, "render failed") + + def test_list_sales_orders_uses_pdf_archive_when_same_version_has_other_format(self) -> None: + with ( + patch("app.api.routes.sales.ensure_employee_has_permission"), + patch( + "app.api.routes.sales.generate_document_archive", + return_value=DocumentArchiveGenerateResult( + business_id=1, + document_type="销售订单", + document_no="销售2026-00001", + archive_status="已归档", + archive_version=1, + archive_error_message=None, + ), + ), + ): + result = create_sales_order(self._payload(), self.db) + + self.db.add_all( + [ + DocumentArchive( + document_type="销售订单", + business_id=result.order_id, + document_no=result.order_no, + archive_version=1, + template_version="单据纸面V1", + file_format="PDF", + file_name="order.pdf", + file_path="/tmp/order.pdf", + file_hash="pdf", + status="已归档", + error_message=None, + created_by=None, + created_at=datetime(2026, 6, 11, 10, 30, 0), + updated_at=datetime(2026, 6, 11, 10, 30, 0), + ), + DocumentArchive( + document_type="销售订单", + business_id=result.order_id, + document_no=result.order_no, + archive_version=1, + template_version="单据纸面V1", + file_format="DOCX", + file_name="order.docx", + file_path="/tmp/order.docx", + file_hash="docx", + status="DOCX不应显示", + error_message="wrong format", + created_by=None, + created_at=datetime(2026, 6, 11, 10, 35, 0), + updated_at=datetime(2026, 6, 11, 10, 35, 0), + ), + ] + ) + self.db.commit() + + rows = list_sales_orders(limit=200, db=self.db) + + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0].archive_status, "已归档") + self.assertEqual(rows[0].archive_version, 1) + self.assertIsNone(rows[0].archive_error_message) + + def test_create_sales_order_returns_failed_archive_metadata_when_archive_generation_raises(self) -> None: + with ( + patch("app.api.routes.sales.ensure_employee_has_permission"), + patch("app.api.routes.sales.generate_document_archive", side_effect=RuntimeError("renderer exploded")), + ): + result = create_sales_order(self._payload(), self.db) + + self.assertIsNotNone(result.order_id) + self.assertTrue(result.order_no) + self.assertEqual(result.archive_status, "归档失败") + self.assertIsNone(result.archive_version) + self.assertTrue(result.archive_error_message and result.archive_error_message.startswith("归档失败:")) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_sales_order_no_generation.py b/backend/tests/test_sales_order_no_generation.py new file mode 100644 index 0000000..43d338e --- /dev/null +++ b/backend/tests/test_sales_order_no_generation.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import unittest +from datetime import UTC, date, datetime + +from sqlalchemy import BigInteger, create_engine +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.api.routes.sales import build_sales_order_no # noqa: E402 +from app.models.base import Base # noqa: E402 +from app.models.sales import Customer, SalesOrder # noqa: E402 + + +class SalesOrderNoGenerationTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self.customer = Customer( + id=1, + customer_code="CUS-001", + customer_name="测试客户", + short_name="客户", + status="ACTIVE", + ) + self.db.add(self.customer) + self.db.commit() + + def tearDown(self) -> None: + self.db.close() + + def _add_sales_order(self, order_no: str) -> None: + now = datetime.now(UTC) + self.db.add( + SalesOrder( + order_no=order_no, + customer_id=self.customer.id, + order_date=date.today(), + promised_date=None, + sales_employee_id=None, + delivery_address=None, + tax_rate=0, + total_amount=0, + status="OPEN", + created_at=now, + updated_at=now, + ) + ) + self.db.commit() + + def test_sales_order_no_starts_from_year_five_digit_sequence(self) -> None: + year = date.today().strftime("%Y") + + self.assertEqual(build_sales_order_no(self.db, self.customer), f"销售{year}-00001") + + def test_sales_order_no_increments_only_matching_current_year_orders(self) -> None: + year = date.today().strftime("%Y") + previous_year = str(int(year) - 1) + self._add_sales_order(f"销售{year}-00001") + self._add_sales_order(f"销售{year}-00009") + self._add_sales_order(f"{previous_year}-00099") + self._add_sales_order(f"{year}-00099") + self._add_sales_order("SO-NB-20260523-CUS001-001") + + self.assertEqual(build_sales_order_no(self.db, self.customer), f"销售{year}-00010") + + def test_sales_order_no_ignores_malformed_same_year_codes(self) -> None: + year = date.today().strftime("%Y") + self._add_sales_order(f"销售{year}-001") + self._add_sales_order(f"销售{year}-000001") + self._add_sales_order(f"销售{year}-ABCDE") + + self.assertEqual(build_sales_order_no(self.db, self.customer), f"销售{year}-00001") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_sales_order_purchase_lock_flow.py b/backend/tests/test_sales_order_purchase_lock_flow.py new file mode 100644 index 0000000..289455e --- /dev/null +++ b/backend/tests/test_sales_order_purchase_lock_flow.py @@ -0,0 +1,428 @@ +from __future__ import annotations + +import unittest +from datetime import date, timedelta +from decimal import Decimal + +from fastapi import HTTPException +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.api.routes.purchase import create_purchase_order, delete_purchase_order, lock_purchase_order, update_purchase_order # noqa: E402 +from app.api.routes.sales import delete_sales_order # noqa: E402 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Bom, BomItem, Item, Material # noqa: E402 +from app.models.operations import PurchaseOrder, PurchaseOrderItem, PurchaseOrderSalesOrderLink, Supplier # noqa: E402 +from app.models.planning import MaterialDemand # noqa: E402 +from app.models.sales import Customer, SalesOrder, SalesOrderItem # noqa: E402 +from app.schemas.operations import PurchaseOrderCreate, PurchaseOrderItemCreate, PurchaseOrderUpdate # noqa: E402 +from app.services.operations import get_purchase_order_query, sync_purchase_order_item_status, sync_purchase_order_status # noqa: E402 + + +class SalesOrderPurchaseLockFlowTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self._seed_master_data() + + def tearDown(self) -> None: + self.db.close() + + def _seed_master_data(self) -> None: + self.customer = Customer( + id=1, + customer_code="CUS-LOCK-001", + customer_name="采购关联客户", + short_name="采购客户", + status="ACTIVE", + ) + self.product = Item( + id=1, + item_code="FG-LOCK-001", + item_name="锁单测试成品", + item_type="FINISHED_GOOD", + unit_weight_kg=Decimal("1"), + status="ACTIVE", + ) + self.material = Item( + id=2, + item_code="RM-LOCK-001", + item_name="锁单测试原料", + item_type="RAW_MATERIAL", + unit_weight_kg=Decimal("1"), + status="ACTIVE", + ) + self.supplier = Supplier( + id=1, + supplier_code="SUP-LOCK-001", + supplier_name="锁单供应商", + short_name="锁单供应商", + lead_time_days=0, + default_tax_rate=Decimal("0"), + status="ACTIVE", + ) + self.db.add_all([self.customer, self.product, self.material, self.supplier]) + self.db.flush() + self.db.add( + Material( + item_id=self.material.id, + default_supplier_id=self.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, + ) + ) + self.bom = Bom( + id=1, + bom_code="BOM-LOCK-001", + product_item_id=self.product.id, + version_no="V1.0", + is_default=1, + status="ACTIVE", + ) + self.bom_item = BomItem( + id=1, + bom_id=1, + seq_no=1, + material_item_id=self.material.id, + usage_type="WEIGHT", + gross_weight_per_piece_kg=Decimal("1"), + loss_rate=Decimal("0"), + ) + self.db.add_all([self.bom, self.bom_item]) + self.sales_order = SalesOrder( + id=1, + order_no="SO-LOCK-001", + customer_id=self.customer.id, + order_date=date.today(), + status="OPEN", + ) + self.sales_order_item = SalesOrderItem( + id=1, + sales_order_id=self.sales_order.id, + line_no=1, + product_item_id=self.product.id, + order_qty=Decimal("100"), + delivered_qty=Decimal("0"), + unit_price=Decimal("10"), + line_amount=Decimal("1000"), + status="OPEN", + ) + self.demand = MaterialDemand( + id=1, + demand_no="MRP-LOCK-001", + sales_order_item_id=self.sales_order_item.id, + product_item_id=self.product.id, + material_item_id=self.material.id, + bom_item_id=self.bom_item.id, + order_qty=Decimal("100"), + theoretical_weight_kg=Decimal("100"), + planned_weight_kg=Decimal("100"), + available_stock_weight_kg=Decimal("0"), + shortage_weight_kg=Decimal("100"), + suggested_purchase_qty=Decimal("100"), + suggested_purchase_weight_kg=Decimal("100"), + status="NEW", + ) + self.db.add_all([self.sales_order, self.sales_order_item, self.demand]) + self.db.commit() + + def test_purchase_order_tracks_sales_order_source_and_can_be_locked(self) -> None: + result = create_purchase_order( + PurchaseOrderCreate( + supplier_id=self.supplier.id, + expected_date=date.today(), + items=[ + PurchaseOrderItemCreate( + material_item_id=self.material.id, + source_demand_id=self.demand.id, + order_weight_kg=Decimal("100"), + unit_price=Decimal("5"), + ) + ], + ), + db=self.db, + ) + + self.assertEqual(result.source_type, "SALES_ORDER") + self.assertEqual(result.source_order_nos, "SO-LOCK-001") + + locked = lock_purchase_order(result.purchase_order_id, db=self.db) + self.assertEqual(locked.status, "LOCKED") + + with self.assertRaises(HTTPException) as exc: + delete_sales_order(self.sales_order.id, db=self.db) + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("已关联采购订单", exc.exception.detail) + + def test_purchase_order_warning_lead_days_marks_unreceived_order(self) -> None: + result = create_purchase_order( + PurchaseOrderCreate( + supplier_id=self.supplier.id, + expected_date=date.today() + timedelta(days=3), + warning_lead_days=3, + items=[ + PurchaseOrderItemCreate( + material_item_id=self.material.id, + order_weight_kg=Decimal("100"), + unit_price=Decimal("5"), + ) + ], + ), + db=self.db, + ) + + self.assertEqual(result.warning_lead_days, 3) + self.assertEqual(result.warning_start_date, date.today()) + self.assertTrue(result.is_warning_active) + + def test_purchase_order_warning_is_inactive_before_warning_start(self) -> None: + result = create_purchase_order( + PurchaseOrderCreate( + supplier_id=self.supplier.id, + expected_date=date.today() + timedelta(days=4), + warning_lead_days=3, + items=[ + PurchaseOrderItemCreate( + material_item_id=self.material.id, + order_weight_kg=Decimal("100"), + unit_price=Decimal("5"), + ) + ], + ), + db=self.db, + ) + + self.assertEqual(result.warning_lead_days, 3) + self.assertEqual(result.warning_start_date, date.today() + timedelta(days=1)) + self.assertFalse(result.is_warning_active) + + def test_explicit_purchase_order_link_blocks_sales_order_delete(self) -> None: + result = create_purchase_order( + PurchaseOrderCreate( + supplier_id=self.supplier.id, + expected_date=date.today(), + sales_order_ids=[self.sales_order.id], + items=[ + PurchaseOrderItemCreate( + material_item_id=self.material.id, + order_weight_kg=Decimal("100"), + unit_price=Decimal("5"), + ) + ], + ), + db=self.db, + ) + self.assertIsNotNone( + self.db.scalar( + select(PurchaseOrderSalesOrderLink).where( + PurchaseOrderSalesOrderLink.purchase_order_id == result.purchase_order_id, + PurchaseOrderSalesOrderLink.sales_order_id == self.sales_order.id, + ) + ) + ) + + with self.assertRaises(HTTPException) as exc: + delete_sales_order(self.sales_order.id, db=self.db) + + self.assertEqual(exc.exception.status_code, 400) + self.assertEqual(exc.exception.detail, "销售订单已关联采购订单,不能删除") + + def test_purchase_order_query_does_not_use_group_concat(self) -> None: + compiled = str(get_purchase_order_query().compile(self.db.get_bind())) + + self.assertNotIn("group_concat", compiled.lower()) + + def test_open_purchase_order_can_be_updated_before_lock(self) -> None: + result = create_purchase_order( + PurchaseOrderCreate( + supplier_id=self.supplier.id, + expected_date=date.today(), + items=[ + PurchaseOrderItemCreate( + material_item_id=self.material.id, + source_demand_id=self.demand.id, + order_weight_kg=Decimal("100"), + unit_price=Decimal("5"), + ) + ], + ), + db=self.db, + ) + + updated = update_purchase_order( + result.purchase_order_id, + PurchaseOrderUpdate( + supplier_id=self.supplier.id, + expected_date=date.today(), + remark="修改未锁单采购订单", + items=[ + PurchaseOrderItemCreate( + material_item_id=self.material.id, + source_demand_id=self.demand.id, + order_weight_kg=Decimal("120"), + unit_price=Decimal("6"), + ) + ], + ), + db=self.db, + ) + + self.assertEqual(updated.po_no, result.po_no) + self.assertEqual(updated.status, "OPEN") + self.assertEqual(float(updated.total_order_weight_kg), 120) + self.assertEqual(float(updated.total_amount), 720) + item = self.db.scalar(select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == result.purchase_order_id)) + self.assertEqual(float(item.order_weight_kg), 120) + self.assertEqual(float(item.unit_price), 6) + self.assertEqual(self.db.get(MaterialDemand, self.demand.id).status, "ORDERED") + + def test_locked_purchase_order_cannot_be_updated_or_deleted(self) -> None: + result = create_purchase_order( + PurchaseOrderCreate( + supplier_id=self.supplier.id, + expected_date=date.today(), + items=[ + PurchaseOrderItemCreate( + material_item_id=self.material.id, + source_demand_id=self.demand.id, + order_weight_kg=Decimal("100"), + unit_price=Decimal("5"), + ) + ], + ), + db=self.db, + ) + lock_purchase_order(result.purchase_order_id, db=self.db) + + with self.assertRaises(HTTPException) as update_exc: + update_purchase_order( + result.purchase_order_id, + PurchaseOrderUpdate( + supplier_id=self.supplier.id, + expected_date=date.today(), + items=[ + PurchaseOrderItemCreate( + material_item_id=self.material.id, + source_demand_id=self.demand.id, + order_weight_kg=Decimal("110"), + unit_price=Decimal("5"), + ) + ], + ), + db=self.db, + ) + self.assertEqual(update_exc.exception.status_code, 400) + + with self.assertRaises(HTTPException) as delete_exc: + delete_purchase_order(result.purchase_order_id, db=self.db) + self.assertEqual(delete_exc.exception.status_code, 400) + + def test_open_purchase_order_can_be_deleted_and_resets_unreferenced_demand(self) -> None: + result = create_purchase_order( + PurchaseOrderCreate( + supplier_id=self.supplier.id, + expected_date=date.today(), + items=[ + PurchaseOrderItemCreate( + material_item_id=self.material.id, + source_demand_id=self.demand.id, + order_weight_kg=Decimal("100"), + unit_price=Decimal("5"), + ) + ], + ), + db=self.db, + ) + self.assertEqual(self.db.get(MaterialDemand, self.demand.id).status, "ORDERED") + + delete_result = delete_purchase_order(result.purchase_order_id, db=self.db) + + self.assertIn("已删除", delete_result["message"]) + self.assertIsNone(self.db.get(PurchaseOrder, result.purchase_order_id)) + self.assertIsNone(self.db.scalar(select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == result.purchase_order_id))) + self.assertEqual(self.db.get(MaterialDemand, self.demand.id).status, "NEW") + + def test_unlinked_sales_order_can_be_deleted(self) -> None: + order = SalesOrder( + id=2, + order_no="SO-DELETE-001", + customer_id=self.customer.id, + order_date=date.today(), + status="OPEN", + ) + item = SalesOrderItem( + id=2, + sales_order_id=order.id, + line_no=1, + product_item_id=self.product.id, + order_qty=Decimal("1"), + delivered_qty=Decimal("0"), + unit_price=Decimal("1"), + line_amount=Decimal("1"), + status="OPEN", + ) + self.db.add_all([order, item]) + self.db.commit() + order_id = order.id + item_id = item.id + + result = delete_sales_order(order_id, db=self.db) + + self.assertIn("已删除", result["message"]) + self.assertIsNone(self.db.get(SalesOrder, order_id)) + self.assertIsNone(self.db.get(SalesOrderItem, item_id)) + + def test_locked_purchase_order_stays_locked_after_partial_receipt_status_sync(self) -> None: + order = PurchaseOrder( + id=10, + po_no="PO-LOCK-001", + supplier_id=self.supplier.id, + order_date=date.today(), + status="LOCKED", + ) + item = PurchaseOrderItem( + id=10, + purchase_order_id=order.id, + line_no=1, + material_item_id=self.material.id, + order_qty=Decimal("100"), + order_weight_kg=Decimal("100"), + received_qty=Decimal("50"), + received_weight_kg=Decimal("50"), + unit_price=Decimal("5"), + line_amount=Decimal("500"), + status="OPEN", + ) + self.db.add_all([order, item]) + self.db.commit() + + sync_purchase_order_item_status(item) + self.db.add(item) + sync_purchase_order_status(self.db, order.id) + self.db.commit() + + self.assertEqual(item.status, "PARTIAL") + self.assertEqual(self.db.get(PurchaseOrder, order.id).status, "LOCKED") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_scrap_warehouse_flow.py b/backend/tests/test_scrap_warehouse_flow.py new file mode 100644 index 0000000..8998f24 --- /dev/null +++ b/backend/tests/test_scrap_warehouse_flow.py @@ -0,0 +1,1166 @@ +from __future__ import annotations + +import unittest +from datetime import UTC, datetime +from decimal import Decimal +from io import BytesIO + +from fastapi import HTTPException +from openpyxl import Workbook, load_workbook +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Item, StockBalance, Warehouse # noqa: E402 +from app.models.operations import InventoryTxn, StockLot, StocktakeLine, WarehouseLocation # noqa: E402 +from app.schemas.operations import WarehouseInboundCreate, WarehouseOutboundCreate # noqa: E402 + + +class User: + id = 1 + + +class Context: + user = User() + + +class ScrapWarehouseFlowTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self._seed() + + def tearDown(self) -> None: + self.db.close() + + def _seed(self) -> None: + now = datetime.now(UTC) + self.scrap_warehouse = Warehouse( + id=1, + warehouse_code="WH-SCRAP-01", + warehouse_name="废料库", + warehouse_type="SCRAP", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.raw_warehouse = Warehouse( + id=2, + warehouse_code="WH-RM-01", + warehouse_name="原材料库", + warehouse_type="RAW", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.scrap_location = WarehouseLocation( + id=1, + warehouse_id=1, + location_code="SCR-E-01", + location_name="废料暂存位", + is_default=1, + is_locked=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.raw_location = WarehouseLocation( + id=2, + warehouse_id=2, + location_code="RM-A-01", + location_name="原料主库位", + is_default=1, + is_locked=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.raw_item = Item( + id=1, + item_code="原材料00001", + item_name="304不锈钢边角料", + item_type="RAW_MATERIAL", + unit_weight_kg=Decimal("1"), + safety_stock_weight_kg=Decimal("0"), + scrap_sale_price=Decimal("1.6"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.product_item = Item( + id=2, + item_code="产品需规00001", + item_name="冲压支架报废品", + item_type="FINISHED_GOOD", + unit_weight_kg=Decimal("2.5"), + safety_stock_weight_kg=Decimal("0"), + scrap_sale_price=Decimal("1.2"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add_all( + [ + self.scrap_warehouse, + self.raw_warehouse, + self.scrap_location, + self.raw_location, + self.raw_item, + self.product_item, + ] + ) + self.db.commit() + + def _opening_workbook(self, rows: list[list[object]]) -> bytes: + from app.api.routes.inventory import OPENING_INVENTORY_HEADERS + + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "废料库期初导入" + worksheet.append(OPENING_INVENTORY_HEADERS) + for row in rows: + worksheet.append(row) + output = BytesIO() + workbook.save(output) + return output.getvalue() + + def test_scrap_opening_template_exports_sample_rows(self) -> None: + from app.api.routes.inventory import build_opening_inventory_template + + workbook = build_opening_inventory_template(self.db, "SCRAP") + output = BytesIO() + workbook.save(output) + loaded = load_workbook(BytesIO(output.getvalue()), read_only=True, data_only=True) + rows = list(loaded["废料库期初导入"].iter_rows(values_only=True)) + headers = list(rows[0]) + + self.assertEqual(rows[1][headers.index("仓库类型")], "废料库") + self.assertEqual(rows[1][headers.index("仓库名称")], "废料库") + self.assertIn("示例-SCRAP-", rows[1][headers.index("批次号")]) + self.assertGreater(rows[1][headers.index("重量(kg)")], 0) + + def test_scrap_opening_import_is_weight_only(self) -> None: + from app.api.routes.inventory import import_opening_inventory_workbook + + content = self._opening_workbook( + [[ + "废料库", + "废料库", + "废料暂存位", + "原材料00001", + "304不锈钢边角料", + "OPEN-SCRAP-001", + 99, + 125, + 1.6, + "初始化废料", + "期初废料", + ]] + ) + + result = import_opening_inventory_workbook(self.db, "SCRAP", content, user_id=1) + + lot = self.db.scalar(select(StockLot).where(StockLot.lot_no == "OPEN-SCRAP-001")) + balance = self.db.scalar( + select(StockBalance).where( + StockBalance.item_id == self.raw_item.id, + StockBalance.warehouse_id == self.scrap_warehouse.id, + ) + ) + + self.assertIsNotNone(lot) + self.assertIsNotNone(balance) + txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == lot.id)) + self.assertIsNotNone(txn) + self.assertEqual(result["warehouse_type"], "SCRAP") + self.assertEqual(result["imported"], 1) + self.assertEqual(float(lot.inbound_qty), 0) + self.assertEqual(float(lot.remaining_qty), 0) + self.assertEqual(float(lot.inbound_weight_kg), 125) + self.assertEqual(float(balance.qty_on_hand), 0) + self.assertEqual(float(balance.weight_on_hand_kg), 125) + self.assertEqual(txn.txn_type, "OPENING_IN") + self.assertEqual(float(txn.qty_change), 0) + self.assertEqual(float(txn.weight_change_kg), 125) + + def test_production_scrap_inbound_creates_scrap_lot_balance_and_txn(self) -> None: + from app.api.routes.inventory import create_warehouse_inbound + + with self.assertRaises(HTTPException) as cm: + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SCRAP_IN", + item_id=self.raw_item.id, + warehouse_id=self.scrap_warehouse.id, + inbound_weight_kg=36.5, + unit_cost=1.6, + source_material_sub_batch_no="JH_110501000185_0001", + source_material_summary="LAC-51012170/71左右后纵梁后段 1序报废边角料", + remark="生产废料入库", + ), + context=Context(), + db=self.db, + ) + + self.assertEqual(cm.exception.status_code, 400) + self.assertIn("生产废料入库必须选择生产工单", cm.exception.detail) + + def test_production_surplus_returns_weight_to_selected_source_lot(self) -> None: + from app.api.routes.inventory import create_warehouse_inbound + + now = datetime.now(UTC) + source_lot = StockLot( + id=101, + lot_no="JH_5系钢板00002_0001", + lot_role="RAW_MATERIAL", + item_id=self.raw_item.id, + warehouse_id=self.raw_warehouse.id, + location_id=self.raw_location.id, + source_doc_type="PURCHASE_RECEIPT", + source_doc_id=30003, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("600"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("400"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("6"), + quality_status="PASS", + status="AVAILABLE", + created_at=now, + updated_at=now, + ) + balance = StockBalance( + item_id=self.raw_item.id, + warehouse_id=self.raw_warehouse.id, + location_id=self.raw_location.id, + qty_on_hand=Decimal("0"), + weight_on_hand_kg=Decimal("400"), + qty_available=Decimal("0"), + weight_available_kg=Decimal("400"), + qty_allocated=Decimal("0"), + weight_allocated_kg=Decimal("0"), + avg_unit_cost=Decimal("6"), + updated_at=now, + ) + self.db.add_all([source_lot, balance]) + self.db.commit() + + with self.assertRaises(HTTPException) as cm: + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SURPLUS", + item_id=self.raw_item.id, + warehouse_id=self.raw_warehouse.id, + inbound_weight_kg=80, + unit_cost=99, + source_lot_id=source_lot.id, + remark="生产余料归还", + ), + context=Context(), + db=self.db, + ) + + self.assertEqual(cm.exception.status_code, 400) + self.assertIn("生产余料入库必须选择生产工单", cm.exception.detail) + + def test_generated_inventory_remarks_use_chinese_business_labels(self) -> None: + from app.api.routes.inventory import create_warehouse_inbound, create_warehouse_outbound + + now = datetime.now(UTC) + source_lot = StockLot( + id=103, + lot_no="JH_5系钢板00002_0003", + lot_role="RAW_MATERIAL", + item_id=self.raw_item.id, + warehouse_id=self.raw_warehouse.id, + location_id=self.raw_location.id, + source_doc_type="PURCHASE_RECEIPT", + source_doc_id=30007, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("500"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("180"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("5"), + quality_status="PASS", + status="AVAILABLE", + created_at=now, + updated_at=now, + ) + balance = StockBalance( + item_id=self.raw_item.id, + warehouse_id=self.raw_warehouse.id, + location_id=self.raw_location.id, + qty_on_hand=Decimal("0"), + weight_on_hand_kg=Decimal("180"), + qty_available=Decimal("0"), + weight_available_kg=Decimal("180"), + qty_allocated=Decimal("0"), + weight_allocated_kg=Decimal("0"), + avg_unit_cost=Decimal("5"), + updated_at=now, + ) + self.db.add_all([source_lot, balance]) + self.db.commit() + + with self.assertRaises(HTTPException) as surplus_exc: + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SURPLUS", + item_id=self.raw_item.id, + warehouse_id=self.raw_warehouse.id, + inbound_weight_kg=20, + unit_cost=99, + source_lot_id=source_lot.id, + ), + context=Context(), + db=self.db, + ) + create_warehouse_outbound( + WarehouseOutboundCreate( + biz_type="SCRAP_OUT", + item_id=self.raw_item.id, + warehouse_id=self.raw_warehouse.id, + outbound_weight_kg=15, + source_lots=[{"source_lot_id": source_lot.id, "outbound_weight_kg": 15}], + ), + context=Context(), + db=self.db, + ) + + scrap_txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "SCRAP_OUT")) + + self.assertEqual(surplus_exc.exception.status_code, 400) + self.assertIn("生产余料入库必须选择生产工单", surplus_exc.exception.detail) + self.assertIsNotNone(scrap_txn) + self.assertIn("报废出库", scrap_txn.remark) + self.assertNotIn("SCRAP_OUT", scrap_txn.remark) + + def test_outsourcing_surplus_returns_weight_to_selected_source_lot(self) -> None: + from app.api.routes.inventory import create_warehouse_inbound + + now = datetime.now(UTC) + source_lot = StockLot( + id=102, + lot_no="JH_5系钢板00002_0002", + lot_role="RAW_MATERIAL", + item_id=self.raw_item.id, + warehouse_id=self.raw_warehouse.id, + location_id=self.raw_location.id, + source_doc_type="PURCHASE_RECEIPT", + source_doc_id=30006, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("500"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("120"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("5"), + quality_status="PASS", + status="AVAILABLE", + created_at=now, + updated_at=now, + ) + balance = StockBalance( + item_id=self.raw_item.id, + warehouse_id=self.raw_warehouse.id, + location_id=self.raw_location.id, + qty_on_hand=Decimal("0"), + weight_on_hand_kg=Decimal("120"), + qty_available=Decimal("0"), + weight_available_kg=Decimal("120"), + qty_allocated=Decimal("0"), + weight_allocated_kg=Decimal("0"), + avg_unit_cost=Decimal("5"), + updated_at=now, + ) + self.db.add_all([source_lot, balance]) + self.db.commit() + + row = create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="OUTSOURCING_SURPLUS", + item_id=self.raw_item.id, + warehouse_id=self.raw_warehouse.id, + inbound_weight_kg=30, + unit_cost=99, + source_lot_id=source_lot.id, + provider_name="委外厂A", + waybill_no="YD-WWSY-001", + freight_amount=12, + remark="委外余料归还", + ), + context=Context(), + db=self.db, + ) + + self.db.refresh(source_lot) + self.db.refresh(balance) + lots = self.db.scalars(select(StockLot).where(StockLot.item_id == self.raw_item.id)).all() + txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "OUTSOURCING_SURPLUS_IN")) + + self.assertEqual(row.lot_id, source_lot.id) + self.assertEqual(len(lots), 1) + self.assertEqual(float(source_lot.inbound_weight_kg), 500) + self.assertEqual(float(source_lot.remaining_weight_kg), 150) + self.assertEqual(float(balance.weight_on_hand_kg), 150) + self.assertEqual(float(balance.weight_available_kg), 150) + self.assertIsNotNone(txn) + self.assertEqual(txn.lot_id, source_lot.id) + self.assertEqual(float(txn.weight_change_kg), 30) + self.assertEqual(float(txn.unit_cost), 5) + self.assertEqual(float(txn.amount), 150) + self.assertIn("来源/提供方:委外厂A", txn.remark) + self.assertEqual(txn.logistics_waybill_no, "YD-WWSY-001") + self.assertEqual(float(txn.logistics_freight_amount), 12) + + def test_outsourcing_outbound_deducts_selected_source_lots(self) -> None: + from app.api.routes.inventory import create_warehouse_outbound + + now = datetime.now(UTC) + lot_a = StockLot( + id=201, + lot_no="JH_5系钢板00002_0001", + lot_role="RAW_MATERIAL", + item_id=self.raw_item.id, + warehouse_id=self.raw_warehouse.id, + location_id=self.raw_location.id, + source_doc_type="PURCHASE_RECEIPT", + source_doc_id=30001, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("100"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("50"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("5"), + quality_status="PASS", + status="AVAILABLE", + created_at=now, + updated_at=now, + ) + lot_b = StockLot( + id=202, + lot_no="JH_5系钢板00002_0002", + lot_role="RAW_MATERIAL", + item_id=self.raw_item.id, + warehouse_id=self.raw_warehouse.id, + location_id=self.raw_location.id, + source_doc_type="PURCHASE_RECEIPT", + source_doc_id=30002, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("120"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("90"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("6"), + quality_status="PASS", + status="AVAILABLE", + created_at=now, + updated_at=now, + ) + lot_c = StockLot( + id=203, + lot_no="JH_5系钢板00002_0003", + lot_role="RAW_MATERIAL", + item_id=self.raw_item.id, + warehouse_id=self.raw_warehouse.id, + location_id=self.raw_location.id, + source_doc_type="PURCHASE_RECEIPT", + source_doc_id=30003, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("80"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("60"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("7"), + quality_status="PASS", + status="AVAILABLE", + created_at=now, + updated_at=now, + ) + balance = StockBalance( + item_id=self.raw_item.id, + warehouse_id=self.raw_warehouse.id, + location_id=self.raw_location.id, + qty_on_hand=Decimal("0"), + weight_on_hand_kg=Decimal("200"), + qty_available=Decimal("0"), + weight_available_kg=Decimal("200"), + qty_allocated=Decimal("0"), + weight_allocated_kg=Decimal("0"), + avg_unit_cost=Decimal("6"), + updated_at=now, + ) + self.db.add_all([lot_a, lot_b, lot_c, balance]) + self.db.commit() + + row = create_warehouse_outbound( + WarehouseOutboundCreate( + biz_type="OUTSOURCING_OUT", + item_id=self.raw_item.id, + warehouse_id=self.raw_warehouse.id, + outbound_weight_kg=50, + source_lots=[ + {"source_lot_id": lot_b.id, "outbound_weight_kg": 30}, + {"source_lot_id": lot_c.id, "outbound_weight_kg": 20}, + ], + waybill_no="YD-WWCK-001", + freight_amount=18, + remark="委外出库指定批次", + ), + context=Context(), + db=self.db, + ) + + self.db.refresh(lot_a) + self.db.refresh(lot_b) + self.db.refresh(lot_c) + self.db.refresh(balance) + txns = self.db.scalars( + select(InventoryTxn) + .where(InventoryTxn.txn_type == "OUTSOURCING_OUT") + .order_by(InventoryTxn.lot_id.asc()) + ).all() + + self.assertEqual(row.lot_id, lot_b.id) + self.assertEqual(float(lot_a.remaining_weight_kg), 50) + self.assertEqual(float(lot_b.remaining_weight_kg), 60) + self.assertEqual(float(lot_c.remaining_weight_kg), 40) + self.assertEqual(float(balance.weight_on_hand_kg), 150) + self.assertEqual(float(balance.weight_available_kg), 150) + self.assertEqual([txn.lot_id for txn in txns], [lot_b.id, lot_c.id]) + self.assertEqual([float(txn.weight_change_kg) for txn in txns], [-30, -20]) + self.assertTrue(all(txn.logistics_waybill_no == "YD-WWCK-001" for txn in txns)) + + def test_scrap_outbound_deducts_selected_source_lots(self) -> None: + from app.api.routes.inventory import create_warehouse_outbound + + now = datetime.now(UTC) + lot_a = StockLot( + id=211, + lot_no="JH_5系钢板00002_0001", + lot_role="RAW_MATERIAL", + item_id=self.raw_item.id, + warehouse_id=self.raw_warehouse.id, + location_id=self.raw_location.id, + source_doc_type="PURCHASE_RECEIPT", + source_doc_id=30011, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("100"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("40"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("5"), + quality_status="PASS", + status="AVAILABLE", + created_at=now, + updated_at=now, + ) + lot_b = StockLot( + id=212, + lot_no="JH_5系钢板00002_0002", + lot_role="RAW_MATERIAL", + item_id=self.raw_item.id, + warehouse_id=self.raw_warehouse.id, + location_id=self.raw_location.id, + source_doc_type="PURCHASE_RECEIPT", + source_doc_id=30012, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("130"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("75"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("6"), + quality_status="PASS", + status="AVAILABLE", + created_at=now, + updated_at=now, + ) + balance = StockBalance( + item_id=self.raw_item.id, + warehouse_id=self.raw_warehouse.id, + location_id=self.raw_location.id, + qty_on_hand=Decimal("0"), + weight_on_hand_kg=Decimal("115"), + qty_available=Decimal("0"), + weight_available_kg=Decimal("115"), + qty_allocated=Decimal("0"), + weight_allocated_kg=Decimal("0"), + avg_unit_cost=Decimal("5.5"), + updated_at=now, + ) + self.db.add_all([lot_a, lot_b, balance]) + self.db.commit() + + row = create_warehouse_outbound( + WarehouseOutboundCreate( + biz_type="SCRAP_OUT", + item_id=self.raw_item.id, + warehouse_id=self.raw_warehouse.id, + outbound_weight_kg=45, + source_lots=[ + {"source_lot_id": lot_a.id, "outbound_weight_kg": 15}, + {"source_lot_id": lot_b.id, "outbound_weight_kg": 30}, + ], + remark="报废出库指定批次", + ), + context=Context(), + db=self.db, + ) + + self.db.refresh(lot_a) + self.db.refresh(lot_b) + self.db.refresh(balance) + txns = self.db.scalars( + select(InventoryTxn) + .where(InventoryTxn.txn_type == "SCRAP_OUT") + .order_by(InventoryTxn.lot_id.asc()) + ).all() + + self.assertEqual(row.lot_id, lot_a.id) + self.assertEqual(float(lot_a.remaining_weight_kg), 25) + self.assertEqual(float(lot_b.remaining_weight_kg), 45) + self.assertEqual(float(balance.weight_on_hand_kg), 70) + self.assertEqual(float(balance.weight_available_kg), 70) + self.assertEqual([txn.lot_id for txn in txns], [lot_a.id, lot_b.id]) + self.assertEqual([float(txn.weight_change_kg) for txn in txns], [-15, -30]) + self.assertTrue(all("来源库存批次号" in (txn.remark or "") for txn in txns)) + + def test_outsourcing_scrap_inbound_accepts_product_item(self) -> None: + from app.api.routes.inventory import create_warehouse_inbound + + row = create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="OUTSOURCING_SCRAP_IN", + item_id=self.product_item.id, + warehouse_id=self.scrap_warehouse.id, + inbound_weight_kg=12, + unit_cost=1.2, + provider_name="外协加工厂A", + waybill_no="YD-WWFL-001", + freight_amount=18.5, + remark="委外废料入库", + ), + context=Context(), + db=self.db, + ) + + lot = self.db.get(StockLot, row.lot_id) + self.assertIsNotNone(lot) + txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == lot.id)) + + self.assertIsNotNone(txn) + self.assertEqual(lot.lot_role, "OUTSOURCING_SCRAP") + self.assertEqual(lot.source_doc_type, "OUTSOURCING_SCRAP_IN") + self.assertEqual(float(lot.inbound_qty), 0) + self.assertEqual(lot.logistics_waybill_no, "YD-WWFL-001") + self.assertEqual(float(lot.logistics_freight_amount), 18.5) + self.assertEqual(txn.txn_type, "OUTSOURCING_SCRAP_IN") + self.assertEqual(txn.logistics_waybill_no, "YD-WWFL-001") + self.assertEqual(float(txn.logistics_freight_amount), 18.5) + + def test_selected_generic_warehouse_operations_require_logistics_info(self) -> None: + from app.api.routes.inventory import create_warehouse_inbound, create_warehouse_outbound + + with self.assertRaises(HTTPException) as inbound_exc: + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="OUTSOURCING_SCRAP_IN", + item_id=self.product_item.id, + warehouse_id=self.scrap_warehouse.id, + inbound_weight_kg=12, + unit_cost=1.2, + provider_name="外协加工厂A", + ), + context=Context(), + db=self.db, + ) + self.assertEqual(inbound_exc.exception.status_code, 400) + self.assertIn("运单号", inbound_exc.exception.detail) + self.assertIn("运费", inbound_exc.exception.detail) + + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="OPENING", + item_id=self.raw_item.id, + warehouse_id=self.scrap_warehouse.id, + inbound_weight_kg=100, + unit_cost=1.6, + ), + context=Context(), + db=self.db, + ) + with self.assertRaises(HTTPException) as outbound_exc: + create_warehouse_outbound( + WarehouseOutboundCreate( + biz_type="SCRAP_SALE_OUT", + item_id=self.raw_item.id, + warehouse_id=self.scrap_warehouse.id, + outbound_weight_kg=40, + sale_unit_price=2.5, + buyer_name="废品回收商A", + ), + context=Context(), + db=self.db, + ) + self.assertEqual(outbound_exc.exception.status_code, 400) + self.assertIn("运单号", outbound_exc.exception.detail) + self.assertIn("运费", outbound_exc.exception.detail) + + def test_required_generic_logistics_allows_order_photo_instead_of_waybill(self) -> None: + from app.api.routes.inventory import create_warehouse_inbound, create_warehouse_outbound + + inbound_row = create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="OUTSOURCING_SCRAP_IN", + item_id=self.product_item.id, + warehouse_id=self.scrap_warehouse.id, + inbound_weight_kg=12, + unit_cost=1.2, + provider_name="外协加工厂A", + order_photo_url="/inventory/logistics-photos/test-inbound.jpg", + freight_amount=18.5, + ), + context=Context(), + db=self.db, + ) + inbound_lot = self.db.get(StockLot, inbound_row.lot_id) + inbound_txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == inbound_lot.id)) + self.assertIsNone(inbound_lot.logistics_waybill_no) + self.assertEqual(inbound_lot.logistics_photo_url, "/inventory/logistics-photos/test-inbound.jpg") + self.assertEqual(inbound_txn.logistics_photo_url, "/inventory/logistics-photos/test-inbound.jpg") + + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="OPENING", + item_id=self.raw_item.id, + warehouse_id=self.scrap_warehouse.id, + inbound_weight_kg=100, + unit_cost=1.6, + ), + context=Context(), + db=self.db, + ) + create_warehouse_outbound( + WarehouseOutboundCreate( + biz_type="SCRAP_SALE_OUT", + item_id=self.raw_item.id, + warehouse_id=self.scrap_warehouse.id, + outbound_weight_kg=40, + sale_unit_price=2.5, + buyer_name="废品回收商A", + order_photo_url="/inventory/logistics-photos/test-outbound.jpg", + freight_amount=10, + ), + context=Context(), + db=self.db, + ) + outbound_txn = self.db.scalar( + select(InventoryTxn) + .where(InventoryTxn.txn_type == "SCRAP_SALE_OUT") + .order_by(InventoryTxn.id.desc()) + .limit(1) + ) + self.assertIsNone(outbound_txn.logistics_waybill_no) + self.assertEqual(outbound_txn.logistics_photo_url, "/inventory/logistics-photos/test-outbound.jpg") + + def test_scrap_sale_outbound_schema_preserves_sale_fields(self) -> None: + payload = WarehouseOutboundCreate( + biz_type="SCRAP_SALE_OUT", + item_id=self.raw_item.id, + warehouse_id=self.scrap_warehouse.id, + outbound_weight_kg=40, + sale_unit_price=2.5, + buyer_name="废品回收商A", + sale_remark="按废不锈钢售卖", + ) + + self.assertEqual(float(payload.sale_unit_price), 2.5) + self.assertEqual(payload.buyer_name, "废品回收商A") + self.assertEqual(payload.sale_remark, "按废不锈钢售卖") + + def test_scrap_sale_outbound_uses_sale_price_for_transaction_amount(self) -> None: + from app.api.routes.inventory import create_warehouse_inbound, create_warehouse_outbound + + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="OPENING", + item_id=self.raw_item.id, + warehouse_id=self.scrap_warehouse.id, + inbound_weight_kg=100, + unit_cost=1.6, + ), + context=Context(), + db=self.db, + ) + + row = create_warehouse_outbound( + WarehouseOutboundCreate( + biz_type="SCRAP_SALE_OUT", + item_id=self.raw_item.id, + warehouse_id=self.scrap_warehouse.id, + outbound_weight_kg=40, + sale_unit_price=2.5, + buyer_name="废品回收商A", + waybill_no="YD-FLSM-001", + freight_amount=10, + sale_remark="按废不锈钢售卖", + ), + context=Context(), + db=self.db, + ) + + txn = self.db.scalar( + select(InventoryTxn) + .where(InventoryTxn.txn_type == "SCRAP_SALE_OUT") + .order_by(InventoryTxn.id.desc()) + .limit(1) + ) + balance = self.db.scalar( + select(StockBalance).where( + StockBalance.item_id == self.raw_item.id, + StockBalance.warehouse_id == self.scrap_warehouse.id, + ) + ) + + self.assertIsNotNone(txn) + self.assertIsNotNone(balance) + self.assertEqual(row.warehouse_type, "SCRAP") + self.assertEqual(float(balance.weight_on_hand_kg), 60) + self.assertEqual(float(txn.unit_cost), 2.5) + self.assertEqual(float(txn.amount), 100) + self.assertEqual(txn.logistics_waybill_no, "YD-FLSM-001") + self.assertEqual(float(txn.logistics_freight_amount), 10) + self.assertIn("废品回收商A", txn.remark) + + def test_scrap_sale_outbound_deducts_selected_scrap_lots_and_records_raw_source_lots(self) -> None: + from app.api.routes.inventory import create_warehouse_outbound + + now = datetime.now(UTC) + raw_source_lot = StockLot( + id=511, + lot_no="JH_304不锈钢00001_0001", + lot_role="INVENTORY", + item_id=self.raw_item.id, + warehouse_id=self.raw_warehouse.id, + location_id=self.raw_location.id, + source_doc_type="PURCHASE_RECEIPT", + source_doc_id=401, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("100"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("20"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("6"), + quality_status="PASS", + status="AVAILABLE", + created_at=now, + updated_at=now, + ) + scrap_lot_a = StockLot( + id=611, + lot_no="FL-20260602-001", + lot_role="SCRAP", + item_id=self.raw_item.id, + warehouse_id=self.scrap_warehouse.id, + location_id=self.scrap_location.id, + source_doc_type="PRODUCTION_SCRAP_IN", + source_doc_id=501, + source_material_lot_id=raw_source_lot.id, + source_material_sub_batch_no="JH_旧文本来源_0009", + source_material_summary="来源库存批次号:JH_304不锈钢00001_0001", + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("40"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("25"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("1.2"), + quality_status="PASS", + status="AVAILABLE", + created_at=now, + updated_at=now, + ) + scrap_lot_b = StockLot( + id=612, + lot_no="FL-20260602-002", + lot_role="SCRAP", + item_id=self.raw_item.id, + warehouse_id=self.scrap_warehouse.id, + location_id=self.scrap_location.id, + source_doc_type="OUTSOURCING_SCRAP_IN", + source_doc_id=502, + source_material_sub_batch_no="JH_304不锈钢00001_0002", + source_material_summary="委外废料;来源库存批次号:JH_304不锈钢00001_0002", + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("60"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("45"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("1.3"), + quality_status="PASS", + status="AVAILABLE", + created_at=now, + updated_at=now, + ) + balance = StockBalance( + item_id=self.raw_item.id, + warehouse_id=self.scrap_warehouse.id, + location_id=self.scrap_location.id, + qty_on_hand=Decimal("0"), + weight_on_hand_kg=Decimal("70"), + qty_available=Decimal("0"), + weight_available_kg=Decimal("70"), + qty_allocated=Decimal("0"), + weight_allocated_kg=Decimal("0"), + avg_unit_cost=Decimal("1.25"), + updated_at=now, + ) + self.db.add_all([raw_source_lot, scrap_lot_a, scrap_lot_b, balance]) + self.db.commit() + + create_warehouse_outbound( + WarehouseOutboundCreate( + biz_type="SCRAP_SALE_OUT", + item_id=self.raw_item.id, + warehouse_id=self.scrap_warehouse.id, + outbound_weight_kg=45, + sale_unit_price=2.5, + buyer_name="废品回收商A", + waybill_no="YD-FLSM-002", + freight_amount=12, + source_lots=[ + {"source_lot_id": scrap_lot_a.id, "outbound_weight_kg": 15}, + {"source_lot_id": scrap_lot_b.id, "outbound_weight_kg": 30}, + ], + ), + context=Context(), + db=self.db, + ) + + self.db.refresh(scrap_lot_a) + self.db.refresh(scrap_lot_b) + self.db.refresh(balance) + txns = self.db.scalars( + select(InventoryTxn) + .where(InventoryTxn.txn_type == "SCRAP_SALE_OUT") + .order_by(InventoryTxn.id.asc()) + ).all() + + self.assertEqual(float(scrap_lot_a.remaining_weight_kg), 10) + self.assertEqual(float(scrap_lot_b.remaining_weight_kg), 15) + self.assertEqual(float(balance.weight_on_hand_kg), 25) + self.assertEqual(len(txns), 2) + self.assertIn("来源库存批次号 JH_304不锈钢00001_0001", txns[0].remark) + self.assertIn("来源库存批次号 JH_304不锈钢00001_0002", txns[1].remark) + + def test_scrap_warehouse_rejects_non_scrap_operations(self) -> None: + from app.api.routes.inventory import create_warehouse_inbound, create_warehouse_outbound + + with self.assertRaises(HTTPException) as inbound_exc: + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="CUSTOMER_SUPPLIED", + item_id=self.raw_item.id, + warehouse_id=self.scrap_warehouse.id, + inbound_weight_kg=5, + ), + context=Context(), + db=self.db, + ) + self.assertEqual(inbound_exc.exception.status_code, 400) + self.assertIn("废料库不允许执行该入库业务", inbound_exc.exception.detail) + + with self.assertRaises(HTTPException) as outbound_exc: + create_warehouse_outbound( + WarehouseOutboundCreate( + biz_type="SCRAP_OUT", + item_id=self.raw_item.id, + warehouse_id=self.scrap_warehouse.id, + outbound_weight_kg=5, + ), + context=Context(), + db=self.db, + ) + self.assertEqual(outbound_exc.exception.status_code, 400) + self.assertIn("废料库不允许执行该出库业务", outbound_exc.exception.detail) + + def test_stocktake_export_uses_scrap_sheet_name(self) -> None: + from app.services.stocktake import build_stocktake_workbook, start_stocktake + + self.db.add( + StockLot( + id=10, + lot_no="SCRAP-STOCKTAKE-001", + lot_role="PRODUCTION_SCRAP", + item_id=self.raw_item.id, + warehouse_id=self.scrap_warehouse.id, + location_id=self.scrap_location.id, + source_doc_type="PRODUCTION_SCRAP_IN", + source_doc_id=10, + source_material_sub_batch_no="JH_110501000185_0001", + source_material_summary="盘库用废料", + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("66"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("66"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("1.6"), + quality_status="PASS", + status="AVAILABLE", + ) + ) + self.db.add( + StockBalance( + item_id=self.raw_item.id, + warehouse_id=self.scrap_warehouse.id, + location_id=self.scrap_location.id, + qty_on_hand=Decimal("0"), + weight_on_hand_kg=Decimal("66"), + qty_available=Decimal("0"), + weight_available_kg=Decimal("66"), + qty_allocated=Decimal("0"), + weight_allocated_kg=Decimal("0"), + avg_unit_cost=Decimal("1.6"), + ) + ) + self.db.commit() + + stocktake = start_stocktake(self.db, warehouse_ids=[self.scrap_warehouse.id], user_id=1, remark="废料库盘库") + workbook = build_stocktake_workbook(self.db, stocktake.id) + output = BytesIO() + workbook.save(output) + loaded = load_workbook(BytesIO(output.getvalue()), read_only=True, data_only=True) + + self.assertIn("废料库", loaded.sheetnames) + rows = list(loaded["废料库"].iter_rows(values_only=True)) + headers = list(rows[0]) + + self.assertEqual(rows[1][headers.index("仓库")], "废料库") + self.assertEqual(rows[1][headers.index("库位")], "废料暂存位") + self.assertEqual(rows[1][headers.index("物料编码")], "原材料00001") + self.assertEqual(rows[1][headers.index("批次号")], "SCRAP-STOCKTAKE-001") + self.assertEqual(rows[1][headers.index("来源库存批次号")], "JH_110501000185_0001") + self.assertNotIn("系统数量", headers) + self.assertNotIn("盘点数量", headers) + self.assertEqual(rows[1][headers.index("系统重量(kg)")], 66) + self.assertEqual(rows[1][headers.index("系统单价")], 1.6) + + def test_runtime_seed_normalizes_existing_scrap_warehouse_name(self) -> None: + from app.api.routes.master_data import _ensure_jiaheng_warehouses + + self.scrap_warehouse.warehouse_name = "废料仓" + self.scrap_warehouse.remark = "旧废料仓说明" + self.db.add(self.scrap_warehouse) + self.db.commit() + + _ensure_jiaheng_warehouses(self.db) + self.db.refresh(self.scrap_warehouse) + + self.assertEqual(self.scrap_warehouse.warehouse_name, "废料库") + self.assertEqual(self.scrap_warehouse.remark, "生产废料、边角料、委外废料、报废品等废料库存") + + + def test_scrap_stocktake_keeps_product_scrap_weight_only_after_confirm(self) -> None: + from app.services.stocktake import build_stocktake_workbook, confirm_stocktake, import_stocktake_workbook, start_stocktake + + self.db.add( + StockLot( + id=11, + lot_no="SCRAP-PRODUCT-001", + lot_role="OUTSOURCING_SCRAP", + item_id=self.product_item.id, + warehouse_id=self.scrap_warehouse.id, + location_id=self.scrap_location.id, + source_doc_type="OUTSOURCING_SCRAP_IN", + source_doc_id=11, + source_material_summary="产品报废件盘库", + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("30"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("30"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("1.2"), + quality_status="PASS", + status="AVAILABLE", + ) + ) + self.db.add( + StockBalance( + item_id=self.product_item.id, + warehouse_id=self.scrap_warehouse.id, + location_id=self.scrap_location.id, + qty_on_hand=Decimal("0"), + weight_on_hand_kg=Decimal("30"), + qty_available=Decimal("0"), + weight_available_kg=Decimal("30"), + qty_allocated=Decimal("0"), + weight_allocated_kg=Decimal("0"), + avg_unit_cost=Decimal("1.2"), + ) + ) + self.db.commit() + + stocktake = start_stocktake(self.db, warehouse_ids=[self.scrap_warehouse.id], user_id=1, remark="产品废料盘库") + workbook = build_stocktake_workbook(self.db, stocktake.id) + output = BytesIO() + workbook.save(output) + loaded = load_workbook(BytesIO(output.getvalue())) + worksheet = loaded["废料库"] + headers = [cell.value for cell in worksheet[1]] + self.assertNotIn("盘点数量", headers) + worksheet.cell(row=2, column=headers.index("盘点重量(kg)") + 1, value=45) + imported_output = BytesIO() + loaded.save(imported_output) + + import_stocktake_workbook(self.db, stocktake.id, imported_output.getvalue(), user_id=1) + confirmed = confirm_stocktake(self.db, stocktake.id, user_id=2, confirm_text="", remark="确认产品废料盘库") + + self.assertEqual(confirmed.status, "CONFIRMED") + lot = self.db.scalar(select(StockLot).where(StockLot.lot_no == "SCRAP-PRODUCT-001")) + balance = self.db.scalar( + select(StockBalance).where( + StockBalance.item_id == self.product_item.id, + StockBalance.warehouse_id == self.scrap_warehouse.id, + ) + ) + line = self.db.scalar(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake.id)) + + self.assertIsNotNone(lot) + self.assertIsNotNone(balance) + self.assertIsNotNone(line) + self.assertEqual(float(line.counted_qty), 0) + self.assertEqual(float(line.diff_qty), 0) + self.assertEqual(float(lot.remaining_qty), 0) + self.assertEqual(float(lot.remaining_weight_kg), 45) + self.assertEqual(float(balance.qty_on_hand), 0) + self.assertEqual(float(balance.qty_available), 0) + self.assertEqual(float(balance.weight_on_hand_kg), 45) + + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_selected_stock_lot_production_issue.py b/backend/tests/test_selected_stock_lot_production_issue.py new file mode 100644 index 0000000..5398839 --- /dev/null +++ b/backend/tests/test_selected_stock_lot_production_issue.py @@ -0,0 +1,1014 @@ +from __future__ import annotations + +import unittest +from datetime import datetime +from decimal import Decimal +from types import SimpleNamespace +from unittest.mock import patch + +from fastapi import HTTPException +from sqlalchemy import BigInteger, create_engine, func, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Bom, BomItem, Item, StockBalance, Warehouse # noqa: E402 +from app.models.miniapp import MiniAppPersonnel, MiniAppProductionReport, MiniAppProductionReportItem # noqa: E402 +from app.models.operations import CompletionReceiptItem, InventoryTxn, Process, ProcessRoute, ProcessRouteOperation, ProductionBatchLedger, ProductionBatchLedgerTxn, StockLot, WorkCenter, WorkOrder, WorkOrderMaterial, WorkOrderMaterialIssue, WorkOrderOperation # noqa: E402 +from app.models.org import Department # noqa: E402 +from app.api.routes import production as production_routes # noqa: E402 +from app.api.routes.production import _find_work_order_for_miniapp_item, create_completion_receipt, create_work_order # noqa: E402 +from app.schemas.operations import CompletionReceiptCreate, CompletionReceiptItemCreate, SelectedStockLotIssueCreate, WorkOrderCreate # noqa: E402 +from app.services.operations import issue_selected_stock_lots # noqa: E402 +from app.services.production_batch_ledger import lock_production_batch_ledger # noqa: E402 + + +class SelectedStockLotProductionIssueTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + raw_connection = engine.raw_connection() + raw_connection.create_function("greatest", -1, lambda *values: max(value for value in values if value is not None)) + raw_connection.close() + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self.material = Item(id=1, item_code="RM-SEL-001", item_name="选择批次原料", item_type="RAW_MATERIAL", unit_weight_kg=Decimal("1"), status="ACTIVE") + self.product = Item(id=2, item_code="FG-SEL-001", item_name="选择批次成品", item_type="FINISHED_GOOD", unit_weight_kg=Decimal("2"), status="ACTIVE") + self.raw_wh = Warehouse(id=1, warehouse_code="WH-RAW", warehouse_name="原材料库", warehouse_type="RAW", status="ACTIVE") + self.finished_wh = Warehouse(id=2, warehouse_code="WH-FG", warehouse_name="成品库", warehouse_type="FINISHED", status="ACTIVE") + self.db.add_all([self.material, self.product, self.raw_wh, self.finished_wh]) + self.db.flush() + self.bom = Bom(id=1, bom_code="BOM-SEL-001", product_item_id=self.product.id, version_no="V1.0", is_default=1, status="ACTIVE") + self.bom_item = BomItem(id=1, bom_id=1, seq_no=1, material_item_id=self.material.id, usage_type="WEIGHT", gross_weight_per_piece_kg=Decimal("1.5"), loss_rate=Decimal("0")) + self.department = Department(id=1, dept_code="D-PROD", dept_name="生产部", dept_type="PRODUCTION", status="ACTIVE") + self.process = Process(id=1, process_code="P-001", process_name="冲压", process_type="INTERNAL", status="ACTIVE") + self.work_center = WorkCenter(id=1, center_code="WC-001", center_name="冲压", dept_id=1, center_type="INTERNAL", capacity_hours_per_day=Decimal("8"), status="ACTIVE") + self.route = ProcessRoute(id=1, route_code="RT-SEL-001", route_name="选择批次工艺", product_item_id=self.product.id, version_no="V1.0", is_default=1, status="ACTIVE") + self.route_op = ProcessRouteOperation(id=1, route_id=1, seq_no=1, process_id=1, operation_name="1序", work_center_id=1, std_cycle_seconds=Decimal("10"), status="ACTIVE") + self.db.add_all([self.bom, self.bom_item, self.department, self.process, self.work_center, self.route, self.route_op]) + self.lot1 = StockLot( + id=1, + lot_no="YL0001", + lot_role="INBOUND_RAW", + item_id=self.material.id, + warehouse_id=self.raw_wh.id, + source_doc_type="PURCHASE_RECEIPT", + source_doc_id=1, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("100"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("100"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("4"), + quality_status="PASS", + status="AVAILABLE", + ) + self.lot2 = StockLot( + id=2, + lot_no="YL0002", + lot_role="INBOUND_RAW", + item_id=self.material.id, + warehouse_id=self.raw_wh.id, + source_doc_type="PURCHASE_RECEIPT", + source_doc_id=2, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("50"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("50"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("5"), + quality_status="PASS", + status="AVAILABLE", + ) + self.balance = StockBalance( + id=1, + item_id=self.material.id, + warehouse_id=self.raw_wh.id, + location_id=None, + qty_on_hand=Decimal("0"), + weight_on_hand_kg=Decimal("150"), + qty_available=Decimal("0"), + weight_available_kg=Decimal("150"), + qty_allocated=Decimal("0"), + weight_allocated_kg=Decimal("0"), + avg_unit_cost=Decimal("4.3333"), + ) + self.db.add_all([self.lot1, self.lot2, self.balance]) + self.db.commit() + + def tearDown(self) -> None: + self.db.close() + + def _add_issued_work_order( + self, + *, + work_order_id: int, + work_order_no: str, + product: Item, + issue_id: int, + material_row_id: int, + ) -> WorkOrder: + work_order = WorkOrder( + id=work_order_id, + work_order_no=work_order_no, + work_order_type="NORMAL", + product_item_id=product.id, + route_id=self.route.id, + bom_id=self.bom.id, + planned_qty=Decimal("10"), + released_qty=Decimal("10"), + finished_qty=Decimal("0"), + scrap_qty=Decimal("0"), + status="RELEASED", + ) + material_row = WorkOrderMaterial( + id=material_row_id, + work_order_id=work_order_id, + bom_item_id=self.bom_item.id, + material_item_id=self.material.id, + required_qty=Decimal("0"), + required_weight_kg=Decimal("10"), + issued_qty=Decimal("0"), + issued_weight_kg=Decimal("10"), + returned_qty=Decimal("0"), + returned_weight_kg=Decimal("0"), + status="ISSUED", + ) + issue = WorkOrderMaterialIssue( + id=issue_id, + work_order_material_id=material_row_id, + work_order_id=work_order_id, + material_item_id=self.material.id, + source_lot_id=self.lot1.id, + material_sub_batch_no=self.lot1.lot_no, + issued_qty=Decimal("0"), + issued_weight_kg=Decimal("10"), + unit_cost=Decimal("4"), + amount=Decimal("40"), + issue_time=datetime.now(), + ) + self.db.add_all([work_order, material_row, issue]) + return work_order + + def _add_work_order_operation( + self, + *, + operation_id: int, + work_order_id: int, + operation_name: str = "1序", + ) -> WorkOrderOperation: + operation = WorkOrderOperation( + id=operation_id, + work_order_id=work_order_id, + route_operation_id=self.route_op.id, + seq_no=1, + operation_name=operation_name, + work_center_id=self.work_center.id, + planned_qty=Decimal("10"), + report_qty=Decimal("0"), + good_qty=Decimal("0"), + scrap_qty=Decimal("0"), + rework_qty=Decimal("0"), + std_labor_cost=Decimal("0"), + std_machine_cost=Decimal("0"), + std_outsource_cost=Decimal("0"), + allowed_scrap_rate=Decimal("0.05"), + is_abnormal=0, + status="READY", + ) + self.db.add(operation) + return operation + + def _add_miniapp_report_item( + self, + *, + item_id: int, + raw_material_batch_no: str, + process_name: str, + good_qty: Decimal = Decimal("1"), + ) -> MiniAppProductionReportItem: + timestamp = datetime(2026, 6, 2, 8, 0, 0) + self.db.add( + MiniAppPersonnel( + phone="13800000000", + name="小程序员工", + created_at=timestamp, + updated_at=timestamp, + ) + ) + self.db.add( + MiniAppProductionReport( + id=9000 + item_id, + attendance_point_name="宁波嘉恒智能科技有限公司", + session_id=8000 + item_id, + employee_phone="13800000000", + report_date=timestamp.date(), + start_at=timestamp, + end_at=timestamp.replace(hour=9), + duration_minutes=Decimal("60"), + break_minutes=Decimal("0"), + effective_minutes=Decimal("60"), + total_good_qty=good_qty, + total_output_qty=good_qty, + actual_beat=Decimal("0"), + standard_beat=Decimal("0"), + expected_workload=Decimal("0"), + pace_rate=Decimal("0"), + workload_rate=Decimal("0"), + status="approved", + submitted_at=timestamp, + updated_at=timestamp, + ) + ) + item = MiniAppProductionReportItem( + id=item_id, + report_id=9000 + item_id, + attendance_point_name="宁波嘉恒智能科技有限公司", + device_no="", + project_no=self.product.item_code, + product_name=self.product.item_name, + material_code=self.material.item_code, + material_name=self.material.item_name, + raw_material_batch_no=raw_material_batch_no, + process_name=process_name, + stamping_method=None, + standard_beat=Decimal("10"), + standard_workload=Decimal("0"), + good_qty=good_qty, + defect_qty=Decimal("0"), + scrap_qty=Decimal("0"), + allocated_minutes=Decimal("60"), + started_at=timestamp, + created_at=timestamp, + ) + self.db.add(item) + return item + + def test_issue_selected_stock_lots_uses_lot_no_as_trace_key(self) -> None: + issues = issue_selected_stock_lots( + self.db, + work_order_id=1, + work_order_material_id=1, + material_item_id=self.material.id, + selected_lots=[ + {"source_lot_id": self.lot1.id, "issued_weight_kg": Decimal("80")}, + {"source_lot_id": self.lot2.id, "issued_weight_kg": Decimal("30")}, + ], + operator_user_id=1, + ) + + self.assertEqual([issue.material_sub_batch_no for issue in issues], ["YL0001", "YL0002"]) + self.assertEqual(float(self.db.get(StockLot, self.lot1.id).remaining_weight_kg), 20) + self.assertEqual(float(self.db.get(StockLot, self.lot2.id).remaining_weight_kg), 20) + persisted = self.db.scalars(select(WorkOrderMaterialIssue).order_by(WorkOrderMaterialIssue.id)).all() + self.assertEqual(len(persisted), 2) + self.assertEqual(persisted[0].source_lot_id, self.lot1.id) + self.assertEqual(persisted[1].source_lot_id, self.lot2.id) + self.assertEqual(float(persisted[0].amount), 320) + self.assertEqual(float(persisted[1].amount), 150) + txns = self.db.scalars(select(InventoryTxn).order_by(InventoryTxn.id)).all() + self.assertEqual([float(txn.amount) for txn in txns], [320, 150]) + + def test_create_work_order_rejects_legacy_auto_issue_without_selected_lots(self) -> None: + with self.assertRaises(HTTPException) as exc: + create_work_order( + WorkOrderCreate( + product_item_id=self.product.id, + planned_qty=10, + issue_weight_kg=Decimal("10"), + auto_issue_materials=True, + selected_stock_lots=[], + ), + context=SimpleNamespace(user=SimpleNamespace(id=1)), + db=self.db, + ) + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("必须选择库存批次号", exc.exception.detail) + + def test_create_work_order_updates_production_ledger_without_creating_normal_work_order(self) -> None: + result = create_work_order( + WorkOrderCreate( + product_item_id=self.product.id, + selected_stock_lots=[ + SelectedStockLotIssueCreate(source_lot_id=self.lot1.id, material_item_id=self.material.id, issued_weight_kg=Decimal("10")), + ], + ), + context=SimpleNamespace(user=SimpleNamespace(id=1)), + db=self.db, + ) + + self.assertEqual(self.db.scalar(select(func.count(WorkOrder.id))), 0) + ledger = self.db.scalar(select(ProductionBatchLedger).where(ProductionBatchLedger.material_lot_id == self.lot1.id)) + self.assertIsNotNone(ledger) + self.assertEqual(result.work_order_id, ledger.id) + self.assertEqual(result.work_order_no, self.lot1.lot_no) + self.assertEqual(result.status, "在生产") + self.assertEqual(float(ledger.total_issued_weight_kg), 10) + self.assertEqual(float(ledger.outside_weight_kg), 10) + self.assertEqual(float(self.db.get(StockLot, self.lot1.id).remaining_weight_kg), 90) + txns = self.db.scalars(select(ProductionBatchLedgerTxn).order_by(ProductionBatchLedgerTxn.id)).all() + self.assertEqual([row.txn_type for row in txns], ["生产出库"]) + inventory_txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == self.lot1.id)) + self.assertIsNotNone(inventory_txn) + self.assertEqual(inventory_txn.source_doc_type, "生产台账") + self.assertEqual(inventory_txn.source_doc_id, ledger.id) + + def test_create_work_order_compat_no_uses_material_lot_no(self) -> None: + class FrozenDateTime(datetime): + @classmethod + def now(cls, tz=None): # noqa: ANN001 + _ = tz + return cls(2026, 6, 7, 14, 30, 15, 143000) + + with patch.object(production_routes, "datetime", FrozenDateTime): + result = create_work_order( + WorkOrderCreate( + product_item_id=self.product.id, + selected_stock_lots=[ + SelectedStockLotIssueCreate(source_lot_id=self.lot1.id, material_item_id=self.material.id, issued_weight_kg=Decimal("10")), + ], + ), + context=SimpleNamespace(user=SimpleNamespace(id=1)), + db=self.db, + ) + + self.assertEqual(result.work_order_no, self.lot1.lot_no) + self.assertEqual(self.db.scalar(select(func.count(WorkOrder.id))), 0) + + def test_create_work_order_accumulates_same_lot_and_product_into_production_ledger(self) -> None: + class FrozenDateTime(datetime): + @classmethod + def now(cls, tz=None): # noqa: ANN001 + _ = tz + return cls(2026, 6, 7, 14, 30, 15, 143000) + + with patch.object(production_routes, "datetime", FrozenDateTime): + first = create_work_order( + WorkOrderCreate( + product_item_id=self.product.id, + selected_stock_lots=[ + SelectedStockLotIssueCreate(source_lot_id=self.lot1.id, material_item_id=self.material.id, issued_weight_kg=Decimal("10")), + ], + ), + context=SimpleNamespace(user=SimpleNamespace(id=1)), + db=self.db, + ) + second = create_work_order( + WorkOrderCreate( + product_item_id=self.product.id, + selected_stock_lots=[ + SelectedStockLotIssueCreate(source_lot_id=self.lot1.id, material_item_id=self.material.id, issued_weight_kg=Decimal("8")), + ], + ), + context=SimpleNamespace(user=SimpleNamespace(id=1)), + db=self.db, + ) + + self.assertEqual(first.work_order_id, second.work_order_id) + self.assertEqual(first.work_order_no, self.lot1.lot_no) + self.assertEqual(second.work_order_no, first.work_order_no) + self.assertEqual(self.db.scalar(select(func.count(WorkOrder.id))), 0) + ledger = self.db.get(ProductionBatchLedger, first.work_order_id) + self.assertIsNotNone(ledger) + self.assertEqual(float(ledger.total_issued_weight_kg), 18) + self.assertEqual(float(ledger.outside_weight_kg), 18) + issues = self.db.scalars(select(ProductionBatchLedgerTxn).where(ProductionBatchLedgerTxn.production_ledger_id == first.work_order_id).order_by(ProductionBatchLedgerTxn.id)).all() + self.assertEqual([float(issue.weight_delta_kg) for issue in issues if issue.txn_type == "生产出库"], [10, 8]) + + def test_create_work_order_reopens_locked_production_ledger_for_same_lot_and_product(self) -> None: + class FrozenDateTime(datetime): + @classmethod + def now(cls, tz=None): # noqa: ANN001 + _ = tz + return cls(2026, 6, 7, 14, 30, 15, 143000) + + with patch.object(production_routes, "datetime", FrozenDateTime): + result = create_work_order( + WorkOrderCreate( + product_item_id=self.product.id, + selected_stock_lots=[ + SelectedStockLotIssueCreate(source_lot_id=self.lot1.id, material_item_id=self.material.id, issued_weight_kg=Decimal("10")), + ], + ), + context=SimpleNamespace(user=SimpleNamespace(id=1)), + db=self.db, + ) + locked = lock_production_batch_ledger( + self.db, + production_ledger_id=result.work_order_id, + operator_user_id=1, + biz_time=FrozenDateTime(2026, 6, 7, 18, 0), + remark="测试锁单", + ) + self.assertEqual(locked.status, "锁单") + reopened = create_work_order( + WorkOrderCreate( + product_item_id=self.product.id, + selected_stock_lots=[ + SelectedStockLotIssueCreate(source_lot_id=self.lot1.id, material_item_id=self.material.id, issued_weight_kg=Decimal("8")), + ], + ), + context=SimpleNamespace(user=SimpleNamespace(id=1)), + db=self.db, + ) + + self.assertEqual(reopened.work_order_id, result.work_order_id) + ledger = self.db.get(ProductionBatchLedger, result.work_order_id) + self.assertEqual(ledger.status, "在生产") + self.assertEqual(float(ledger.total_issued_weight_kg), 18) + self.assertEqual(float(ledger.outside_weight_kg), 8) + + def test_create_work_order_rejects_multiple_selected_stock_lots(self) -> None: + with self.assertRaises(HTTPException) as exc: + create_work_order( + WorkOrderCreate( + product_item_id=self.product.id, + selected_stock_lots=[ + SelectedStockLotIssueCreate(source_lot_id=self.lot1.id, material_item_id=self.material.id, issued_weight_kg=Decimal("10")), + SelectedStockLotIssueCreate(source_lot_id=self.lot2.id, material_item_id=self.material.id, issued_weight_kg=Decimal("8")), + ], + ), + context=SimpleNamespace(user=SimpleNamespace(id=1)), + db=self.db, + ) + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("一次生产出库只能选择一个材料库存批次号", exc.exception.detail) + + def test_create_work_order_issues_only_selected_material_and_leaves_other_bom_rows_unissued(self) -> None: + material2 = Item(id=5, item_code="RM-SEL-002", item_name="第二种原料", item_type="RAW_MATERIAL", unit_weight_kg=Decimal("1"), status="ACTIVE") + bom_item2 = BomItem(id=2, bom_id=self.bom.id, seq_no=2, material_item_id=material2.id, usage_type="WEIGHT", gross_weight_per_piece_kg=Decimal("0.5"), loss_rate=Decimal("0")) + self.db.add_all([material2, bom_item2]) + self.db.commit() + + result = create_work_order( + WorkOrderCreate( + product_item_id=self.product.id, + selected_stock_lots=[ + SelectedStockLotIssueCreate(source_lot_id=self.lot1.id, material_item_id=self.material.id, issued_weight_kg=Decimal("15")), + ], + ), + context=SimpleNamespace(user=SimpleNamespace(id=1)), + db=self.db, + ) + + self.assertEqual(self.db.scalar(select(func.count(WorkOrderMaterial.id))), 0) + self.assertEqual(self.db.scalar(select(func.count(WorkOrderMaterialIssue.id))), 0) + ledger = self.db.get(ProductionBatchLedger, result.work_order_id) + self.assertIsNotNone(ledger) + self.assertEqual(ledger.material_item_id, self.material.id) + self.assertEqual(ledger.material_lot_no, self.lot1.lot_no) + self.assertEqual(float(ledger.total_issued_weight_kg), 15) + + def test_create_work_order_issues_only_selected_non_first_bom_material(self) -> None: + material2 = Item(id=5, item_code="RM-SEL-002", item_name="第二种原料", item_type="RAW_MATERIAL", unit_weight_kg=Decimal("1"), status="ACTIVE") + bom_item2 = BomItem(id=2, bom_id=self.bom.id, seq_no=2, material_item_id=material2.id, usage_type="WEIGHT", gross_weight_per_piece_kg=Decimal("0.5"), loss_rate=Decimal("0")) + lot3 = StockLot( + id=3, + lot_no="YL0003", + lot_role="INBOUND_RAW", + item_id=material2.id, + warehouse_id=self.raw_wh.id, + source_doc_type="PURCHASE_RECEIPT", + source_doc_id=3, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("60"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("60"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("8"), + quality_status="PASS", + status="AVAILABLE", + ) + balance2 = StockBalance( + id=2, + item_id=material2.id, + warehouse_id=self.raw_wh.id, + location_id=None, + qty_on_hand=Decimal("0"), + weight_on_hand_kg=Decimal("60"), + qty_available=Decimal("0"), + weight_available_kg=Decimal("60"), + qty_allocated=Decimal("0"), + weight_allocated_kg=Decimal("0"), + avg_unit_cost=Decimal("8"), + ) + self.db.add_all([material2, bom_item2, lot3, balance2]) + self.db.commit() + + result = create_work_order( + WorkOrderCreate( + product_item_id=self.product.id, + selected_stock_lots=[ + SelectedStockLotIssueCreate(source_lot_id=lot3.id, material_item_id=material2.id, issued_weight_kg=Decimal("12")), + ], + ), + context=SimpleNamespace(user=SimpleNamespace(id=1)), + db=self.db, + ) + + self.assertEqual(self.db.scalar(select(func.count(WorkOrderMaterial.id))), 0) + self.assertEqual(self.db.scalar(select(func.count(WorkOrderMaterialIssue.id))), 0) + ledger = self.db.get(ProductionBatchLedger, result.work_order_id) + self.assertIsNotNone(ledger) + self.assertEqual(ledger.material_item_id, material2.id) + self.assertEqual(ledger.material_lot_no, lot3.lot_no) + self.assertEqual(float(ledger.total_issued_weight_kg), 12) + + def test_merge_non_first_bom_material_recalculates_reference_qty_and_requirements(self) -> None: + material2 = Item(id=5, item_code="RM-SEL-002", item_name="第二种原料", item_type="RAW_MATERIAL", unit_weight_kg=Decimal("1"), status="ACTIVE") + bom_item2 = BomItem(id=2, bom_id=self.bom.id, seq_no=2, material_item_id=material2.id, usage_type="WEIGHT", gross_weight_per_piece_kg=Decimal("0.5"), loss_rate=Decimal("0")) + lot3 = StockLot( + id=3, + lot_no="YL0003", + lot_role="INBOUND_RAW", + item_id=material2.id, + warehouse_id=self.raw_wh.id, + source_doc_type="PURCHASE_RECEIPT", + source_doc_id=3, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("60"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("60"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("8"), + quality_status="PASS", + status="AVAILABLE", + ) + balance2 = StockBalance( + id=2, + item_id=material2.id, + warehouse_id=self.raw_wh.id, + location_id=None, + qty_on_hand=Decimal("0"), + weight_on_hand_kg=Decimal("60"), + qty_available=Decimal("0"), + weight_available_kg=Decimal("60"), + qty_allocated=Decimal("0"), + weight_allocated_kg=Decimal("0"), + avg_unit_cost=Decimal("8"), + ) + self.db.add_all([material2, bom_item2, lot3, balance2]) + self.db.commit() + + class FrozenDateTime(datetime): + @classmethod + def now(cls, tz=None): # noqa: ANN001 + _ = tz + return cls(2026, 6, 7, 14, 30, 15, 143000) + + with patch.object(production_routes, "datetime", FrozenDateTime): + first = create_work_order( + WorkOrderCreate( + product_item_id=self.product.id, + selected_stock_lots=[ + SelectedStockLotIssueCreate(source_lot_id=lot3.id, material_item_id=material2.id, issued_weight_kg=Decimal("12")), + ], + ), + context=SimpleNamespace(user=SimpleNamespace(id=1)), + db=self.db, + ) + second = create_work_order( + WorkOrderCreate( + product_item_id=self.product.id, + selected_stock_lots=[ + SelectedStockLotIssueCreate(source_lot_id=lot3.id, material_item_id=material2.id, issued_weight_kg=Decimal("6")), + ], + ), + context=SimpleNamespace(user=SimpleNamespace(id=1)), + db=self.db, + ) + + self.assertEqual(second.work_order_id, first.work_order_id) + self.assertEqual(self.db.scalar(select(func.count(WorkOrder.id))), 0) + ledger = self.db.get(ProductionBatchLedger, first.work_order_id) + self.assertIsNotNone(ledger) + self.assertEqual(ledger.material_item_id, material2.id) + self.assertEqual(float(ledger.total_issued_weight_kg), 18) + self.assertEqual(float(ledger.outside_weight_kg), 18) + + def test_completion_receipt_allows_partial_reported_finished_qty(self) -> None: + work_order = self._add_issued_work_order( + work_order_id=1, + work_order_no="历史工单-001", + product=self.product, + issue_id=10, + material_row_id=10, + ) + self.db.flush() + material_row = self.db.get(WorkOrderMaterial, 10) + material_row.issued_weight_kg = Decimal("90") + material_row.required_weight_kg = Decimal("90") + issue = self.db.get(WorkOrderMaterialIssue, 10) + issue.issued_weight_kg = Decimal("90") + issue.amount = Decimal("360") + self.db.add( + InventoryTxn( + id=100, + txn_no="TXN-LEGACY-001", + txn_type="MATERIAL_ISSUE", + item_id=self.material.id, + warehouse_id=self.raw_wh.id, + lot_id=self.lot1.id, + qty_change=Decimal("0"), + weight_change_kg=Decimal("-90"), + unit_cost=Decimal("4"), + amount=Decimal("360"), + source_doc_type="WORK_ORDER", + source_doc_id=work_order.id, + biz_time=datetime(2026, 6, 8, 9, 0), + operator_user_id=1, + ) + ) + operation = self._add_work_order_operation(operation_id=1001, work_order_id=work_order.id) + operation.report_qty = Decimal("40") + operation.good_qty = Decimal("38") + operation.scrap_qty = Decimal("2") + operation.std_labor_cost = Decimal("2") + operation.std_machine_cost = Decimal("3") + operation.status = "IN_PROGRESS" + self.db.add(operation) + self.db.commit() + + receipt = create_completion_receipt( + CompletionReceiptCreate( + work_order_id=work_order.id, + warehouse_id=self.finished_wh.id, + items=[ + CompletionReceiptItemCreate( + product_item_id=self.product.id, + receipt_qty=38, + ) + ], + ), + context=SimpleNamespace(user=SimpleNamespace(id=1)), + db=self.db, + ) + + self.assertEqual(receipt.work_order_id, work_order.id) + finished_lot = self.db.scalar(select(StockLot).where(StockLot.warehouse_id == self.finished_wh.id)) + self.assertIsNotNone(finished_lot) + self.assertEqual(float(finished_lot.remaining_qty), 38) + self.assertEqual(float(finished_lot.unit_cost), 6) + receipt_item = self.db.scalar(select(CompletionReceiptItem).where(CompletionReceiptItem.completion_receipt_id == receipt.completion_receipt_id)) + self.assertIsNotNone(receipt_item) + self.assertEqual(float(receipt_item.unit_cost), 6) + finished_txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "FG_IN")) + self.assertIsNotNone(finished_txn) + self.assertEqual(float(finished_txn.unit_cost), 6) + self.assertEqual(float(finished_txn.amount), 228) + + def test_issue_selected_stock_lots_rejects_depleted_or_overdrawn_lots(self) -> None: + self.lot2.remaining_weight_kg = Decimal("0") + self.lot2.status = "DEPLETED" + self.db.commit() + + with self.assertRaises(HTTPException) as exc: + issue_selected_stock_lots( + self.db, + work_order_id=1, + work_order_material_id=1, + material_item_id=self.material.id, + selected_lots=[{"source_lot_id": self.lot2.id, "issued_weight_kg": Decimal("1")}], + operator_user_id=1, + ) + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("不可用", exc.exception.detail) + + def test_issue_selected_stock_lots_rejects_duplicate_lot_selection(self) -> None: + with self.assertRaises(HTTPException) as exc: + issue_selected_stock_lots( + self.db, + work_order_id=1, + work_order_material_id=1, + material_item_id=self.material.id, + selected_lots=[ + {"source_lot_id": self.lot1.id, "issued_weight_kg": Decimal("10")}, + {"source_lot_id": self.lot1.id, "issued_weight_kg": Decimal("5")}, + ], + operator_user_id=1, + ) + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("库存批次重复选择", exc.exception.detail) + + self.lot1.remaining_weight_kg = Decimal("20") + self.lot1.status = "LOCKED" + self.db.commit() + + with self.assertRaises(HTTPException) as exc: + issue_selected_stock_lots( + self.db, + work_order_id=1, + work_order_material_id=1, + material_item_id=self.material.id, + selected_lots=[{"source_lot_id": self.lot1.id, "issued_weight_kg": Decimal("1")}], + operator_user_id=1, + ) + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("不可用", exc.exception.detail) + + def test_miniapp_material_lot_no_resolves_unsettled_work_order(self) -> None: + lot_no = "YL0001" + work_order = self._add_issued_work_order( + work_order_id=1, + work_order_no=f"20260607-{lot_no}-{self.product.item_name}", + product=self.product, + issue_id=10, + material_row_id=10, + ) + self.db.commit() + + matched = _find_work_order_for_miniapp_item( + self.db, + { + "raw_material_batch_no": lot_no, + "product_name": self.product.item_name, + "project_no": self.product.item_code, + }, + ) + + self.assertEqual(matched.id, work_order.id) + + def test_miniapp_material_lot_no_ignores_settled_work_order(self) -> None: + lot_no = "YL0001" + self._add_issued_work_order( + work_order_id=1, + work_order_no=lot_no, + product=self.product, + issue_id=10, + material_row_id=10, + ).status = "SETTLED" + self.db.commit() + + matched = _find_work_order_for_miniapp_item( + self.db, + { + "raw_material_batch_no": lot_no, + "product_name": self.product.item_name, + "project_no": self.product.item_code, + }, + ) + + self.assertIsNone(matched) + + def test_miniapp_material_lot_no_ignores_canceled_work_order(self) -> None: + lot_no = "YL0001" + self._add_issued_work_order( + work_order_id=1, + work_order_no=f"20260607-{lot_no}-{self.product.item_name}", + product=self.product, + issue_id=10, + material_row_id=10, + ).status = "CANCELED" + self.db.commit() + + matched = _find_work_order_for_miniapp_item( + self.db, + { + "raw_material_batch_no": lot_no, + "product_name": self.product.item_name, + "project_no": self.product.item_code, + }, + ) + + self.assertIsNone(matched) + + def test_miniapp_lot_resolver_does_not_fallback_to_work_order_no_when_lot_is_ambiguous(self) -> None: + other_product = Item( + id=3, + item_code="FG-SEL-002", + item_name="另一个选择批次成品", + item_type="FINISHED_GOOD", + unit_weight_kg=Decimal("2"), + status="ACTIVE", + ) + direct_collision_product = Item( + id=4, + item_code="FG-SEL-003", + item_name="碰撞工单成品", + item_type="FINISHED_GOOD", + unit_weight_kg=Decimal("2"), + status="ACTIVE", + ) + self.db.add_all([other_product, direct_collision_product]) + self.db.flush() + self._add_issued_work_order( + work_order_id=1, + work_order_no="WO-LOT-AMB-001", + product=self.product, + issue_id=10, + material_row_id=10, + ) + other_work_order = self._add_issued_work_order( + work_order_id=2, + work_order_no="WO-LOT-AMB-002", + product=other_product, + issue_id=11, + material_row_id=11, + ) + self.db.add( + WorkOrder( + id=3, + work_order_no=self.lot1.lot_no, + work_order_type="NORMAL", + product_item_id=direct_collision_product.id, + route_id=self.route.id, + bom_id=self.bom.id, + planned_qty=Decimal("10"), + released_qty=Decimal("10"), + finished_qty=Decimal("0"), + scrap_qty=Decimal("0"), + status="RELEASED", + ) + ) + self.db.commit() + + ambiguous_row = { + "raw_material_batch_no": self.lot1.lot_no, + "product_name": "不存在的成品", + "project_no": "FG-NOT-MATCHED", + } + self.assertIsNone(_find_work_order_for_miniapp_item(self.db, ambiguous_row)) + + cache = {"work_order": {}} + matched_by_name = _find_work_order_for_miniapp_item( + self.db, + { + "raw_material_batch_no": self.lot1.lot_no, + "product_name": other_product.item_name, + "project_no": "", + }, + cache, + ) + matched_by_code = _find_work_order_for_miniapp_item( + self.db, + { + "raw_material_batch_no": self.lot1.lot_no, + "product_name": "", + "project_no": other_product.item_code, + }, + cache, + ) + self.assertEqual(matched_by_name.id, other_work_order.id) + self.assertEqual(matched_by_code.id, other_work_order.id) + + def test_miniapp_jh_short_code_resolves_yearly_work_order_and_distinguishes_same_lot(self) -> None: + first_work_order = self._add_issued_work_order( + work_order_id=1, + work_order_no=f"20260001-{self.product.item_name}", + product=self.product, + issue_id=10, + material_row_id=10, + ) + second_work_order = self._add_issued_work_order( + work_order_id=2, + work_order_no=f"20260002-{self.product.item_name}", + product=self.product, + issue_id=11, + material_row_id=11, + ) + self.db.commit() + + class FrozenDateTime(datetime): + @classmethod + def now(cls, tz=None): # noqa: ANN001 + _ = tz + return cls(2026, 6, 2, 14, 30, 15) + + with patch.object(production_routes, "datetime", FrozenDateTime): + matched_first = _find_work_order_for_miniapp_item( + self.db, + { + "raw_material_batch_no": "JH0001", + "product_name": self.product.item_name, + "project_no": self.product.item_code, + }, + ) + matched_second = _find_work_order_for_miniapp_item( + self.db, + { + "raw_material_batch_no": "JH0002", + "product_name": self.product.item_name, + "project_no": self.product.item_code, + }, + ) + + self.assertEqual(matched_first.id, first_work_order.id) + self.assertEqual(matched_second.id, second_work_order.id) + + def test_miniapp_jh_short_code_does_not_resolve_settled_work_order(self) -> None: + self._add_issued_work_order( + work_order_id=1, + work_order_no=f"20260001-{self.product.item_name}", + product=self.product, + issue_id=10, + material_row_id=10, + ).status = "SETTLED" + self.db.commit() + + class FrozenDateTime(datetime): + @classmethod + def now(cls, tz=None): # noqa: ANN001 + _ = tz + return cls(2026, 6, 2, 14, 30, 15) + + with patch.object(production_routes, "datetime", FrozenDateTime): + matched = _find_work_order_for_miniapp_item( + self.db, + { + "raw_material_batch_no": "JH0001", + "product_name": self.product.item_name, + "project_no": self.product.item_code, + }, + ) + + self.assertIsNone(matched) + + def test_miniapp_jh_short_code_does_not_resolve_canceled_work_order(self) -> None: + self._add_issued_work_order( + work_order_id=1, + work_order_no=f"20260001-{self.product.item_name}", + product=self.product, + issue_id=10, + material_row_id=10, + ).status = "CANCELLED" + self.db.commit() + + class FrozenDateTime(datetime): + @classmethod + def now(cls, tz=None): # noqa: ANN001 + _ = tz + return cls(2026, 6, 2, 14, 30, 15) + + with patch.object(production_routes, "datetime", FrozenDateTime): + matched = _find_work_order_for_miniapp_item( + self.db, + { + "raw_material_batch_no": "JH0001", + "product_name": self.product.item_name, + "project_no": self.product.item_code, + }, + ) + + self.assertIsNone(matched) + + def test_miniapp_jh_short_code_sync_requires_exact_process_match(self) -> None: + work_order = self._add_issued_work_order( + work_order_id=1, + work_order_no=f"20260001-{self.product.item_name}", + product=self.product, + issue_id=10, + material_row_id=10, + ) + self._add_work_order_operation(operation_id=101, work_order_id=work_order.id, operation_name="1序") + item = self._add_miniapp_report_item(item_id=7001, raw_material_batch_no="JH0001", process_name="不存在的工序") + self.db.commit() + + class FrozenDateTime(datetime): + @classmethod + def now(cls, tz=None): # noqa: ANN001 + _ = tz + return cls(2026, 6, 2, 14, 30, 15) + + cache = production_routes._new_miniapp_resolve_cache() + with patch.object(production_routes, "datetime", FrozenDateTime): + matched_work_order = _find_work_order_for_miniapp_item( + self.db, + { + "raw_material_batch_no": "JH0001", + "product_name": self.product.item_name, + "project_no": self.product.item_code, + }, + cache, + ) + synced_report = production_routes._sync_miniapp_item_to_erp( + self.db, + item.id, + row=None, + cache=cache, + required=False, + ) + + self.assertEqual(matched_work_order.id, work_order.id) + self.assertIsNone(synced_report) diff --git a/backend/tests/test_semi_finished_operation_inventory.py b/backend/tests/test_semi_finished_operation_inventory.py new file mode 100644 index 0000000..9b6ed06 --- /dev/null +++ b/backend/tests/test_semi_finished_operation_inventory.py @@ -0,0 +1,857 @@ +from __future__ import annotations + +import unittest +from datetime import UTC, datetime +from decimal import Decimal + +from fastapi import HTTPException +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +from app.api.routes.inventory import create_warehouse_inbound, create_warehouse_outbound # noqa: E402 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Bom, BomItem, Item, Warehouse # noqa: E402 +from app.models.operations import InventoryTxn, Process, ProcessRoute, ProcessRouteOperation, StockLot, WarehouseLocation, WorkCenter # noqa: E402 +from app.schemas.operations import WarehouseInboundCreate, WarehouseOutboundCreate # noqa: E402 + + +class User: + id = 1 + + +class Context: + user = User() + + +class SemiFinishedOperationInventoryTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + + def tearDown(self) -> None: + self.db.close() + + def _seed_route(self) -> tuple[Item, Warehouse, ProcessRoute, ProcessRouteOperation, ProcessRouteOperation]: + now = datetime.now(UTC) + product = Item( + id=1, + item_code="产品需规00001", + item_name="五金支架", + item_type="FINISHED_GOOD", + unit_weight_kg=Decimal("0.6"), + safety_stock_weight_kg=Decimal("0"), + scrap_sale_price=Decimal("0"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + process = Process( + id=1, + process_code="GX01", + process_name="冲压", + process_type="INHOUSE", + is_report_required=1, + is_quality_gate=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + center = WorkCenter( + id=1, + center_code="WC01", + center_name="一号工位", + dept_id=1, + center_type="INHOUSE", + capacity_hours_per_day=Decimal("8"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + warehouse = Warehouse( + id=1, + warehouse_code="WH-SEMI-T", + warehouse_name="半成品测试仓", + warehouse_type="SEMI", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add_all([product, process, center, warehouse]) + self.db.flush() + location = WarehouseLocation( + id=1, + warehouse_id=warehouse.id, + location_code="SEMI-A", + location_name="半成品默认库位", + is_default=1, + is_locked=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + route = ProcessRoute( + id=1, + route_code="R-001", + route_name="五金支架工艺", + product_item_id=product.id, + version_no="V1.0", + is_default=1, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add_all([location, route]) + self.db.flush() + op1 = ProcessRouteOperation( + id=1, + route_id=route.id, + seq_no=1, + process_id=process.id, + work_center_id=center.id, + operation_name="冲压", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + op2 = ProcessRouteOperation( + id=2, + route_id=route.id, + seq_no=2, + process_id=process.id, + work_center_id=center.id, + operation_name="折弯", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add_all([op1, op2]) + self.db.commit() + return product, warehouse, route, op1, op2 + + def _seed_raw_source_lots(self, product: Item) -> tuple[StockLot, StockLot]: + now = datetime.now(UTC) + material = Item( + id=2, + item_code="原材料00001", + item_name="冷轧钢板", + item_type="RAW_MATERIAL", + unit_weight_kg=Decimal("0"), + safety_stock_weight_kg=Decimal("0"), + scrap_sale_price=Decimal("0"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + raw_warehouse = Warehouse( + id=2, + warehouse_code="WH-RAW-T", + warehouse_name="原材料测试仓", + warehouse_type="RAW", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add_all([material, raw_warehouse]) + self.db.flush() + raw_location = WarehouseLocation( + id=2, + warehouse_id=raw_warehouse.id, + location_code="RAW-A", + location_name="原材料默认库位", + is_default=1, + is_locked=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + bom = Bom( + id=1, + bom_code="BOM-SEMI-001", + product_item_id=product.id, + version_no="V1.0", + is_default=1, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + bom_item = BomItem( + id=1, + bom_id=bom.id, + seq_no=1, + material_item_id=material.id, + usage_type="WEIGHT", + gross_weight_per_piece_kg=Decimal("0.6"), + loss_rate=Decimal("0"), + created_at=now, + updated_at=now, + ) + lot_a = StockLot( + id=10, + lot_no="JH_冷轧钢板00001_0001", + item_id=material.id, + warehouse_id=raw_warehouse.id, + location_id=raw_location.id, + source_doc_type="PURCHASE_RECEIPT", + source_doc_id=1, + source_line_id=1, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("100"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("0"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("4.5"), + quality_status="PASS", + status="DEPLETED", + created_at=now, + updated_at=now, + ) + lot_b = StockLot( + id=11, + lot_no="JH_冷轧钢板00001_0002", + item_id=material.id, + warehouse_id=raw_warehouse.id, + location_id=raw_location.id, + source_doc_type="PURCHASE_RECEIPT", + source_doc_id=2, + source_line_id=2, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("200"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("200"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("4.8"), + quality_status="PASS", + status="AVAILABLE", + created_at=now, + updated_at=now, + ) + self.db.add_all([raw_location, bom, bom_item, lot_a, lot_b]) + self.db.commit() + return lot_a, lot_b + + def _seed_finished_warehouse(self) -> Warehouse: + now = datetime.now(UTC) + warehouse = Warehouse( + id=3, + warehouse_code="WH-FG-T", + warehouse_name="成品测试仓", + warehouse_type="FINISHED", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + location = WarehouseLocation( + id=3, + warehouse_id=warehouse.id, + location_code="FG-A", + location_name="成品默认库位", + is_default=1, + is_locked=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add_all([warehouse, location]) + self.db.commit() + return warehouse + + def test_semi_inbound_records_product_operation_source_line(self) -> None: + product, warehouse, route, op1, _ = self._seed_route() + + row = create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="WIP_IN", + item_id=product.id, + warehouse_id=warehouse.id, + inbound_weight_kg=120, + unit_cost=8, + source_doc_type="PRODUCT_ROUTE_OPERATION", + source_doc_id=route.id, + source_line_id=op1.id, + source_material_summary="产品:五金支架;已完成工序:第1道 冲压", + ), + context=Context(), + db=self.db, + ) + + lot = self.db.get(StockLot, row.lot_id) + self.assertEqual(lot.source_line_id, op1.id) + self.assertEqual(lot.source_doc_id, route.id) + self.assertIn("第1道", lot.source_material_summary) + txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == lot.id)) + self.assertEqual(txn.source_line_id, op1.id) + + def test_semi_wip_inbound_accepts_quantity_when_weight_is_empty(self) -> None: + product, warehouse, route, op1, _ = self._seed_route() + + row = create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="WIP_IN", + item_id=product.id, + warehouse_id=warehouse.id, + inbound_weight_kg=0, + inbound_qty=12, + unit_cost=0, + source_doc_type="PRODUCT_ROUTE_OPERATION", + source_doc_id=route.id, + source_line_id=op1.id, + source_material_summary="产品:五金支架;已完成工序:第1道 冲压", + ), + context=Context(), + db=self.db, + ) + + lot = self.db.get(StockLot, row.lot_id) + self.assertEqual(lot.inbound_qty, Decimal("12.000000")) + self.assertEqual(lot.remaining_qty, Decimal("12.000000")) + self.assertEqual(lot.inbound_weight_kg, Decimal("0.000000")) + txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == lot.id)) + self.assertEqual(txn.qty_change, Decimal("12.000000")) + self.assertEqual(txn.weight_change_kg, Decimal("0.000000")) + + def test_semi_wip_inbound_requires_weight_or_quantity(self) -> None: + product, warehouse, route, op1, _ = self._seed_route() + + with self.assertRaises(HTTPException) as caught: + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="WIP_IN", + item_id=product.id, + warehouse_id=warehouse.id, + inbound_weight_kg=0, + inbound_qty=0, + unit_cost=0, + source_doc_type="PRODUCT_ROUTE_OPERATION", + source_doc_id=route.id, + source_line_id=op1.id, + ), + context=Context(), + db=self.db, + ) + + self.assertEqual(caught.exception.status_code, 400) + self.assertIn("至少填写一项", caught.exception.detail) + + def test_semi_wip_inbound_rejects_weight_and_quantity_together(self) -> None: + product, warehouse, route, op1, _ = self._seed_route() + + with self.assertRaises(HTTPException) as caught: + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="WIP_IN", + item_id=product.id, + warehouse_id=warehouse.id, + inbound_weight_kg=60, + inbound_qty=100, + unit_cost=0, + source_doc_type="PRODUCT_ROUTE_OPERATION", + source_doc_id=route.id, + source_line_id=op1.id, + ), + context=Context(), + db=self.db, + ) + + self.assertEqual(caught.exception.status_code, 400) + self.assertIn("只能选择按件或按重一种口径", caught.exception.detail) + + def test_semi_wip_inbound_follows_existing_piece_basis(self) -> None: + product, warehouse, route, op1, _ = self._seed_route() + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="WIP_IN", + item_id=product.id, + warehouse_id=warehouse.id, + inbound_weight_kg=0, + inbound_qty=12, + unit_cost=0, + source_doc_type="PRODUCT_ROUTE_OPERATION", + source_doc_id=route.id, + source_line_id=op1.id, + ), + context=Context(), + db=self.db, + ) + + with self.assertRaises(HTTPException) as caught: + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="WIP_IN", + item_id=product.id, + warehouse_id=warehouse.id, + inbound_weight_kg=30, + inbound_qty=0, + unit_cost=0, + source_doc_type="PRODUCT_ROUTE_OPERATION", + source_doc_id=route.id, + source_line_id=op1.id, + ), + context=Context(), + db=self.db, + ) + + self.assertEqual(caught.exception.status_code, 400) + self.assertIn("已有库存按件管理", caught.exception.detail) + + def test_semi_wip_inbound_follows_existing_weight_basis(self) -> None: + product, warehouse, route, op1, _ = self._seed_route() + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="WIP_IN", + item_id=product.id, + warehouse_id=warehouse.id, + inbound_weight_kg=30, + inbound_qty=0, + unit_cost=0, + source_doc_type="PRODUCT_ROUTE_OPERATION", + source_doc_id=route.id, + source_line_id=op1.id, + ), + context=Context(), + db=self.db, + ) + + with self.assertRaises(HTTPException) as caught: + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="WIP_IN", + item_id=product.id, + warehouse_id=warehouse.id, + inbound_weight_kg=0, + inbound_qty=12, + unit_cost=0, + source_doc_type="PRODUCT_ROUTE_OPERATION", + source_doc_id=route.id, + source_line_id=op1.id, + ), + context=Context(), + db=self.db, + ) + + self.assertEqual(caught.exception.status_code, 400) + self.assertIn("已有库存按重管理", caught.exception.detail) + + def test_semi_wip_inbound_records_selected_raw_source_lots_without_deducting_them(self) -> None: + product, warehouse, route, op1, _ = self._seed_route() + lot_a, lot_b = self._seed_raw_source_lots(product) + + row = create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="WIP_IN", + item_id=product.id, + warehouse_id=warehouse.id, + inbound_weight_kg=60, + inbound_qty=0, + unit_cost=0, + source_doc_type="PRODUCT_ROUTE_OPERATION", + source_doc_id=route.id, + source_line_id=op1.id, + source_lot_ids=[lot_a.id, lot_b.id], + source_material_summary="产品:五金支架;已完成工序:第1道 冲压", + ), + context=Context(), + db=self.db, + ) + + lot = self.db.get(StockLot, row.lot_id) + self.assertEqual(lot.source_material_lot_id, lot_a.id) + self.assertEqual(lot.source_material_sub_batch_no, f"{lot_a.lot_no}、{lot_b.lot_no}") + self.assertIn("来源库存批次号", lot.source_material_summary) + self.assertIn(lot_a.lot_no, lot.source_material_summary) + self.assertIn(lot_b.lot_no, lot.source_material_summary) + self.db.refresh(lot_a) + self.db.refresh(lot_b) + self.assertEqual(lot_a.remaining_weight_kg, Decimal("0.000000")) + self.assertEqual(lot_b.remaining_weight_kg, Decimal("200.000000")) + + def test_semi_outsourcing_inbound_accepts_quantity_when_weight_is_empty(self) -> None: + product, warehouse, route, _, op2 = self._seed_route() + + row = create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="OUTSOURCING_IN", + item_id=product.id, + warehouse_id=warehouse.id, + inbound_weight_kg=0, + inbound_qty=8, + source_doc_type="PRODUCT_ROUTE_OPERATION", + source_doc_id=route.id, + source_line_id=op2.id, + waybill_no="WW-001", + freight_amount=0, + source_material_summary="产品:五金支架;已完成工序:第2道 折弯", + ), + context=Context(), + db=self.db, + ) + + lot = self.db.get(StockLot, row.lot_id) + self.assertEqual(lot.inbound_qty, Decimal("8.000000")) + self.assertEqual(lot.remaining_qty, Decimal("8.000000")) + self.assertEqual(lot.inbound_weight_kg, Decimal("0.000000")) + txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == lot.id)) + self.assertEqual(txn.qty_change, Decimal("8.000000")) + self.assertEqual(txn.weight_change_kg, Decimal("0.000000")) + + def test_semi_outsourcing_inbound_records_selected_raw_source_lots_without_deducting_them(self) -> None: + product, warehouse, route, _, op2 = self._seed_route() + lot_a, lot_b = self._seed_raw_source_lots(product) + + row = create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="OUTSOURCING_IN", + item_id=product.id, + warehouse_id=warehouse.id, + inbound_weight_kg=30, + inbound_qty=0, + source_doc_type="PRODUCT_ROUTE_OPERATION", + source_doc_id=route.id, + source_line_id=op2.id, + source_lot_ids=[lot_a.id, lot_b.id], + waybill_no="WW-002", + freight_amount=12, + provider_name="委外厂甲", + source_material_summary="产品:五金支架;已完成工序:第2道 折弯", + ), + context=Context(), + db=self.db, + ) + + lot = self.db.get(StockLot, row.lot_id) + self.assertEqual(lot.source_material_lot_id, lot_a.id) + self.assertEqual(lot.source_material_sub_batch_no, f"{lot_a.lot_no}、{lot_b.lot_no}") + self.assertIn("来源库存批次号", lot.source_material_summary) + self.assertIn(lot_a.lot_no, lot.source_material_summary) + self.assertIn(lot_b.lot_no, lot.source_material_summary) + self.db.refresh(lot_a) + self.db.refresh(lot_b) + self.assertEqual(lot_a.remaining_weight_kg, Decimal("0.000000")) + self.assertEqual(lot_b.remaining_weight_kg, Decimal("200.000000")) + + def test_finished_outsourcing_inbound_uses_quantity_and_optional_raw_source_lots(self) -> None: + product, _, _, _, _ = self._seed_route() + warehouse = self._seed_finished_warehouse() + _, lot_b = self._seed_raw_source_lots(product) + + row = create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="OUTSOURCING_IN", + item_id=product.id, + warehouse_id=warehouse.id, + inbound_weight_kg=0, + inbound_qty=12, + unit_cost=6.5, + source_lot_ids=[lot_b.id], + waybill_no="WW-FG-001", + freight_amount=15, + provider_name="委外厂甲", + ), + context=Context(), + db=self.db, + ) + + lot = self.db.get(StockLot, row.lot_id) + self.assertEqual(lot.inbound_qty, Decimal("12.000000")) + self.assertEqual(lot.remaining_qty, Decimal("12.000000")) + self.assertEqual(lot.inbound_weight_kg, Decimal("0.000000")) + self.assertEqual(lot.remaining_weight_kg, Decimal("0.000000")) + self.assertEqual(lot.source_material_lot_id, lot_b.id) + self.assertEqual(lot.source_material_sub_batch_no, lot_b.lot_no) + self.assertIn("来源库存批次号", lot.source_material_summary) + txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == lot.id)) + self.assertEqual(txn.qty_change, Decimal("12.000000")) + self.assertEqual(txn.weight_change_kg, Decimal("0.000000")) + self.assertEqual(txn.amount, Decimal("78.00")) + + def test_finished_outsourcing_inbound_allows_no_raw_source_lot(self) -> None: + product, _, _, _, _ = self._seed_route() + warehouse = self._seed_finished_warehouse() + + row = create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="OUTSOURCING_IN", + item_id=product.id, + warehouse_id=warehouse.id, + inbound_weight_kg=0, + inbound_qty=5, + unit_cost=7, + source_lot_ids=[], + waybill_no="WW-FG-002", + freight_amount=0, + provider_name="委外厂自供原料", + ), + context=Context(), + db=self.db, + ) + + lot = self.db.get(StockLot, row.lot_id) + self.assertEqual(lot.inbound_qty, Decimal("5.000000")) + self.assertEqual(lot.inbound_weight_kg, Decimal("0.000000")) + self.assertIsNone(lot.source_material_lot_id) + self.assertIsNone(lot.source_material_sub_batch_no) + self.assertIn("委外厂自供原料", lot.source_material_summary) + + def test_semi_outbound_deducts_only_selected_product_operation(self) -> None: + product, warehouse, route, op1, op2 = self._seed_route() + first = create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="WIP_IN", + item_id=product.id, + warehouse_id=warehouse.id, + inbound_weight_kg=50, + unit_cost=8, + source_doc_type="PRODUCT_ROUTE_OPERATION", + source_doc_id=route.id, + source_line_id=op1.id, + source_material_summary="产品:五金支架;已完成工序:第1道 冲压", + ), + context=Context(), + db=self.db, + ) + second = create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="WIP_IN", + item_id=product.id, + warehouse_id=warehouse.id, + inbound_weight_kg=80, + unit_cost=9, + source_doc_type="PRODUCT_ROUTE_OPERATION", + source_doc_id=route.id, + source_line_id=op2.id, + source_material_summary="产品:五金支架;已完成工序:第2道 折弯", + ), + context=Context(), + db=self.db, + ) + + create_warehouse_outbound( + WarehouseOutboundCreate( + biz_type="WIP_OUT", + item_id=product.id, + warehouse_id=warehouse.id, + outbound_weight_kg=30, + target_doc_type="PRODUCT_ROUTE_OPERATION", + target_doc_id=route.id, + target_doc_line_id=op2.id, + target_material_sub_batch_no="第2道 折弯", + ), + context=Context(), + db=self.db, + ) + + first_lot = self.db.get(StockLot, first.lot_id) + second_lot = self.db.get(StockLot, second.lot_id) + self.assertEqual(first_lot.remaining_weight_kg, Decimal("50.000000")) + self.assertEqual(second_lot.remaining_weight_kg, Decimal("50.000000")) + + def test_semi_wip_outbound_accepts_quantity_when_weight_is_empty(self) -> None: + product, warehouse, route, _, op2 = self._seed_route() + inbound = create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="WIP_IN", + item_id=product.id, + warehouse_id=warehouse.id, + inbound_weight_kg=0, + inbound_qty=20, + unit_cost=8, + source_doc_type="PRODUCT_ROUTE_OPERATION", + source_doc_id=route.id, + source_line_id=op2.id, + source_material_summary="产品:五金支架;已完成工序:第2道 折弯", + ), + context=Context(), + db=self.db, + ) + + create_warehouse_outbound( + WarehouseOutboundCreate( + biz_type="WIP_OUT", + item_id=product.id, + warehouse_id=warehouse.id, + outbound_weight_kg=0, + outbound_qty=5, + target_doc_type="PRODUCT_ROUTE_OPERATION", + target_doc_id=route.id, + target_doc_line_id=op2.id, + ), + context=Context(), + db=self.db, + ) + + lot = self.db.get(StockLot, inbound.lot_id) + self.assertEqual(lot.remaining_qty, Decimal("15.000000")) + self.assertEqual(lot.remaining_weight_kg, Decimal("0.000000")) + txn = self.db.scalar( + select(InventoryTxn) + .where(InventoryTxn.lot_id == lot.id, InventoryTxn.txn_type == "WIP_OUT") + .order_by(InventoryTxn.id.desc()) + ) + self.assertEqual(txn.qty_change, Decimal("-5.000000")) + self.assertEqual(txn.weight_change_kg, Decimal("0.000000")) + self.assertIn(lot.lot_no, txn.remark) + + def test_semi_wip_outbound_requires_weight_or_quantity(self) -> None: + product, warehouse, route, _, op2 = self._seed_route() + + with self.assertRaises(HTTPException) as caught: + create_warehouse_outbound( + WarehouseOutboundCreate( + biz_type="WIP_OUT", + item_id=product.id, + warehouse_id=warehouse.id, + outbound_weight_kg=0, + outbound_qty=0, + target_doc_type="PRODUCT_ROUTE_OPERATION", + target_doc_id=route.id, + target_doc_line_id=op2.id, + ), + context=Context(), + db=self.db, + ) + + self.assertEqual(caught.exception.status_code, 400) + self.assertIn("至少填写一项", caught.exception.detail) + + def test_semi_wip_outbound_follows_existing_piece_basis(self) -> None: + product, warehouse, route, _, op2 = self._seed_route() + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="WIP_IN", + item_id=product.id, + warehouse_id=warehouse.id, + inbound_weight_kg=0, + inbound_qty=20, + unit_cost=0, + source_doc_type="PRODUCT_ROUTE_OPERATION", + source_doc_id=route.id, + source_line_id=op2.id, + ), + context=Context(), + db=self.db, + ) + + with self.assertRaises(HTTPException) as caught: + create_warehouse_outbound( + WarehouseOutboundCreate( + biz_type="WIP_OUT", + item_id=product.id, + warehouse_id=warehouse.id, + outbound_weight_kg=10, + outbound_qty=0, + target_doc_type="PRODUCT_ROUTE_OPERATION", + target_doc_id=route.id, + target_doc_line_id=op2.id, + ), + context=Context(), + db=self.db, + ) + + self.assertEqual(caught.exception.status_code, 400) + self.assertIn("按件管理", caught.exception.detail) + + def test_semi_wip_outbound_follows_existing_weight_basis(self) -> None: + product, warehouse, route, _, op2 = self._seed_route() + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="WIP_IN", + item_id=product.id, + warehouse_id=warehouse.id, + inbound_weight_kg=80, + inbound_qty=0, + unit_cost=0, + source_doc_type="PRODUCT_ROUTE_OPERATION", + source_doc_id=route.id, + source_line_id=op2.id, + ), + context=Context(), + db=self.db, + ) + + with self.assertRaises(HTTPException) as caught: + create_warehouse_outbound( + WarehouseOutboundCreate( + biz_type="WIP_OUT", + item_id=product.id, + warehouse_id=warehouse.id, + outbound_weight_kg=0, + outbound_qty=5, + target_doc_type="PRODUCT_ROUTE_OPERATION", + target_doc_id=route.id, + target_doc_line_id=op2.id, + ), + context=Context(), + db=self.db, + ) + + self.assertEqual(caught.exception.status_code, 400) + self.assertIn("按重管理", caught.exception.detail) + + def test_semi_outsourcing_outbound_accepts_quantity_and_records_outsourcing_party(self) -> None: + product, warehouse, route, _, op2 = self._seed_route() + inbound = create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="OUTSOURCING_IN", + item_id=product.id, + warehouse_id=warehouse.id, + inbound_weight_kg=0, + inbound_qty=10, + source_doc_type="PRODUCT_ROUTE_OPERATION", + source_doc_id=route.id, + source_line_id=op2.id, + waybill_no="WW-IN-001", + freight_amount=0, + source_material_summary="产品:五金支架;已完成工序:第2道 折弯", + ), + context=Context(), + db=self.db, + ) + + create_warehouse_outbound( + WarehouseOutboundCreate( + biz_type="OUTSOURCING_OUT", + item_id=product.id, + warehouse_id=warehouse.id, + outbound_weight_kg=0, + outbound_qty=4, + target_doc_type="PRODUCT_ROUTE_OPERATION", + target_doc_id=route.id, + target_doc_line_id=op2.id, + waybill_no="WW-OUT-001", + freight_amount=15, + outsourcing_party_name="委外厂乙", + ), + context=Context(), + db=self.db, + ) + + lot = self.db.get(StockLot, inbound.lot_id) + self.assertEqual(lot.remaining_qty, Decimal("6.000000")) + self.assertEqual(lot.remaining_weight_kg, Decimal("0.000000")) + txn = self.db.scalar( + select(InventoryTxn) + .where(InventoryTxn.lot_id == lot.id, InventoryTxn.txn_type == "OUTSOURCING_OUT") + .order_by(InventoryTxn.id.desc()) + ) + self.assertEqual(txn.qty_change, Decimal("-4.000000")) + self.assertEqual(txn.weight_change_kg, Decimal("0.000000")) + self.assertIn(lot.lot_no, txn.remark) + self.assertIn("委外方:委外厂乙", txn.remark) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_smart_operation_report_config.py b/backend/tests/test_smart_operation_report_config.py new file mode 100644 index 0000000..bacd771 --- /dev/null +++ b/backend/tests/test_smart_operation_report_config.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import unittest + +from sqlalchemy import BigInteger, create_engine +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.services.system_config import ( # noqa: E402 + get_smart_operation_report_enabled, + normalize_enabled_config_value, + upsert_smart_operation_report_config, +) + + +class SmartOperationReportConfigTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + + def tearDown(self) -> None: + self.db.close() + + def test_defaults_enabled(self) -> None: + self.assertTrue(get_smart_operation_report_enabled(self.db)) + + def test_normalizes_chinese_values(self) -> None: + self.assertEqual(normalize_enabled_config_value("开启"), "开启") + self.assertEqual(normalize_enabled_config_value("关闭"), "关闭") + self.assertEqual(normalize_enabled_config_value(True), "开启") + self.assertEqual(normalize_enabled_config_value(False), "关闭") + + def test_upsert_changes_enabled_state(self) -> None: + upsert_smart_operation_report_config(self.db, enabled=False, updated_by=1, remark="未购买小程序") + self.db.commit() + self.assertFalse(get_smart_operation_report_enabled(self.db)) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_special_warehouse_adjustment.py b/backend/tests/test_special_warehouse_adjustment.py new file mode 100644 index 0000000..331b14d --- /dev/null +++ b/backend/tests/test_special_warehouse_adjustment.py @@ -0,0 +1,619 @@ +from __future__ import annotations + +import unittest +from datetime import UTC, datetime +from decimal import Decimal +from types import SimpleNamespace + +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.document_archive # noqa: E402,F401 +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.document_archive import DocumentArchive # noqa: E402 +from app.models.master_data import Item, Product, StockBalance, Unit, Warehouse # noqa: E402 +from app.models.operations import InventoryTxn, StockLot, WarehouseLocation # noqa: E402 +from app.models.org import Department, Employee, User # noqa: E402 +from app.schemas.operations import ( # noqa: E402 + SpecialWarehouseAdjustmentCreate, + SpecialWarehouseAdjustmentLineCreate, +) +from app.api.routes.inventory import create_special_warehouse_adjustment # noqa: E402 +from app.services.document_archives import ARCHIVE_STATUS_READY, DOCUMENT_TYPE_WAREHOUSE_OPERATION # noqa: E402 +from app.services.operations import get_inventory_txn_ledger_query # noqa: E402 +from app.services.special_inventory_adjustment import create_special_inventory_adjustment # noqa: E402 + + +class SpecialWarehouseAdjustmentTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self._seed_master_data() + + def tearDown(self) -> None: + self.db.close() + + def _seed_master_data(self) -> None: + now = datetime(2026, 6, 6, 9, 0, tzinfo=UTC) + self.department = Department( + id=1, + dept_code="D-SPECIAL", + dept_name="仓库部", + dept_type="WAREHOUSE", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.employee = Employee( + id=1, + employee_code="EMP-SPECIAL-001", + employee_name="特殊调整员", + dept_id=1, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.user = User( + id=1, + username="special_tester", + password_hash="x", + employee_id=1, + dept_id=1, + nickname="特殊调整员", + is_super_admin=1, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.unit = Unit(id=1, unit_code="PCS", unit_name="个", precision_digits=0, created_at=now, updated_at=now) + self.raw_item = Item( + id=1, + item_code="原材料00001", + item_name="304不锈钢卷", + item_type="RAW_MATERIAL", + unit_weight_kg=Decimal("0"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.aux_item = Item( + id=2, + item_code="辅料00001", + item_name="包装薄膜", + item_type="RAW_MATERIAL", + unit_weight_kg=Decimal("0"), + stock_unit_id=1, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.finished_item = Item( + id=3, + item_code="产品需规00001", + item_name="冲压支架", + item_type="FINISHED_GOOD", + unit_weight_kg=Decimal("2.5"), + stock_unit_id=1, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.finished_product = Product( + id=1, + item_id=3, + drawing_no="FG-DWG-001", + product_family="支架", + version_no="V1", + created_at=now, + updated_at=now, + ) + self.raw_warehouse = Warehouse( + id=1, + warehouse_code="WH-RAW", + warehouse_name="原材料库", + warehouse_type="RAW", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.aux_warehouse = Warehouse( + id=2, + warehouse_code="WH-AUX", + warehouse_name="辅料库", + warehouse_type="AUX", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.finished_warehouse = Warehouse( + id=3, + warehouse_code="WH-FG", + warehouse_name="成品库", + warehouse_type="FINISHED", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.raw_location = WarehouseLocation( + id=1, + warehouse_id=1, + location_code="RAW-A", + location_name="原材料默认库位", + is_default=1, + is_locked=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.aux_location = WarehouseLocation( + id=2, + warehouse_id=2, + location_code="AUX-A", + location_name="辅料默认库位", + is_default=1, + is_locked=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.finished_location = WarehouseLocation( + id=3, + warehouse_id=3, + location_code="FG-A", + location_name="成品默认库位", + is_default=1, + is_locked=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.raw_lot = StockLot( + id=1, + lot_no="RAW-LOT-001", + lot_role="INBOUND_RAW", + item_id=1, + warehouse_id=1, + location_id=1, + source_doc_type="OPENING_STOCK", + source_doc_id=1, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("100"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("80"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("10"), + quality_status="PASS", + status="AVAILABLE", + created_at=now, + updated_at=now, + ) + self.aux_lot = StockLot( + id=2, + lot_no="AUX-LOT-001", + lot_role="INVENTORY", + item_id=2, + warehouse_id=2, + location_id=2, + source_doc_type="OPENING_STOCK", + source_doc_id=2, + inbound_qty=Decimal("10"), + inbound_weight_kg=Decimal("0"), + remaining_qty=Decimal("6"), + remaining_weight_kg=Decimal("0"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("2.5"), + quality_status="PASS", + status="AVAILABLE", + created_at=now, + updated_at=now, + ) + self.finished_lot = StockLot( + id=3, + lot_no="FG-LOT-001", + lot_role="INBOUND_FINISHED", + item_id=3, + warehouse_id=3, + location_id=3, + source_doc_type="FG_RECEIPT", + source_doc_id=3, + inbound_qty=Decimal("20"), + inbound_weight_kg=Decimal("50"), + remaining_qty=Decimal("12"), + remaining_weight_kg=Decimal("30"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("18"), + quality_status="PASS", + status="AVAILABLE", + created_at=now, + updated_at=now, + ) + self.raw_balance = StockBalance( + id=1, + item_id=1, + warehouse_id=1, + location_id=1, + qty_on_hand=Decimal("0"), + weight_on_hand_kg=Decimal("80"), + qty_available=Decimal("0"), + weight_available_kg=Decimal("80"), + avg_unit_cost=Decimal("10"), + created_at=now, + updated_at=now, + ) + self.aux_balance = StockBalance( + id=2, + item_id=2, + warehouse_id=2, + location_id=2, + qty_on_hand=Decimal("6"), + weight_on_hand_kg=Decimal("0"), + qty_available=Decimal("6"), + weight_available_kg=Decimal("0"), + avg_unit_cost=Decimal("2.5"), + created_at=now, + updated_at=now, + ) + self.finished_balance = StockBalance( + id=3, + item_id=3, + warehouse_id=3, + location_id=3, + qty_on_hand=Decimal("12"), + weight_on_hand_kg=Decimal("30"), + qty_available=Decimal("12"), + weight_available_kg=Decimal("30"), + avg_unit_cost=Decimal("18"), + created_at=now, + updated_at=now, + ) + self.db.add_all( + [ + self.department, + self.employee, + self.user, + self.unit, + self.raw_item, + self.aux_item, + self.finished_item, + self.finished_product, + self.raw_warehouse, + self.aux_warehouse, + self.finished_warehouse, + self.raw_location, + self.aux_location, + self.finished_location, + self.raw_lot, + self.aux_lot, + self.finished_lot, + self.raw_balance, + self.aux_balance, + self.finished_balance, + ] + ) + self.db.commit() + + def _adjustment_payload( + self, + *, + direction: str, + warehouse_id: int, + item_id: int, + lot_id: int | None = None, + location_id: int | None = None, + adjust_qty: Decimal = Decimal("0"), + adjust_weight_kg: Decimal = Decimal("0"), + reason: str | None = None, + unit_cost: Decimal | None = None, + line_reason: str | None = None, + ) -> SpecialWarehouseAdjustmentCreate: + return SpecialWarehouseAdjustmentCreate( + warehouse_id=warehouse_id, + direction=direction, + reason=reason, + lines=[ + SpecialWarehouseAdjustmentLineCreate( + item_id=item_id, + lot_id=lot_id, + location_id=location_id, + adjust_qty=adjust_qty, + adjust_weight_kg=adjust_weight_kg, + unit_cost=unit_cost, + line_reason=line_reason, + ) + ], + ) + + def _only_txn(self, txn_type: str) -> InventoryTxn: + return self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == txn_type)) + + def _auth_context(self): + return SimpleNamespace(user=SimpleNamespace(id=self.user.id)) + + def _seed_second_raw_lot(self) -> StockLot: + now = datetime(2026, 6, 6, 9, 0, tzinfo=UTC) + second_lot = StockLot( + id=99, + lot_no="RAW-LOT-002", + lot_role="INBOUND_RAW", + item_id=self.raw_item.id, + warehouse_id=self.raw_warehouse.id, + location_id=self.raw_location.id, + source_doc_type="OPENING_STOCK", + source_doc_id=99, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("20"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("20"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("10"), + quality_status="PASS", + status="AVAILABLE", + created_at=now, + updated_at=now, + ) + self.raw_balance.weight_on_hand_kg += Decimal("20") + self.raw_balance.weight_available_kg += Decimal("20") + self.db.add_all([second_lot, self.raw_balance]) + self.db.commit() + return second_lot + + def test_special_out_requires_remark_and_keeps_inventory_unchanged(self) -> None: + before_lot_weight = self.raw_lot.remaining_weight_kg + before_balance_weight = self.raw_balance.weight_on_hand_kg + + with self.assertRaises(ValueError) as exc: + create_special_inventory_adjustment( + self.db, + self._adjustment_payload( + direction="OUT", + warehouse_id=self.raw_warehouse.id, + item_id=self.raw_item.id, + lot_id=self.raw_lot.id, + adjust_weight_kg=Decimal("5"), + reason="", + ), + user_id=9, + ) + + self.assertIn("特殊出库必须填写说明", str(exc.exception)) + self.db.refresh(self.raw_lot) + self.db.refresh(self.raw_balance) + self.assertEqual(self.raw_lot.remaining_weight_kg, before_lot_weight) + self.assertEqual(self.raw_balance.weight_on_hand_kg, before_balance_weight) + self.assertIsNone(self._only_txn("SPECIAL_OUT")) + + def test_raw_special_out_deducts_lot_balance_by_weight_and_writes_ledger(self) -> None: + create_special_inventory_adjustment( + self.db, + self._adjustment_payload( + direction="OUT", + warehouse_id=self.raw_warehouse.id, + item_id=self.raw_item.id, + lot_id=self.raw_lot.id, + adjust_weight_kg=Decimal("12.5"), + reason="样品寄出", + ), + user_id=9, + ) + + self.db.refresh(self.raw_lot) + self.db.refresh(self.raw_balance) + txn = self._only_txn("SPECIAL_OUT") + self.assertEqual(self.raw_lot.remaining_qty, Decimal("0.000000")) + self.assertEqual(self.raw_lot.remaining_weight_kg, Decimal("67.500000")) + self.assertEqual(self.raw_balance.qty_on_hand, Decimal("0.000000")) + self.assertEqual(self.raw_balance.weight_on_hand_kg, Decimal("67.500000")) + self.assertEqual(self.raw_balance.weight_available_kg, Decimal("67.500000")) + self.assertIsNotNone(txn) + self.assertEqual(txn.lot_id, self.raw_lot.id) + self.assertEqual(txn.qty_change, Decimal("0.000000")) + self.assertEqual(txn.weight_change_kg, Decimal("-12.500000")) + self.assertIn("特殊出库说明:样品寄出", txn.remark) + self.assertIn("调整前重量:80", txn.remark) + self.assertIn("调整后重量:67.5", txn.remark) + + def test_raw_special_out_is_visible_in_inventory_transaction_ledger(self) -> None: + create_special_inventory_adjustment( + self.db, + self._adjustment_payload( + direction="OUT", + warehouse_id=self.raw_warehouse.id, + item_id=self.raw_item.id, + lot_id=self.raw_lot.id, + adjust_weight_kg=Decimal("6.5"), + reason="研发打样领出", + ), + user_id=9, + ) + + rows = self.db.execute( + get_inventory_txn_ledger_query(warehouse_type="RAW", txn_type="SPECIAL_OUT") + ).mappings().all() + + self.assertEqual(len(rows), 1) + row = rows[0] + self.assertEqual(row["direction"], "OUT") + self.assertEqual(row["txn_type"], "SPECIAL_OUT") + self.assertIn("特殊出库说明:研发打样领出", row["remark"]) + + def test_special_adjustment_generates_archive_for_each_adjustment_transaction(self) -> None: + second_lot = self._seed_second_raw_lot() + + result = create_special_warehouse_adjustment( + SpecialWarehouseAdjustmentCreate( + warehouse_id=self.raw_warehouse.id, + direction="OUT", + reason="研发打样多批次领出", + lines=[ + SpecialWarehouseAdjustmentLineCreate( + item_id=self.raw_item.id, + lot_id=self.raw_lot.id, + adjust_weight_kg=Decimal("5"), + ), + SpecialWarehouseAdjustmentLineCreate( + item_id=self.raw_item.id, + lot_id=second_lot.id, + adjust_weight_kg=Decimal("7"), + ), + ], + ), + context=self._auth_context(), + db=self.db, + ) + + txns = self.db.scalars( + select(InventoryTxn).where(InventoryTxn.txn_type == "SPECIAL_OUT").order_by(InventoryTxn.id) + ).all() + self.assertEqual(len(txns), 2) + self.assertEqual(result.archive_status, ARCHIVE_STATUS_READY) + self.assertEqual(result.archive_document_type, DOCUMENT_TYPE_WAREHOUSE_OPERATION) + self.assertEqual(result.archive_business_id, txns[0].id) + archive_business_ids = set( + self.db.scalars( + select(DocumentArchive.business_id).where( + DocumentArchive.document_type == DOCUMENT_TYPE_WAREHOUSE_OPERATION, + DocumentArchive.business_id.in_([txn.id for txn in txns]), + ) + ).all() + ) + self.assertEqual(archive_business_ids, {txn.id for txn in txns}) + + def test_special_out_rejects_quantity_greater_than_auxiliary_available_quantity(self) -> None: + with self.assertRaises(ValueError) as exc: + create_special_inventory_adjustment( + self.db, + self._adjustment_payload( + direction="OUT", + warehouse_id=self.aux_warehouse.id, + item_id=self.aux_item.id, + lot_id=self.aux_lot.id, + adjust_qty=Decimal("7"), + reason="盘亏调整", + ), + user_id=9, + ) + + self.assertIn("特殊出库数量不能超过当前可用数量", str(exc.exception)) + self.db.refresh(self.aux_lot) + self.db.refresh(self.aux_balance) + self.assertEqual(self.aux_lot.remaining_qty, Decimal("6.000000")) + self.assertEqual(self.aux_balance.qty_available, Decimal("6.000000")) + self.assertIsNone(self._only_txn("SPECIAL_OUT")) + + def test_auxiliary_special_in_increases_quantity_only_and_writes_ledger(self) -> None: + create_special_inventory_adjustment( + self.db, + self._adjustment_payload( + direction="IN", + warehouse_id=self.aux_warehouse.id, + item_id=self.aux_item.id, + location_id=self.aux_location.id, + adjust_qty=Decimal("4"), + adjust_weight_kg=Decimal("99"), + reason="供应商补送", + unit_cost=Decimal("2.5"), + ), + user_id=9, + ) + + lot = self.db.scalar( + select(StockLot).where( + StockLot.item_id == self.aux_item.id, + StockLot.warehouse_id == self.aux_warehouse.id, + StockLot.source_doc_type == "SPECIAL_ADJUSTMENT", + ) + ) + self.db.refresh(self.aux_balance) + txn = self._only_txn("SPECIAL_IN") + self.assertIsNotNone(lot) + self.assertEqual(lot.inbound_qty, Decimal("4.000000")) + self.assertEqual(lot.remaining_qty, Decimal("4.000000")) + self.assertEqual(lot.inbound_weight_kg, Decimal("0.000000")) + self.assertEqual(lot.remaining_weight_kg, Decimal("0.000000")) + self.assertEqual(self.aux_balance.qty_on_hand, Decimal("10.000000")) + self.assertEqual(self.aux_balance.qty_available, Decimal("10.000000")) + self.assertEqual(self.aux_balance.weight_on_hand_kg, Decimal("0.000000")) + self.assertEqual(self.aux_balance.weight_available_kg, Decimal("0.000000")) + self.assertIsNotNone(txn) + self.assertEqual(txn.qty_change, Decimal("4.000000")) + self.assertEqual(txn.weight_change_kg, Decimal("0.000000")) + self.assertIn("特殊入库说明:供应商补送", txn.remark) + + def test_raw_special_in_generates_global_raw_lot_number(self) -> None: + create_special_inventory_adjustment( + self.db, + self._adjustment_payload( + direction="IN", + warehouse_id=self.raw_warehouse.id, + item_id=self.raw_item.id, + location_id=self.raw_location.id, + adjust_weight_kg=Decimal("15"), + reason="供应商补送原料", + unit_cost=Decimal("10"), + ), + user_id=9, + ) + + lot = self.db.scalar( + select(StockLot).where( + StockLot.item_id == self.raw_item.id, + StockLot.warehouse_id == self.raw_warehouse.id, + StockLot.source_doc_type == "SPECIAL_ADJUSTMENT", + ) + ) + + self.assertIsNotNone(lot) + self.assertEqual(lot.lot_no, "YL0001") + + def test_finished_special_out_deducts_quantity_and_weight_together(self) -> None: + create_special_inventory_adjustment( + self.db, + self._adjustment_payload( + direction="OUT", + warehouse_id=self.finished_warehouse.id, + item_id=self.finished_item.id, + lot_id=self.finished_lot.id, + adjust_qty=Decimal("3"), + adjust_weight_kg=Decimal("7.5"), + reason="展会样品", + ), + user_id=9, + ) + + self.db.refresh(self.finished_lot) + self.db.refresh(self.finished_balance) + txn = self._only_txn("SPECIAL_OUT") + self.assertEqual(self.finished_lot.remaining_qty, Decimal("9.000000")) + self.assertEqual(self.finished_lot.remaining_weight_kg, Decimal("22.500000")) + self.assertEqual(self.finished_balance.qty_on_hand, Decimal("9.000000")) + self.assertEqual(self.finished_balance.qty_available, Decimal("9.000000")) + self.assertEqual(self.finished_balance.weight_on_hand_kg, Decimal("22.500000")) + self.assertEqual(self.finished_balance.weight_available_kg, Decimal("22.500000")) + self.assertIsNotNone(txn) + self.assertEqual(txn.qty_change, Decimal("-3.000000")) + self.assertEqual(txn.weight_change_kg, Decimal("-7.500000")) + self.assertIn("特殊出库说明:展会样品", txn.remark) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_stocktake_excel_diff_confirm.py b/backend/tests/test_stocktake_excel_diff_confirm.py new file mode 100644 index 0000000..359aa17 --- /dev/null +++ b/backend/tests/test_stocktake_excel_diff_confirm.py @@ -0,0 +1,561 @@ +from __future__ import annotations + +import unittest +from datetime import UTC, datetime +from decimal import Decimal +from io import BytesIO + +from openpyxl import Workbook, load_workbook +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Item, StockBalance, Warehouse # noqa: E402 +from app.models.operations import StockLot, StocktakeLine, StocktakeWarehouse # noqa: E402 +from app.services.stocktake import build_stocktake_workbook, start_stocktake # noqa: E402 + + +class StocktakeExcelDiffConfirmTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self._seed() + + def tearDown(self) -> None: + self.db.close() + + def _seed(self) -> None: + now = datetime.now(UTC) + self.raw = Warehouse( + id=1, + warehouse_code="WH-RAW-01", + warehouse_name="原材料库", + warehouse_type="RAW", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.item = Item( + id=1, + item_code="原材料00001", + item_name="冷轧钢板", + item_type="RAW_MATERIAL", + unit_weight_kg=Decimal("1"), + safety_stock_weight_kg=Decimal("0"), + scrap_sale_price=Decimal("0"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add_all([self.raw, self.item]) + self.db.flush() + self.lot = StockLot( + id=1, + lot_no="LOT-RAW-001", + lot_role="INVENTORY", + item_id=self.item.id, + warehouse_id=self.raw.id, + source_doc_type="OPENING", + source_doc_id=1, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("100"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("100"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("2.5"), + quality_status="PASS", + status="AVAILABLE", + created_at=now, + updated_at=now, + ) + self.balance = StockBalance( + item_id=self.item.id, + warehouse_id=self.raw.id, + qty_on_hand=Decimal("0"), + weight_on_hand_kg=Decimal("100"), + qty_available=Decimal("0"), + weight_available_kg=Decimal("100"), + qty_allocated=Decimal("0"), + weight_allocated_kg=Decimal("0"), + avg_unit_cost=Decimal("2.5"), + created_at=now, + updated_at=now, + ) + self.db.add_all([self.lot, self.balance]) + self.db.commit() + + def test_empty_warehouse_stocktake_import_can_be_confirmed_and_unlocked(self) -> None: + from app.services.stocktake import confirm_stocktake, import_stocktake_workbook + + now = datetime.now(UTC) + aux = Warehouse( + id=2, + warehouse_code="WH-AUX-01", + warehouse_name="辅料库", + warehouse_type="AUX", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add(aux) + self.db.commit() + + stocktake = start_stocktake(self.db, warehouse_ids=[aux.id], user_id=1, remark="空仓盘库") + workbook = build_stocktake_workbook(self.db, stocktake.id) + output = BytesIO() + workbook.save(output) + + preview = import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1) + + self.assertEqual(preview["summary"]["match_count"], 0) + self.assertEqual(preview["lines"], []) + imported = self.db.get(type(stocktake), stocktake.id) + self.assertEqual(imported.status, "IMPORTED") + + confirmed = confirm_stocktake(self.db, stocktake.id, user_id=2, confirm_text="确认盘库", remark="空仓确认") + stocktake_warehouse = self.db.scalar( + select(StocktakeWarehouse).where(StocktakeWarehouse.stocktake_id == stocktake.id) + ) + + self.assertEqual(confirmed.status, "CONFIRMED") + self.assertEqual(stocktake_warehouse.status, "UNLOCKED") + + def test_empty_warehouse_export_includes_stocktake_marker_row(self) -> None: + now = datetime.now(UTC) + aux = Warehouse( + id=2, + warehouse_code="WH-AUX-01", + warehouse_name="辅料库", + warehouse_type="AUX", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add(aux) + self.db.commit() + + stocktake = start_stocktake(self.db, warehouse_ids=[aux.id], user_id=1, remark="空仓盘库") + workbook = build_stocktake_workbook(self.db, stocktake.id) + output = BytesIO() + workbook.save(output) + loaded = load_workbook(BytesIO(output.getvalue()), read_only=True, data_only=True) + + rows = list(loaded["辅料库"].iter_rows(values_only=True)) + headers = rows[0] + + self.assertEqual(rows[1][headers.index("盘库单号")], stocktake.stocktake_no) + self.assertEqual(rows[1][headers.index("仓库")], "辅料库") + self.assertIn("空库", rows[1][headers.index("盘点备注")]) + + def test_empty_warehouse_marker_row_imports_as_clean_empty_preview(self) -> None: + from openpyxl import Workbook + + from app.services.stocktake import import_stocktake_workbook + + now = datetime.now(UTC) + aux = Warehouse( + id=2, + warehouse_code="WH-AUX-01", + warehouse_name="辅料库", + warehouse_type="AUX", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add(aux) + self.db.commit() + + stocktake = start_stocktake(self.db, warehouse_ids=[aux.id], user_id=1, remark="空仓盘库") + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "辅料库" + worksheet.append([ + "盘库单号", + "盘库校验码", + "仓库ID", + "快照行ID", + "仓库", + "库位", + "物料编码", + "物料名称", + "批次号", + "来源库存批次号", + "系统数量", + "系统重量(kg)", + "系统单价", + "盘点数量", + "盘点重量(kg)", + "盘点备注", + ]) + worksheet.append([ + stocktake.stocktake_no, + f"{stocktake.stocktake_no}|{aux.id}|AUX", + aux.id, + "", + "辅料库", + "", + "", + "", + "", + "", + 0, + 0, + 0, + 0, + 0, + "空库盘点校验行", + ]) + output = BytesIO() + workbook.save(output) + + preview = import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1) + + self.assertEqual(preview["lines"], []) + self.assertEqual(preview["summary"]["match_count"], 0) + self.assertEqual(self.db.get(type(stocktake), stocktake.id).status, "IMPORTED") + + def test_exported_empty_warehouse_stocktake_can_be_confirmed_without_import_rows(self) -> None: + from app.services.stocktake import confirm_stocktake + + now = datetime.now(UTC) + aux = Warehouse( + id=2, + warehouse_code="WH-AUX-01", + warehouse_name="辅料库", + warehouse_type="AUX", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add(aux) + self.db.commit() + + stocktake = start_stocktake(self.db, warehouse_ids=[aux.id], user_id=1, remark="空仓盘库") + build_stocktake_workbook(self.db, stocktake.id) + + confirmed = confirm_stocktake(self.db, stocktake.id, user_id=2, confirm_text="确认盘库", remark="空仓确认") + stocktake_warehouse = self.db.scalar( + select(StocktakeWarehouse).where(StocktakeWarehouse.stocktake_id == stocktake.id) + ) + + self.assertEqual(confirmed.status, "CONFIRMED") + self.assertIsNotNone(confirmed.imported_at) + self.assertEqual(stocktake_warehouse.status, "UNLOCKED") + + def test_confirm_stocktake_does_not_require_manual_confirm_text(self) -> None: + from app.services.stocktake import confirm_stocktake + + now = datetime.now(UTC) + aux = Warehouse( + id=2, + warehouse_code="WH-AUX-01", + warehouse_name="辅料库", + warehouse_type="AUX", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add(aux) + self.db.commit() + + stocktake = start_stocktake(self.db, warehouse_ids=[aux.id], user_id=1, remark="空仓盘库") + build_stocktake_workbook(self.db, stocktake.id) + + confirmed = confirm_stocktake(self.db, stocktake.id, user_id=2, confirm_text="", remark="空仓确认") + + self.assertEqual(confirmed.status, "CONFIRMED") + + def test_start_stocktake_locks_warehouse_and_snapshots_lots(self) -> None: + stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="月底盘库") + + self.assertEqual(stocktake.status, "LOCKED") + line = self.db.scalar(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake.id)) + self.assertIsNotNone(line) + self.assertEqual(line.lot_no, "LOT-RAW-001") + self.assertEqual(float(line.snapshot_weight_kg), 100) + self.assertEqual(line.diff_type, "MATCH") + + def test_start_stocktake_rejects_multiple_warehouses_for_current_bar_flow(self) -> None: + from fastapi import HTTPException + + now = datetime.now(UTC) + aux = Warehouse( + id=2, + warehouse_code="WH-AUX-01", + warehouse_name="辅料库", + warehouse_type="AUX", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add(aux) + self.db.commit() + + with self.assertRaises(HTTPException) as exc: + start_stocktake(self.db, warehouse_ids=[self.raw.id, aux.id], user_id=1, remark="多库盘库") + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("一次只能盘一个仓库", exc.exception.detail) + + def test_export_workbook_has_one_sheet_per_selected_warehouse(self) -> None: + stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="月底盘库") + + workbook = build_stocktake_workbook(self.db, stocktake.id) + output = BytesIO() + workbook.save(output) + loaded = load_workbook(BytesIO(output.getvalue()), read_only=True, data_only=True) + + self.assertEqual(loaded.sheetnames, ["原材料库"]) + rows = list(loaded["原材料库"].iter_rows(values_only=True)) + self.assertIn("快照行ID", rows[0]) + self.assertIn("盘点重量(kg)", rows[0]) + self.assertNotIn("系统数量", rows[0]) + self.assertNotIn("盘点数量", rows[0]) + self.assertEqual(rows[1][rows[0].index("批次号")], "LOT-RAW-001") + + def test_single_warehouse_export_contains_validation_marker(self) -> None: + stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="原材料盘库") + workbook = build_stocktake_workbook(self.db, stocktake.id) + output = BytesIO() + workbook.save(output) + loaded = load_workbook(BytesIO(output.getvalue()), read_only=True, data_only=True) + + self.assertEqual(loaded.sheetnames, ["原材料库"]) + rows = list(loaded["原材料库"].iter_rows(values_only=True)) + headers = rows[0] + + self.assertIn("盘库校验码", headers) + self.assertIn("仓库ID", headers) + self.assertEqual(rows[1][headers.index("盘库单号")], stocktake.stocktake_no) + self.assertEqual(rows[1][headers.index("仓库ID")], self.raw.id) + self.assertEqual( + rows[1][headers.index("盘库校验码")], + f"{stocktake.stocktake_no}|{self.raw.id}|RAW", + ) + + def _exported_workbook_bytes(self, stocktake) -> bytes: + workbook = build_stocktake_workbook(self.db, stocktake.id) + output = BytesIO() + workbook.save(output) + return output.getvalue() + + def test_import_rejects_workbook_from_other_stocktake_number(self) -> None: + from app.services.stocktake import import_stocktake_workbook + + stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="原材料盘库") + workbook = load_workbook(BytesIO(self._exported_workbook_bytes(stocktake))) + worksheet = workbook["原材料库"] + headers = [cell.value for cell in worksheet[1]] + worksheet.cell(row=2, column=headers.index("盘库单号") + 1).value = "PK-OTHER-001" + worksheet.cell(row=2, column=headers.index("盘库校验码") + 1).value = "PK-OTHER-001|1|RAW" + output = BytesIO() + workbook.save(output) + + with self.assertRaises(ValueError) as exc: + import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1) + + self.assertIn("不属于当前盘库单", str(exc.exception)) + + def test_import_rejects_workbook_from_other_warehouse(self) -> None: + from app.services.stocktake import import_stocktake_workbook + + stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="原材料盘库") + workbook = load_workbook(BytesIO(self._exported_workbook_bytes(stocktake))) + worksheet = workbook["原材料库"] + headers = [cell.value for cell in worksheet[1]] + worksheet.cell(row=2, column=headers.index("仓库") + 1).value = "成品库" + worksheet.cell(row=2, column=headers.index("仓库ID") + 1).value = 999 + worksheet.cell(row=2, column=headers.index("盘库校验码") + 1).value = f"{stocktake.stocktake_no}|999|FINISHED" + output = BytesIO() + workbook.save(output) + + with self.assertRaises(ValueError) as exc: + import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1) + + self.assertIn("不属于当前仓库", str(exc.exception)) + + def test_reimport_overwrites_previous_stocktake_preview(self) -> None: + from app.services.stocktake import import_stocktake_workbook + + stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="原材料盘库") + + workbook = load_workbook(BytesIO(self._exported_workbook_bytes(stocktake))) + worksheet = workbook["原材料库"] + headers = [cell.value for cell in worksheet[1]] + worksheet.cell(row=2, column=headers.index("盘点重量(kg)") + 1).value = 90 + output = BytesIO() + workbook.save(output) + first_preview = import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1) + self.assertEqual(first_preview["summary"]["loss_count"], 1) + + workbook = load_workbook(BytesIO(self._exported_workbook_bytes(stocktake))) + worksheet = workbook["原材料库"] + headers = [cell.value for cell in worksheet[1]] + worksheet.cell(row=2, column=headers.index("盘点重量(kg)") + 1).value = 100 + output = BytesIO() + workbook.save(output) + second_preview = import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1) + + self.assertEqual(second_preview["summary"]["loss_count"], 0) + self.assertEqual(second_preview["summary"]["match_count"], 1) + line = self.db.scalar(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake.id)) + self.assertEqual(line.diff_type, "MATCH") + self.assertEqual(float(line.counted_weight_kg), 100) + + def test_import_counted_workbook_marks_loss_gain_and_missing_rows(self) -> None: + from app.services.stocktake import import_stocktake_workbook + + stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="月底盘库") + line = self.db.scalar(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake.id)) + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "原材料库" + worksheet.append([ + "盘库单号", + "盘库校验码", + "仓库ID", + "快照行ID", + "仓库", + "库位", + "物料编码", + "物料名称", + "批次号", + "来源库存批次号", + "系统数量", + "系统重量(kg)", + "系统单价", + "盘点数量", + "盘点重量(kg)", + "盘点备注", + ]) + worksheet.append([ + stocktake.stocktake_no, + f"{stocktake.stocktake_no}|{self.raw.id}|RAW", + self.raw.id, + line.id, + "原材料库", + "", + "原材料00001", + "冷轧钢板", + "LOT-RAW-001", + "", + 0, + 100, + 2.5, + 0, + 90, + "实盘少10kg", + ]) + output = BytesIO() + workbook.save(output) + + preview = import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1) + + self.assertEqual(preview["summary"]["loss_count"], 1) + loss_line = self.db.get(StocktakeLine, line.id) + self.assertEqual(loss_line.diff_type, "LOSS") + self.assertEqual(float(loss_line.diff_weight_kg), -10) + self.assertEqual(loss_line.row_status, "READY") + + def test_confirm_stocktake_adjusts_lot_balance_and_writes_txn(self) -> None: + from openpyxl import Workbook + + from app.models.operations import InventoryTxn, StocktakeAdjustment + from app.services.stocktake import confirm_stocktake, import_stocktake_workbook + + stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="月底盘库") + line = self.db.scalar(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake.id)) + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "原材料库" + worksheet.append([ + "盘库单号", + "盘库校验码", + "仓库ID", + "快照行ID", + "仓库", + "库位", + "物料编码", + "物料名称", + "批次号", + "来源库存批次号", + "系统数量", + "系统重量(kg)", + "系统单价", + "盘点数量", + "盘点重量(kg)", + "盘点备注", + ]) + worksheet.append([ + stocktake.stocktake_no, + f"{stocktake.stocktake_no}|{self.raw.id}|RAW", + self.raw.id, + line.id, + "原材料库", + "", + "原材料00001", + "冷轧钢板", + "LOT-RAW-001", + "", + 0, + 100, + 2.5, + 0, + 90, + "实盘少10kg", + ]) + output = BytesIO() + workbook.save(output) + import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1) + + confirmed = confirm_stocktake(self.db, stocktake.id, user_id=2, confirm_text="确认盘库", remark="确认调整") + + self.assertEqual(confirmed.status, "CONFIRMED") + self.db.refresh(self.lot) + self.db.refresh(self.balance) + self.assertEqual(float(self.lot.remaining_weight_kg), 90) + self.assertEqual(float(self.balance.weight_on_hand_kg), 90) + txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "STOCKTAKE_LOSS")) + self.assertIsNotNone(txn) + adjustment = self.db.scalar(select(StocktakeAdjustment).where(StocktakeAdjustment.stocktake_id == stocktake.id)) + self.assertIsNotNone(adjustment) + self.assertEqual(float(adjustment.weight_change_kg), -10) + + def test_stocktake_status_sequence_supports_status_driven_ui(self) -> None: + from app.services.stocktake import confirm_stocktake, import_stocktake_workbook + + stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="月底盘库") + self.assertEqual(stocktake.status, "LOCKED") + + workbook = build_stocktake_workbook(self.db, stocktake.id) + self.assertEqual(self.db.get(type(stocktake), stocktake.id).status, "EXPORTED") + + output = BytesIO() + workbook.save(output) + preview = import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1) + self.assertEqual(preview["stocktake"].status, "IMPORTED") + self.assertEqual(preview["summary"]["match_count"], 1) + + confirmed = confirm_stocktake(self.db, stocktake.id, user_id=2, confirm_text="", remark="确认盘库") + self.assertEqual(confirmed.status, "CONFIRMED") + stocktake_warehouse = self.db.scalar( + select(StocktakeWarehouse).where(StocktakeWarehouse.stocktake_id == stocktake.id) + ) + self.assertEqual(stocktake_warehouse.status, "UNLOCKED") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_stocktake_locking.py b/backend/tests/test_stocktake_locking.py new file mode 100644 index 0000000..4b7ca45 --- /dev/null +++ b/backend/tests/test_stocktake_locking.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import unittest +from datetime import UTC, datetime + +from fastapi import HTTPException +from sqlalchemy import BigInteger, create_engine +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Warehouse # noqa: E402 +from app.models.operations import Stocktake, StocktakeWarehouse # noqa: E402 +from app.services.stocktake import ensure_warehouses_unlocked, get_locked_warehouse_ids # noqa: E402 + + +class StocktakeLockingTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self._seed() + + def tearDown(self) -> None: + self.db.close() + + def _seed(self) -> None: + now = datetime.now(UTC) + self.raw = Warehouse( + id=1, + warehouse_code="WH-RAW-01", + warehouse_name="原材料库", + warehouse_type="RAW", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.finished = Warehouse( + id=2, + warehouse_code="WH-FG-01", + warehouse_name="成品库", + warehouse_type="FINISHED", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add_all([self.raw, self.finished]) + self.db.flush() + stocktake = Stocktake( + stocktake_no="PK-20260530-001", + status="LOCKED", + started_by_user_id=1, + started_at=now, + remark="测试锁库", + ) + self.db.add(stocktake) + self.db.flush() + self.db.add( + StocktakeWarehouse( + stocktake_id=stocktake.id, + warehouse_id=self.raw.id, + warehouse_type="RAW", + warehouse_name="原材料库", + status="LOCKED", + ) + ) + self.db.commit() + + def test_get_locked_warehouse_ids_returns_only_active_stocktakes(self) -> None: + locked = get_locked_warehouse_ids(self.db, [self.raw.id, self.finished.id]) + + self.assertEqual(locked, {self.raw.id}) + + def test_ensure_warehouses_unlocked_raises_for_locked_warehouse(self) -> None: + with self.assertRaises(HTTPException) as exc: + ensure_warehouses_unlocked(self.db, [self.raw.id], "入库") + + self.assertEqual(exc.exception.status_code, 423) + self.assertIn("原材料库正在盘库", exc.exception.detail) + + def test_ensure_warehouses_unlocked_allows_unlocked_warehouse(self) -> None: + ensure_warehouses_unlocked(self.db, [self.finished.id], "销售出库") + + def test_inventory_inbound_is_blocked_when_warehouse_is_locked(self) -> None: + from decimal import Decimal + + from app.api.routes.inventory import create_warehouse_inbound + from app.models.master_data import Item + from app.schemas.operations import WarehouseInboundCreate + + now = datetime.now(UTC) + item = Item( + id=1, + item_code="原材料00001", + item_name="冷轧钢板", + item_type="RAW_MATERIAL", + unit_weight_kg=Decimal("1"), + safety_stock_weight_kg=Decimal("0"), + scrap_sale_price=Decimal("0"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add(item) + self.db.commit() + + class User: + id = 1 + + class Context: + user = User() + + with self.assertRaises(HTTPException) as exc: + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="OPENING", + item_id=item.id, + warehouse_id=self.raw.id, + inbound_weight_kg=10, + unit_cost=1, + ), + context=Context(), + db=self.db, + ) + + self.assertEqual(exc.exception.status_code, 423) + self.assertIn("正在盘库", exc.exception.detail) + + def test_lock_guard_message_names_action(self) -> None: + with self.assertRaises(HTTPException) as exc: + ensure_warehouses_unlocked(self.db, [self.raw.id], "到货入库") + + self.assertEqual(exc.exception.status_code, 423) + self.assertIn("到货入库", exc.exception.detail) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_system_config_raw_lot_prefix.py b/backend/tests/test_system_config_raw_lot_prefix.py new file mode 100644 index 0000000..09bcacc --- /dev/null +++ b/backend/tests/test_system_config_raw_lot_prefix.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import unittest +from datetime import datetime + +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.org import SystemConfig # noqa: E402 +from app.services.system_config import ( # noqa: E402 + RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE, + get_raw_material_lot_prefix, + normalize_raw_material_lot_prefix, + upsert_system_config, +) + + +class SystemConfigRawLotPrefixTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + + def tearDown(self) -> None: + self.db.close() + + def test_raw_material_lot_prefix_defaults_and_normalizes(self) -> None: + self.assertEqual(get_raw_material_lot_prefix(self.db), "YL") + self.assertEqual(normalize_raw_material_lot_prefix(" yl-01 "), "YL01") + self.assertEqual(normalize_raw_material_lot_prefix(""), "YL") + self.assertEqual(normalize_raw_material_lot_prefix("原材料"), "YL") + + def test_upsert_raw_material_lot_prefix_normalizes_and_reads_active_config(self) -> None: + fixed_now = datetime(2026, 6, 7, 8, 30) + + row = upsert_system_config( + self.db, + config_code=RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE, + config_name="原材料库存批次号前缀", + config_value="rm", + remark="测试配置", + updated_by=1, + now=fixed_now, + ) + self.db.commit() + + persisted = self.db.scalar(select(SystemConfig).where(SystemConfig.config_code == RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE)) + self.assertIsNotNone(persisted) + self.assertEqual(row.config_value, "RM") + self.assertEqual(persisted.config_value, "RM") + self.assertEqual(persisted.config_name, "原材料库存批次号前缀") + self.assertEqual(persisted.remark, "测试配置") + self.assertEqual(persisted.updated_by, 1) + self.assertEqual(persisted.updated_at, fixed_now) + self.assertEqual(get_raw_material_lot_prefix(self.db), "RM") + + updated_now = datetime(2026, 6, 7, 9, 45) + updated_row = upsert_system_config( + self.db, + config_code=RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE, + config_name="原材料库存批次号前缀", + config_value=" yl-02 ", + remark="更新配置", + updated_by=2, + now=updated_now, + ) + self.db.commit() + + rows = self.db.scalars(select(SystemConfig).where(SystemConfig.config_code == RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE)).all() + self.assertEqual(len(rows), 1) + self.assertEqual(updated_row.id, persisted.id) + self.assertEqual(rows[0].config_value, "YL02") + self.assertEqual(rows[0].remark, "更新配置") + self.assertEqual(rows[0].updated_by, 2) + self.assertEqual(rows[0].updated_at, updated_now) + self.assertEqual(get_raw_material_lot_prefix(self.db), "YL02") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_system_permission_management.py b/backend/tests/test_system_permission_management.py new file mode 100644 index 0000000..532d779 --- /dev/null +++ b/backend/tests/test_system_permission_management.py @@ -0,0 +1,736 @@ +from __future__ import annotations + +import unittest +from datetime import datetime + +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.org import Department, Employee, Permission, Role, RolePermission, User, UserRole # noqa: E402 + + +class SystemPermissionManagementTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + now = datetime(2026, 6, 8, 9, 0, 0) + self.company = Department( + dept_code="ORG_ROOT", + dept_name="总公司", + parent_id=None, + org_node_type="COMPANY", + dept_type="ADMIN", + manager_name="王总", + manager_employee_id=None, + status="ACTIVE", + sort_no=1, + remark="总公司节点", + created_at=now, + updated_at=now, + ) + self.db.add(self.company) + self.db.flush() + + def tearDown(self) -> None: + self.db.close() + + def test_department_model_exposes_tree_and_manager_fields(self) -> None: + row = self.db.scalar(select(Department).where(Department.dept_code == "ORG_ROOT")) + self.assertIsNotNone(row) + self.assertEqual(row.parent_id, None) + self.assertEqual(row.org_node_type, "COMPANY") + self.assertEqual(row.manager_name, "王总") + self.assertEqual(row.manager_employee_id, None) + self.assertEqual(row.remark, "总公司节点") + + def _seed_role_permission_user_data(self) -> None: + now = datetime(2026, 6, 8, 9, 0, 0) + purchase = Department( + dept_code="PURCHASE", + dept_name="采购部", + parent_id=self.company.id, + org_node_type="DEPARTMENT", + dept_type="PURCHASE", + manager_name="采购主管", + manager_employee_id=None, + status="ACTIVE", + sort_no=10, + remark=None, + created_at=now, + updated_at=now, + ) + self.db.add(purchase) + self.db.flush() + manager = Employee( + employee_code="EMP-001", + employee_name="张三", + dept_id=purchase.id, + mobile="13800000011", + gender=None, + hire_date=None, + job_title="采购负责人", + shift_code=None, + manager_employee_id=None, + is_operator=0, + is_workshop_staff=0, + status="ACTIVE", + remark="来自人员主数据", + created_at=now, + updated_at=now, + ) + buyer = Employee( + employee_code="EMP-002", + employee_name="李四", + dept_id=purchase.id, + mobile="13800000012", + gender=None, + hire_date=None, + job_title="采购专员", + shift_code=None, + manager_employee_id=None, + is_operator=0, + is_workshop_staff=0, + status="ACTIVE", + remark=None, + created_at=now, + updated_at=now, + ) + role = Role( + role_code="PURCHASER", + role_name="采购专员", + role_scope="SYSTEM", + status="ACTIVE", + remark=None, + created_at=now, + updated_at=now, + ) + permission = Permission( + permission_code="MENU_PURCHASE_ORDER", + permission_name="采购订单菜单", + module_code="PURCHASE_ORDER", + action_code="VIEW", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add_all([manager, buyer, role, permission]) + self.db.flush() + purchase.manager_employee_id = manager.id + purchase.manager_name = manager.employee_name + self.db.add(purchase) + self.db.commit() + self.purchase_dept_id = purchase.id + self.manager_employee_id = manager.id + self.buyer_employee_id = buyer.id + self.purchaser_role_id = role.id + self.purchase_permission_id = permission.id + + def test_build_org_tree_returns_managed_root_and_ignores_legacy_root_departments(self) -> None: + from app.services.system_permissions import build_org_tree + + self._seed_role_permission_user_data() + now = datetime(2026, 6, 8, 9, 0, 0) + legacy = Department( + dept_code="LEGACY-SALES", + dept_name="旧销售部", + parent_id=None, + org_node_type="DEPARTMENT", + dept_type="SALES", + manager_name="旧主管", + manager_employee_id=None, + status="ACTIVE", + sort_no=99, + remark="旧部门不直接作为脑图根节点展示", + created_at=now, + updated_at=now, + ) + self.db.add(legacy) + self.db.commit() + + tree = build_org_tree(self.db) + + self.assertEqual(len(tree), 1) + self.assertEqual(tree[0].node_label, "总公司") + self.assertEqual(tree[0].node_type, "COMPANY") + purchase = tree[0].children[0] + self.assertEqual(purchase.node_label, "采购部") + self.assertEqual(purchase.manager_employee_id, self.manager_employee_id) + self.assertEqual(purchase.manager_name, "张三") + self.assertEqual(purchase.employees, []) + + def test_org_employee_leaves_are_explicit_bindings_not_department_membership(self) -> None: + from app.schemas.system_permissions import OrgEmployeeBind + from app.services.system_permissions import bind_org_employee, build_org_tree, unbind_org_employee + + self._seed_role_permission_user_data() + + tree_before = build_org_tree(self.db) + purchase_before = tree_before[0].children[0] + self.assertEqual(purchase_before.employees, []) + + bind_org_employee( + self.db, + self.purchase_dept_id, + OrgEmployeeBind(employee_id=self.buyer_employee_id, sort_no=1), + ) + tree_after = build_org_tree(self.db) + purchase_after = tree_after[0].children[0] + self.assertEqual([employee.employee_name for employee in purchase_after.employees], ["李四"]) + self.assertEqual(purchase_after.employees[0].mobile, "13800000012") + + unbind_org_employee(self.db, self.purchase_dept_id, self.buyer_employee_id) + tree_unbound = build_org_tree(self.db) + self.assertEqual(tree_unbound[0].children[0].employees, []) + + def test_update_org_node_supports_multiple_managers(self) -> None: + from app.schemas.system_permissions import OrgNodeUpdate + from app.services.system_permissions import build_org_tree, update_org_node + + self._seed_role_permission_user_data() + update_org_node( + self.db, + self.purchase_dept_id, + OrgNodeUpdate( + node_label="采购部", + dept_type="PURCHASE", + manager_employee_ids=[self.manager_employee_id, self.buyer_employee_id], + sort_no=10, + remark=None, + status="ACTIVE", + ), + ) + + tree = build_org_tree(self.db) + purchase = tree[0].children[0] + self.assertEqual(purchase.manager_employee_ids, [self.manager_employee_id, self.buyer_employee_id]) + self.assertEqual(purchase.manager_names, ["张三", "李四"]) + self.assertEqual(purchase.manager_employee_id, self.manager_employee_id) + self.assertEqual(purchase.manager_name, "张三、李四") + + def test_build_org_tree_falls_back_to_legacy_single_manager(self) -> None: + from app.services.system_permissions import build_org_tree + + self._seed_role_permission_user_data() + + tree = build_org_tree(self.db) + purchase = tree[0].children[0] + self.assertEqual(purchase.manager_employee_ids, [self.manager_employee_id]) + self.assertEqual(purchase.manager_names, ["张三"]) + + def test_create_system_user_uses_employee_phone_as_account_and_hashes_password(self) -> None: + from app.schemas.system_permissions import SystemUserCreate + from app.services.auth import verify_password + from app.services.system_permissions import create_system_user + + self._seed_role_permission_user_data() + result = create_system_user( + self.db, + SystemUserCreate( + employee_id=self.buyer_employee_id, + role_ids=[self.purchaser_role_id], + password="secret123", + status="ACTIVE", + ), + ) + + self.assertEqual(result.username, "13800000012") + self.assertEqual(result.employee_name, "李四") + self.assertEqual(result.role_names, ["采购专员"]) + user = self.db.scalar(select(User).where(User.username == "13800000012")) + self.assertIsNotNone(user) + self.assertTrue(verify_password("secret123", user.password_hash)) + links = self.db.scalars(select(UserRole).where(UserRole.user_id == user.id)).all() + self.assertEqual(len(links), 1) + self.assertEqual(links[0].role_id, self.purchaser_role_id) + + def test_create_system_user_rejects_employee_without_phone(self) -> None: + from fastapi import HTTPException + from app.schemas.system_permissions import SystemUserCreate + from app.services.system_permissions import create_system_user + + self._seed_role_permission_user_data() + now = datetime(2026, 6, 8, 9, 0, 0) + employee = Employee( + employee_code="EMP-NOPHONE", + employee_name="无手机号人员", + dept_id=self.purchase_dept_id, + mobile=None, + gender=None, + hire_date=None, + job_title=None, + shift_code=None, + manager_employee_id=None, + is_operator=0, + is_workshop_staff=0, + status="ACTIVE", + remark=None, + created_at=now, + updated_at=now, + ) + self.db.add(employee) + self.db.commit() + + with self.assertRaises(HTTPException) as ctx: + create_system_user( + self.db, + SystemUserCreate( + employee_id=employee.id, + role_ids=[self.purchaser_role_id], + password="secret123", + status="ACTIVE", + ), + ) + self.assertEqual(ctx.exception.status_code, 400) + self.assertIn("人员没有电话号码", str(ctx.exception.detail)) + + def test_create_role_invalid_permission_does_not_leave_pending_role(self) -> None: + from fastapi import HTTPException + from app.schemas.system_permissions import RoleCreate + from app.services.system_permissions import create_role + + with self.assertRaises(HTTPException): + create_role( + self.db, + RoleCreate( + role_code="INVALID_PERMISSION_ROLE", + role_name="无效权限角色", + role_scope="SYSTEM", + status="ACTIVE", + remark=None, + permission_codes=["MISSING_PERMISSION"], + ), + ) + self.db.commit() + + row = self.db.scalar(select(Role).where(Role.role_code == "INVALID_PERMISSION_ROLE")) + self.assertIsNone(row) + + def test_update_role_invalid_permission_does_not_persist_name_change(self) -> None: + from fastapi import HTTPException + from app.schemas.system_permissions import RoleUpdate + from app.services.system_permissions import update_role + + self._seed_role_permission_user_data() + + with self.assertRaises(HTTPException): + update_role( + self.db, + self.purchaser_role_id, + RoleUpdate( + role_name="错误改名", + role_scope="SYSTEM", + status="ACTIVE", + remark="不应持久化", + permission_codes=["MISSING_PERMISSION"], + ), + ) + self.db.commit() + + row = self.db.get(Role, self.purchaser_role_id) + self.assertIsNotNone(row) + self.assertEqual(row.role_name, "采购专员") + self.assertIsNone(row.remark) + + def test_create_system_user_invalid_role_does_not_leave_pending_user(self) -> None: + from fastapi import HTTPException + from app.schemas.system_permissions import SystemUserCreate + from app.services.system_permissions import create_system_user + + self._seed_role_permission_user_data() + + with self.assertRaises(HTTPException): + create_system_user( + self.db, + SystemUserCreate( + employee_id=self.buyer_employee_id, + role_ids=[999999], + password="secret123", + status="ACTIVE", + ), + ) + self.db.commit() + + employee = self.db.get(Employee, self.buyer_employee_id) + self.assertIsNotNone(employee) + row = self.db.scalar( + select(User).where( + User.employee_id == self.buyer_employee_id, + User.username == employee.mobile, + ) + ) + self.assertIsNone(row) + + def test_create_org_node_adds_child_under_parent(self) -> None: + from app.schemas.system_permissions import OrgNodeCreate + from app.services.system_permissions import create_org_node, build_org_tree + + payload = OrgNodeCreate( + parent_id=self.company.id, + node_type="BRANCH", + node_label="分公司A", + dept_code="BR-A", + dept_type="ADMIN", + manager_employee_id=None, + sort_no=1, + remark="分公司节点", + status="ACTIVE", + ) + create_org_node(self.db, payload) + tree = build_org_tree(self.db) + self.assertEqual(tree[0].children[0].node_label, "分公司A") + self.assertEqual(tree[0].children[0].parent_id, self.company.id) + self.assertEqual(tree[0].children[0].node_type, "BRANCH") + + def test_create_org_node_rejects_invalid_child_level(self) -> None: + from fastapi import HTTPException + from app.schemas.system_permissions import OrgNodeCreate + from app.services.system_permissions import create_org_node + + with self.assertRaises(HTTPException) as ctx: + create_org_node( + self.db, + OrgNodeCreate( + parent_id=self.company.id, + node_type="DEPARTMENT", + node_label="错误部门", + dept_code="BAD-DEPT", + dept_type="ADMIN", + manager_employee_id=None, + sort_no=1, + remark=None, + status="ACTIVE", + ), + ) + self.assertEqual(ctx.exception.status_code, 400) + self.assertIn("总公司下只能新增分公司", str(ctx.exception.detail)) + + def test_delete_org_node_rejects_nodes_with_children_or_bound_employees(self) -> None: + from fastapi import HTTPException + from app.schemas.system_permissions import OrgEmployeeBind, OrgNodeCreate + from app.services.system_permissions import bind_org_employee, create_org_node, delete_org_node + + self._seed_role_permission_user_data() + branch = create_org_node( + self.db, + OrgNodeCreate( + parent_id=self.company.id, + node_type="BRANCH", + node_label="分公司A", + dept_code="BR-A", + dept_type="ADMIN", + manager_employee_id=None, + sort_no=1, + remark=None, + status="ACTIVE", + ), + ) + + with self.assertRaises(HTTPException) as child_ctx: + delete_org_node(self.db, self.company.id) + self.assertEqual(child_ctx.exception.status_code, 400) + self.assertIn("存在下级节点", str(child_ctx.exception.detail)) + + department = create_org_node( + self.db, + OrgNodeCreate( + parent_id=branch.node_id, + node_type="DEPARTMENT", + node_label="业务部门", + dept_code="DEPT-A", + dept_type="ADMIN", + manager_employee_id=None, + sort_no=1, + remark=None, + status="ACTIVE", + ), + ) + bind_org_employee( + self.db, + department.node_id, + OrgEmployeeBind(employee_id=self.buyer_employee_id, sort_no=1), + ) + with self.assertRaises(HTTPException) as employee_ctx: + delete_org_node(self.db, department.node_id) + self.assertEqual(employee_ctx.exception.status_code, 400) + self.assertIn("存在人员节点", str(employee_ctx.exception.detail)) + + def test_admin_context_gets_system_permission_code(self) -> None: + from app.services.auth import get_auth_context, hash_password + + self._seed_role_permission_user_data() + now = datetime(2026, 6, 8, 9, 0, 0) + admin_role = Role( + role_code="ADMIN", + role_name="超级管理员", + role_scope="SYSTEM", + status="ACTIVE", + remark=None, + created_at=now, + updated_at=now, + ) + admin_permission = Permission( + permission_code="MENU_SYSTEM_PERMISSION", + permission_name="系统权限管理菜单", + module_code="SYSTEM_PERMISSION", + action_code="VIEW", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + admin_employee = Employee( + employee_code="EMP-ADMIN", + employee_name="系统管理员", + dept_id=self.purchase_dept_id, + mobile="13800000099", + gender=None, + hire_date=None, + job_title=None, + shift_code=None, + manager_employee_id=None, + is_operator=0, + is_workshop_staff=0, + status="ACTIVE", + remark=None, + created_at=now, + updated_at=now, + ) + self.db.add_all([admin_role, admin_permission, admin_employee]) + self.db.flush() + admin_user = User( + username="admin", + password_hash=hash_password("secret123"), + employee_id=admin_employee.id, + dept_id=self.purchase_dept_id, + nickname="系统管理员", + email=None, + is_super_admin=1, + last_login_at=None, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add(admin_user) + self.db.flush() + self.db.add(UserRole(user_id=admin_user.id, role_id=admin_role.id, created_at=now)) + self.db.commit() + + context = get_auth_context(self.db, "admin") + self.assertIn("MENU_SYSTEM_PERMISSION", context.permission_codes) + + def _seed_permission_employee_selector_data(self) -> dict[str, int]: + from app.services.auth import hash_password + + self._seed_role_permission_user_data() + now = datetime(2026, 6, 8, 9, 0, 0) + sales_permission = Permission( + permission_code="MENU_SALES_PLANNING", + permission_name="客户名录/销售订单菜单", + module_code="SALES_PLANNING", + action_code="VIEW", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + sales_role = Role( + role_code="SALES", + role_name="销售人员", + role_scope="SYSTEM", + status="ACTIVE", + remark=None, + created_at=now, + updated_at=now, + ) + sales_employee = Employee( + employee_code="EMP-SALES", + employee_name="赵销售", + dept_id=self.purchase_dept_id, + mobile="13800000021", + gender=None, + hire_date=None, + job_title="销售人员", + shift_code=None, + manager_employee_id=None, + is_operator=0, + is_workshop_staff=0, + status="ACTIVE", + remark=None, + created_at=now, + updated_at=now, + ) + inactive_employee = Employee( + employee_code="EMP-INACTIVE", + employee_name="停用员工", + dept_id=self.purchase_dept_id, + mobile="13800000022", + gender=None, + hire_date=None, + job_title="停用", + shift_code=None, + manager_employee_id=None, + is_operator=0, + is_workshop_staff=0, + status="INACTIVE", + remark=None, + created_at=now, + updated_at=now, + ) + self.db.add_all([sales_permission, sales_role, sales_employee, inactive_employee]) + self.db.flush() + self.db.add_all( + [ + RolePermission(role_id=self.purchaser_role_id, permission_id=self.purchase_permission_id, created_at=now), + RolePermission(role_id=sales_role.id, permission_id=sales_permission.id, created_at=now), + User( + username="13800000012", + password_hash=hash_password("secret123"), + employee_id=self.buyer_employee_id, + dept_id=self.purchase_dept_id, + nickname="李四", + email=None, + is_super_admin=0, + last_login_at=None, + status="ACTIVE", + created_at=now, + updated_at=now, + ), + User( + username="13800000021", + password_hash=hash_password("secret123"), + employee_id=sales_employee.id, + dept_id=self.purchase_dept_id, + nickname="赵销售", + email=None, + is_super_admin=0, + last_login_at=None, + status="ACTIVE", + created_at=now, + updated_at=now, + ), + User( + username="13800000022", + password_hash=hash_password("secret123"), + employee_id=inactive_employee.id, + dept_id=self.purchase_dept_id, + nickname="停用员工", + email=None, + is_super_admin=0, + last_login_at=None, + status="ACTIVE", + created_at=now, + updated_at=now, + ), + ] + ) + self.db.flush() + buyer_user = self.db.scalar(select(User).where(User.employee_id == self.buyer_employee_id)) + sales_user = self.db.scalar(select(User).where(User.employee_id == sales_employee.id)) + inactive_user = self.db.scalar(select(User).where(User.employee_id == inactive_employee.id)) + self.db.add_all( + [ + UserRole(user_id=buyer_user.id, role_id=self.purchaser_role_id, created_at=now), + UserRole(user_id=sales_user.id, role_id=sales_role.id, created_at=now), + UserRole(user_id=inactive_user.id, role_id=sales_role.id, created_at=now), + ] + ) + self.db.commit() + return { + "sales_employee_id": sales_employee.id, + "inactive_employee_id": inactive_employee.id, + } + + def test_list_employee_options_filters_by_permission_code(self) -> None: + from app.services.system_permissions import list_employee_options + + ids = self._seed_permission_employee_selector_data() + + purchase_options = list_employee_options(self.db, permission_code="MENU_PURCHASE_ORDER") + sales_options = list_employee_options(self.db, permission_code="MENU_SALES_PLANNING") + full_options = list_employee_options(self.db) + + self.assertEqual([option.employee_name for option in purchase_options], ["李四"]) + self.assertEqual([option.employee_name for option in sales_options], ["赵销售"]) + self.assertIn("销售人员", sales_options[0].role_names) + self.assertEqual(sales_options[0].username, "13800000021") + self.assertNotIn(ids["inactive_employee_id"], [option.employee_id for option in full_options]) + + def test_ensure_employee_has_permission_rejects_unqualified_employee(self) -> None: + from fastapi import HTTPException + from app.services.system_permissions import ensure_employee_has_permission + + ids = self._seed_permission_employee_selector_data() + + ensure_employee_has_permission( + self.db, + ids["sales_employee_id"], + "MENU_SALES_PLANNING", + "销售人员", + ) + with self.assertRaises(HTTPException) as ctx: + ensure_employee_has_permission( + self.db, + self.buyer_employee_id, + "MENU_SALES_PLANNING", + "销售人员", + ) + self.assertEqual(ctx.exception.status_code, 400) + self.assertIn("销售人员必须选择有“客户名录/销售订单菜单”权限的人员", str(ctx.exception.detail)) + + def test_optional_personnel_validator_allows_empty_value(self) -> None: + from app.services.system_permissions import ensure_employee_has_permission + + self._seed_permission_employee_selector_data() + + ensure_employee_has_permission( + self.db, + None, + "MENU_PURCHASE_ORDER", + "采购员", + required=False, + ) + + def test_optional_personnel_validator_rejects_zero_value(self) -> None: + from fastapi import HTTPException + from app.services.system_permissions import ensure_employee_has_permission + + self._seed_permission_employee_selector_data() + + with self.assertRaises(HTTPException) as ctx: + ensure_employee_has_permission( + self.db, + 0, + "MENU_PURCHASE_ORDER", + "采购员", + required=False, + ) + self.assertEqual(ctx.exception.status_code, 400) + self.assertIn("采购员不存在或已停用", str(ctx.exception.detail)) + + def test_login_session_exposes_employee_id_for_default_selectors(self) -> None: + from app.services.auth import get_auth_context, hash_password + + self._seed_permission_employee_selector_data() + context = get_auth_context(self.db, "13800000012") + + self.assertEqual(context.employee.id, self.buyer_employee_id) + self.assertEqual(context.employee.mobile, "13800000012") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_warehouse_operation_document_archive.py b/backend/tests/test_warehouse_operation_document_archive.py new file mode 100644 index 0000000..441f4ae --- /dev/null +++ b/backend/tests/test_warehouse_operation_document_archive.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +import unittest +from datetime import datetime +from decimal import Decimal +from tempfile import TemporaryDirectory +from types import SimpleNamespace +from unittest.mock import patch + +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + + +@compiles(BigInteger, "sqlite") +def _compile_big_integer_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.document_archive # noqa: E402,F401 +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.document_archive import DocumentArchive # noqa: E402 +from app.models.master_data import Item, StockBalance, Warehouse # noqa: E402 +from app.models.operations import InventoryTxn, StockLot, WarehouseLocation # noqa: E402 +from app.api.routes.inventory import create_warehouse_inbound, create_warehouse_outbound # noqa: E402 +from app.schemas.operations import WarehouseInboundCreate, WarehouseOutboundCreate # noqa: E402 +from app.services.document_archives import ( # noqa: E402 + ARCHIVE_STATUS_READY, + DOCUMENT_TYPE_WAREHOUSE_OPERATION, + collect_archive_context, + collect_warehouse_operation_archive_context, + generate_document_archive, +) + + +class WarehouseOperationDocumentArchiveTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + future=True, + ) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self.now = datetime(2026, 6, 11, 9, 0, 0) + + def tearDown(self) -> None: + self.db.close() + + def _seed_customer_supplied_txn(self) -> InventoryTxn: + warehouse = Warehouse( + id=1, + warehouse_code="RAW", + warehouse_name="原材料库", + warehouse_type="RAW", + status="ACTIVE", + created_at=self.now, + updated_at=self.now, + ) + location = WarehouseLocation( + id=2, + warehouse_id=warehouse.id, + location_code="RAW-A", + location_name="原材料暂存位", + status="ACTIVE", + created_at=self.now, + updated_at=self.now, + ) + item = Item( + id=3, + item_code="RM00001", + item_name="冷轧钢板", + item_type="RAW_MATERIAL", + specification="1.2mm", + unit_weight_kg=Decimal("1"), + status="ACTIVE", + created_at=self.now, + updated_at=self.now, + ) + source_lot = StockLot( + id=4, + lot_no="SRC-YL0001", + lot_role="RAW_MATERIAL", + item_id=item.id, + warehouse_id=warehouse.id, + source_doc_type="期初入库", + source_doc_id=1, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("100"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("100"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("0"), + quality_status="PASS", + status="AVAILABLE", + created_at=self.now, + updated_at=self.now, + ) + lot = StockLot( + id=5, + lot_no="YL0001", + lot_role="RAW_MATERIAL", + item_id=item.id, + warehouse_id=warehouse.id, + location_id=location.id, + source_doc_type="客料入库", + source_doc_id=1, + source_material_lot_id=source_lot.id, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("100"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("100"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("0"), + quality_status="PASS", + status="AVAILABLE", + created_at=self.now, + updated_at=self.now, + ) + txn = InventoryTxn( + id=6, + txn_no="TXN-CUSTOMER-001", + txn_type="CUSTOMER_SUPPLIED_IN", + item_id=item.id, + warehouse_id=warehouse.id, + location_id=location.id, + lot_id=lot.id, + qty_change=Decimal("0"), + weight_change_kg=Decimal("100"), + unit_cost=Decimal("0"), + amount=Decimal("0"), + source_doc_type="客料入库", + source_doc_id=1, + logistics_waybill_no="SF123", + logistics_freight_amount=Decimal("12.5"), + logistics_photo_url="/uploads/demo.jpg", + biz_time=self.now, + remark="客户来料测试", + created_at=self.now, + updated_at=self.now, + ) + balance = StockBalance( + id=7, + item_id=item.id, + warehouse_id=warehouse.id, + location_id=location.id, + qty_on_hand=Decimal("0"), + weight_on_hand_kg=Decimal("100"), + qty_available=Decimal("0"), + weight_available_kg=Decimal("100"), + qty_allocated=Decimal("0"), + weight_allocated_kg=Decimal("0"), + avg_unit_cost=Decimal("0"), + updated_at=self.now, + ) + self.db.add_all([warehouse, location, item, source_lot, lot, txn, balance]) + self.db.commit() + return txn + + def test_collect_warehouse_operation_archive_context_uses_inventory_txn(self) -> None: + txn = self._seed_customer_supplied_txn() + + context = collect_warehouse_operation_archive_context(self.db, txn.id) + + self.assertEqual(context.document_type, DOCUMENT_TYPE_WAREHOUSE_OPERATION) + self.assertEqual(context.business_id, txn.id) + self.assertEqual(context.title, "客料入库单归档") + self.assertEqual(context.document_no, "客料入库-TXN-CUSTOMER-001") + self.assertEqual(context.partner_label, "仓库") + self.assertEqual(context.partner_name, "原材料库") + self.assertEqual(context.status, "客料入库") + self.assertEqual(context.address, "原材料库 / 原材料暂存位") + self.assertEqual(context.total_amount, Decimal("0")) + self.assertEqual(len(context.lines), 1) + line = context.lines[0] + self.assertEqual(line.item_code, "RM00001") + self.assertEqual(line.item_name, "冷轧钢板") + self.assertEqual(line.quantity, Decimal("100.000000")) + self.assertEqual(line.delivered_or_received_quantity, Decimal("100.000000")) + self.assertIn("库存流水号:TXN-CUSTOMER-001", line.remark or "") + self.assertIn("库存批次号:YL0001", line.remark or "") + self.assertIn("来源库存批次号:SRC-YL0001", line.remark or "") + self.assertIn("库位:原材料暂存位", line.remark or "") + self.assertIn("运单号:SF123", line.remark or "") + self.assertIn("运费:12.5", line.remark or "") + self.assertIn("辅助照片:已上传", line.remark or "") + self.assertIn("客户来料测试", line.remark or "") + + def test_collect_archive_context_dispatches_warehouse_operation(self) -> None: + txn = self._seed_customer_supplied_txn() + + context = collect_archive_context(self.db, DOCUMENT_TYPE_WAREHOUSE_OPERATION, txn.id) + + self.assertEqual(context.document_type, DOCUMENT_TYPE_WAREHOUSE_OPERATION) + self.assertEqual(context.business_id, txn.id) + + def test_unknown_english_txn_type_is_not_exposed_in_context(self) -> None: + txn = self._seed_customer_supplied_txn() + txn.txn_type = "unknown_code" + self.db.commit() + + context = collect_warehouse_operation_archive_context(self.db, txn.id) + + self.assertEqual(context.status, "未识别仓库操作") + self.assertEqual(context.title, "未识别仓库操作单归档") + self.assertEqual(context.document_no, "未识别仓库操作-TXN-CUSTOMER-001") + self.assertNotIn("unknown_code", context.remark) + self.assertNotIn("unknown_code", context.lines[0].remark or "") + + def test_generate_document_archive_creates_pdf_for_warehouse_operation(self) -> None: + txn = self._seed_customer_supplied_txn() + + with TemporaryDirectory() as tmp_dir, patch("app.services.document_archives.default_archive_root", return_value=tmp_dir): + result = generate_document_archive(self.db, DOCUMENT_TYPE_WAREHOUSE_OPERATION, txn.id) + + archive = self.db.scalar( + select(DocumentArchive).where( + DocumentArchive.document_type == DOCUMENT_TYPE_WAREHOUSE_OPERATION, + DocumentArchive.business_id == txn.id, + ) + ) + self.assertIsNotNone(archive) + self.assertEqual(result.archive_status, ARCHIVE_STATUS_READY) + self.assertEqual(archive.status, ARCHIVE_STATUS_READY) + self.assertTrue(archive.file_name.endswith(".pdf")) + with open(archive.file_path, "rb") as pdf_file: + self.assertEqual(pdf_file.read(4), b"%PDF") + + def test_customer_supplied_inbound_save_generates_warehouse_archive(self) -> None: + self._seed_customer_supplied_txn() + payload = WarehouseInboundCreate( + biz_type="CUSTOMER_SUPPLIED", + item_id=3, + warehouse_id=1, + location_id=2, + inbound_weight_kg=25, + inbound_qty=0, + unit_cost=0, + provider_name="测试客户", + waybill_no="SF999", + freight_amount=Decimal("8.5"), + remark="保存时生成仓库出入库单", + ) + context = SimpleNamespace(user=SimpleNamespace(id=99)) + + with TemporaryDirectory() as tmp_dir, patch("app.services.document_archives.default_archive_root", return_value=tmp_dir): + result = create_warehouse_inbound(payload=payload, context=context, db=self.db) + + self.assertEqual(result.archive_status, ARCHIVE_STATUS_READY) + self.assertEqual(result.archive_document_type, DOCUMENT_TYPE_WAREHOUSE_OPERATION) + self.assertIsNotNone(result.archive_business_id) + archive = self.db.scalar( + select(DocumentArchive).where( + DocumentArchive.document_type == DOCUMENT_TYPE_WAREHOUSE_OPERATION, + DocumentArchive.business_id == result.archive_business_id, + ) + ) + self.assertIsNotNone(archive) + self.assertTrue(archive.file_name.endswith(".pdf")) + + def test_outsourcing_outbound_save_generates_warehouse_archive(self) -> None: + self._seed_customer_supplied_txn() + payload = WarehouseOutboundCreate( + biz_type="OUTSOURCING_OUT", + item_id=3, + warehouse_id=1, + location_id=2, + outbound_weight_kg=10, + outsourcing_party_name="测试委外厂", + waybill_no="SF-WW-001", + freight_amount=Decimal("9.9"), + remark="委外出库保存归档", + ) + context = SimpleNamespace(user=SimpleNamespace(id=99)) + + with TemporaryDirectory() as tmp_dir, patch("app.services.document_archives.default_archive_root", return_value=tmp_dir): + result = create_warehouse_outbound(payload=payload, context=context, db=self.db) + + self.assertEqual(result.archive_status, ARCHIVE_STATUS_READY) + self.assertEqual(result.archive_document_type, DOCUMENT_TYPE_WAREHOUSE_OPERATION) + self.assertIsNotNone(result.archive_business_id) + archive = self.db.scalar( + select(DocumentArchive).where( + DocumentArchive.document_type == DOCUMENT_TYPE_WAREHOUSE_OPERATION, + DocumentArchive.business_id == result.archive_business_id, + ) + ) + self.assertIsNotNone(archive) + self.assertTrue(archive.file_name.endswith(".pdf")) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_warehouse_transaction_ledger.py b/backend/tests/test_warehouse_transaction_ledger.py new file mode 100644 index 0000000..dd6bf0d --- /dev/null +++ b/backend/tests/test_warehouse_transaction_ledger.py @@ -0,0 +1,311 @@ +from __future__ import annotations + +import unittest +from datetime import UTC, datetime, timedelta +from decimal import Decimal + +from sqlalchemy import BigInteger, create_engine +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +from app.models.base import Base # noqa: E402 + + +class WarehouseTransactionLedgerTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + + def tearDown(self) -> None: + self.db.close() + + def seed_ledger_data(self) -> None: + from app.models.master_data import Item, Warehouse + from app.models.operations import InventoryTxn, StockLot, WarehouseLocation + from app.models.org import Department, Employee, User + + now = datetime(2026, 6, 1, 9, 0, tzinfo=UTC) + department = Department( + id=1, + dept_code="D001", + dept_name="仓储部", + dept_type="WAREHOUSE", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + employee = Employee( + id=1, + employee_code="EMP001", + employee_name="张仓管", + dept_id=1, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + user = User( + id=1, + username="keeper", + password_hash="x", + employee_id=1, + dept_id=1, + nickname="张仓管", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + raw_wh = Warehouse( + id=1, + warehouse_code="WH-RAW-01", + warehouse_name="原材料库", + warehouse_type="RAW", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + finished_wh = Warehouse( + id=2, + warehouse_code="WH-FG-01", + warehouse_name="成品库", + warehouse_type="FINISHED", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + raw_location = WarehouseLocation( + id=1, + warehouse_id=1, + location_code="RAW-A", + location_name="原料主库位", + is_default=1, + is_locked=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + finished_location = WarehouseLocation( + id=2, + warehouse_id=2, + location_code="FG-A", + location_name="成品主库位", + is_default=1, + is_locked=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + raw_item = Item( + id=1, + item_code="原材料00001", + item_name="304不锈钢", + item_type="RAW_MATERIAL", + unit_weight_kg=Decimal("1"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + finished_item = Item( + id=2, + item_code="产品需规00001", + item_name="冲压支架", + item_type="FINISHED_GOOD", + unit_weight_kg=Decimal("2.5"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + raw_lot = StockLot( + id=1, + lot_no="JH_原材料00001_0001", + lot_role="INBOUND_RAW", + item_id=1, + warehouse_id=1, + location_id=1, + source_doc_type="OPENING_STOCK", + source_doc_id=1, + inbound_qty=0, + inbound_weight_kg=1000, + remaining_qty=0, + remaining_weight_kg=850, + locked_qty=0, + locked_weight_kg=0, + unit_cost=Decimal("10"), + quality_status="PASS", + status="AVAILABLE", + created_at=now, + updated_at=now, + ) + finished_lot = StockLot( + id=2, + lot_no="FG-20260601-001", + lot_role="INBOUND_FINISHED", + item_id=2, + warehouse_id=2, + location_id=2, + source_doc_type="FG_RECEIPT", + source_doc_id=9, + inbound_qty=100, + inbound_weight_kg=250, + remaining_qty=80, + remaining_weight_kg=200, + locked_qty=0, + locked_weight_kg=0, + unit_cost=Decimal("18"), + quality_status="PASS", + status="AVAILABLE", + created_at=now, + updated_at=now, + ) + transactions = [ + InventoryTxn( + id=1, + txn_no="TXN-RAW-IN", + txn_type="OPENING_IN", + item_id=1, + warehouse_id=1, + location_id=1, + lot_id=1, + qty_change=0, + weight_change_kg=1000, + unit_cost=10, + amount=10000, + source_doc_type="OPENING_STOCK", + source_doc_id=1, + biz_time=now, + operator_user_id=1, + remark="期初入库 304", + created_at=now, + updated_at=now, + ), + InventoryTxn( + id=2, + txn_no="TXN-RAW-OUT", + txn_type="MATERIAL_ISSUE", + item_id=1, + warehouse_id=1, + location_id=1, + lot_id=1, + qty_change=0, + weight_change_kg=-150, + unit_cost=10, + amount=1500, + source_doc_type="WORK_ORDER", + source_doc_id=2, + biz_time=now + timedelta(hours=1), + operator_user_id=1, + remark="生产出库", + created_at=now, + updated_at=now, + ), + InventoryTxn( + id=3, + txn_no="TXN-FG-IN", + txn_type="FG_IN", + item_id=2, + warehouse_id=2, + location_id=2, + lot_id=2, + qty_change=100, + weight_change_kg=250, + unit_cost=18, + amount=1800, + source_doc_type="FG_RECEIPT", + source_doc_id=9, + biz_time=now + timedelta(hours=2), + operator_user_id=1, + remark="成品入库", + created_at=now, + updated_at=now, + ), + ] + self.db.add_all( + [ + department, + employee, + user, + raw_wh, + finished_wh, + raw_location, + finished_location, + raw_item, + finished_item, + raw_lot, + finished_lot, + *transactions, + ] + ) + self.db.commit() + + def test_transaction_ledger_filters_by_warehouse_type_and_returns_summary(self) -> None: + from app.api.routes.inventory import list_inventory_transaction_ledger + + self.seed_ledger_data() + + result = list_inventory_transaction_ledger( + warehouse_type="RAW", + page=1, + page_size=10, + db=self.db, + ) + + self.assertEqual(result.total, 2) + self.assertEqual([row.warehouse_type for row in result.items], ["RAW", "RAW"]) + self.assertEqual(result.summary.in_count, 1) + self.assertEqual(result.summary.out_count, 1) + self.assertEqual(Decimal(str(result.summary.in_weight_kg)), Decimal("1000.0")) + self.assertEqual(Decimal(str(result.summary.out_weight_kg)), Decimal("150.0")) + self.assertEqual(result.items[0].txn_type, "MATERIAL_ISSUE") + self.assertEqual(result.items[0].direction, "OUT") + + def test_transaction_ledger_supports_keyword_direction_pagination_and_raw_qty_zero(self) -> None: + from app.api.routes.inventory import list_inventory_transaction_ledger + + self.seed_ledger_data() + + result = list_inventory_transaction_ledger( + warehouse_type="RAW", + keyword="304", + direction="OUT", + page=1, + page_size=1, + db=self.db, + ) + + self.assertEqual(result.total, 1) + self.assertEqual(len(result.items), 1) + row = result.items[0] + self.assertEqual(row.txn_no, "TXN-RAW-OUT") + self.assertEqual(row.item_code, "原材料00001") + self.assertEqual(row.lot_no, "JH_原材料00001_0001") + self.assertEqual(row.qty_change, 0) + self.assertEqual(row.operator_name, "张仓管") + self.assertEqual(result.summary.out_count, 1) + + def test_transaction_ledger_rejects_unknown_warehouse_type(self) -> None: + from app.api.routes.inventory import list_inventory_transaction_ledger + from fastapi import HTTPException + + self.seed_ledger_data() + + with self.assertRaises(HTTPException) as exc: + list_inventory_transaction_ledger( + warehouse_type="UNKNOWN", + page=1, + page_size=10, + db=self.db, + ) + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("未知仓库类型", exc.exception.detail) diff --git a/docs/auth-accounts.md b/docs/auth-accounts.md new file mode 100644 index 0000000..aaf60f2 --- /dev/null +++ b/docs/auth-accounts.md @@ -0,0 +1,44 @@ +# 真实账号与菜单权限 + +系统已经切换为数据库真实账号登录,账号来自以下表: + +- `hr_employee` +- `sys_user` +- `sys_role` +- `sys_user_role` +- `sys_permission` +- `sys_role_permission` + +## 初始账号 + +- `admin / admin123` +- `sales / sales123` +- `planner / planner123` +- `warehouse / warehouse123` +- `supervisor / supervisor123` + +以上密码已采用 PBKDF2-SHA256 存储到 `sys_user.password_hash`。 + +## 菜单权限 + +- `MENU_DASHBOARD`:经营驾驶舱 +- `MENU_SALES_PLANNING`:销售与计划中心 +- `MENU_PROCESS_CENTER`:流程与工序中心 +- `MENU_DATA_MODEL`:模块联动与数据设计 + +## 角色与菜单关系 + +- `ADMIN`:全部菜单 +- `SALES`:驾驶舱、销售与计划中心 +- `PLANNER`:驾驶舱、销售与计划中心、流程与工序中心 +- `WAREHOUSE`:驾驶舱、流程与工序中心 +- `SUPERVISOR`:驾驶舱、流程与工序中心 + +## 后续建议 + +如果你后面要进入正式使用阶段,建议继续做: + +1. 修改初始密码 +2. 增加修改密码接口 +3. 增加用户停用与重置密码功能 +4. 把页面按钮权限也接到 `sys_permission` diff --git a/docs/benti-master-data.md b/docs/benti-master-data.md new file mode 100644 index 0000000..68440dc --- /dev/null +++ b/docs/benti-master-data.md @@ -0,0 +1,94 @@ +# 本体样例主数据说明 + +本文件描述已为“本体”生成的样例主数据假设,目的是让 ERP 的销售、MRP、采购、仓储、工艺路线、设备管理和报工链路可以直接开始联调。 + +## 产品定义 + +- 产品编码:`FG-BT-001` +- 产品名称:`本体` +- 图号:`JH-BT-001` +- 版本:`V1.0` +- 产品系列:`结构件` +- 成品单位:`件` +- 单件成品重量:`5.2kg` +- 产品允许总报废率:`3.5%` + +## 原材料定义 + +- 原材料编码:`RM-SAP440-500600-2.5` +- 名称:`SAP440钢板` +- 规格:`500*600*2.5` +- 材质牌号:`SAP440` +- 密度:`7850kg/m3` +- 理论单张重量:`5.8875kg` +- 安全库存重量:`10000kg` +- 默认供应商:`宁波嘉钢材料有限公司` +- 采购方式:按重量采购 +- 最小采购重量:`2000kg` +- 采购倍数重量:`2000kg` + +## BOM 假设 + +- 单件净重:`5.2kg` +- 单件毛重:`5.9kg` +- 单件用料类型:按重量 +- 损耗率:`11.8%` +- 领料超发容差率:`2%` + +这一组参数用于支撑你前面提出的业务场景: + +- 订单 `10000件` +- 理论毛重需求约 `59000kg` +- 按 `2000kg` 采购倍数向上取整后建议采购 `60000kg` + +## 工艺路线 + +标准 6 道工序: + +1. 开平 +2. 冲压 +3. 去毛刺 +4. 折弯 +5. 焊接 +6. 检验包装 + +各工序已配置: + +- 标准准备时间 +- 标准节拍 +- 标准人工成本 +- 标准设备成本 +- 允许报废率 +- 是否关键工序 + +## 设备与工作中心 + +已同步生成: + +- 6 个工作中心 +- 6 台样例设备 +- 6 条周期保养计划 + +这样后续你接设备管理、报工设备选择、设备保养预警时可以直接复用。 + +## 仓储主数据 + +已生成 5 个仓库: + +- 原材料仓 +- 在制品暂存仓 +- 成品待发仓 +- 退货仓 +- 废料库 + +每个仓库已带一个默认库位,便于后续做入库、发料、退货和废料流转。 + +## 后续建议 + +下一步最适合继续补的数据: + +1. 员工与账号 +2. 一个客户 +3. 一张销售订单 +4. 自动生成一张本体的 MRP 需求 +5. 自动生成一张本体工单 diff --git a/docs/production-operation-case.md b/docs/production-operation-case.md new file mode 100644 index 0000000..a0da4bb --- /dev/null +++ b/docs/production-operation-case.md @@ -0,0 +1,78 @@ +# 嘉恒智能五金 ERP 生产使用案例 + +## 案例背景 + +- 客户:宁波华远汽车零部件有限公司 +- 产品:本体 +- 产品编码:`FG-BT-001` +- 订单数量:`10000` 件 +- 承诺交期:`2026-04-28` +- 原材料:`SAP440 500*600*2.5` 钢板 + +## 操作步骤 + +### 1. 管理员先完成基础主数据 + +- 进入 `模块联动与数据设计`,确认主数据范围与流程顺序。 +- 在后台维护或核对产品、原材料、BOM、工艺路线、仓库、部门、角色账号。 +- 确认产品 `FG-BT-001` 已维护单重、允许报废率、BOM 用料和采购倍数。 + +### 2. 销售录入客户 + +- 使用销售账号登录系统。 +- 进入 `销售与计划中心`。 +- 在 `客户管理` 区域录入客户名称、联系人、电话、地址和账期。 +- 点击 `新增客户`,系统生成客户编码并建立客户主档。 + +### 3. 销售录入订单 + +- 在同页 `销售订单` 区域选择刚创建的客户。 +- 填写承诺交期、送货地址、税率和备注。 +- 在订单明细中选择产品 `FG-BT-001`。 +- 录入数量 `10000`、单价和客户料号。 +- 点击 `创建销售订单`,系统生成销售订单号。 + +### 4. 计划员生成 MRP + +- 在下方 `销售订单列表` 找到刚生成的订单。 +- 点击 `生成 MRP`。 +- 系统按 BOM、材料毛重、当前库存和采购倍数自动生成物料需求。 +- 在 `MRP 需求结果` 中查看理论重量、缺口重量和建议采购重量。 + +### 5. 采购按 MRP 执行下单 + +- 根据 MRP 结果确认材料缺口。 +- 按建议采购重量发起采购,例如系统建议采购 `60000 kg`,则按采购倍数下单。 +- 到货后由仓库按批次入库,形成库存余额。 + +### 6. 车间按工序组织生产 + +- 计划员释放工单后,仓库按工单领料。 +- 工人通过小程序扫码报工,回传工单号、工序、员工、设备、开工时间、完工时间、报工数量、合格数、报废数等字段。 +- 管理员在 `流程与工序中心` 查看产品主档、MRP 需求和报工字段约束。 + +### 7. 成品入库与发货 + +- 工序全部完成并检验合格后,成品入库。 +- 仓库按销售订单组织发货。 +- 财务按订单、材料、工序、制造费用进行对账和成本核算。 + +### 8. 异常与退货处理 + +- 若现场出现异常报废,按工序、设备、员工追溯原因。 +- 若客户退货,按实际情况选择返工或报废处理。 +- 返工继续计入成本,报废则记录废料收益与损失。 + +## 推荐角色分工 + +- 管理员:维护主数据、账号权限、异常监控 +- 销售:客户建档、销售订单录入、交期跟进 +- 计划员:MRP 生成、工单释放、排产协调 +- 仓库:收料、入库、领料、成品发货 +- 车间主管:工序执行、报工审核、异常处置 + +## 上线建议 + +- 先跑通 `客户 → 订单 → MRP → 采购 → 入库`。 +- 再接入 `工单 → 报工 → 成品入库 → 发货`。 +- 最后完善 `财务核算、返工、退货、设备管理`。 diff --git a/docs/superpowers/plans/2026-05-30-single-warehouse-stocktake-flow.md b/docs/superpowers/plans/2026-05-30-single-warehouse-stocktake-flow.md new file mode 100644 index 0000000..b4c735a --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-single-warehouse-stocktake-flow.md @@ -0,0 +1,1098 @@ +# Single Warehouse Stocktake Flow Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Change 嘉恒仓库盘库 into a current-selected-warehouse-only flow: select a warehouse bar first, click 盘库, confirm/export one locked warehouse sheet, import only that same stocktake workbook, show a modal diff immediately, and confirm to reset that warehouse inventory and unlock it. + +**Architecture:** Backend becomes the source of truth for single-warehouse stocktake rules, workbook validation token checks, import overwrite behavior, and current-warehouse history filtering. Frontend removes multi-warehouse selection from the stocktake dialog and renders two blocks: a stocktake flow block for the currently selected warehouse and a stocktake records block filtered to that warehouse. The diff review is opened as a modal immediately after each import; cancel closes the modal and leaves the warehouse locked for re-import. + +**Tech Stack:** FastAPI, SQLAlchemy ORM, openpyxl, Python unittest, Vue 3 Composition API, Node built-in `assert`, Vite. + +--- + +## Scope Rules + +- Keep existing stocktake tables: `wh_stocktake`, `wh_stocktake_warehouse`, `wh_stocktake_line`, and `wh_stocktake_adjustment`. +- Do not add a migration unless tests prove existing columns cannot represent the requested flow. +- Backend must reject multi-warehouse stocktake creation even if a caller bypasses the UI. +- Backend must reject import files that are not for the current stocktake number and current warehouse. +- Re-importing a corrected workbook must replace the previous imported diff preview for that same stocktake before confirmation. +- Frontend must not let the user choose warehouses inside the stocktake dialog. The active warehouse comes from the warehouse bar selection. +- The workspace root `/Users/souplearn/Gitlab/py/ForgeFlow-ERP` is not a git repository in this environment, so plan steps do not include commits. + +## File Structure + +- Modify: `backend/app/services/stocktake.py` + - Enforce one warehouse per stocktake. + - Build and validate workbook check tokens. + - Export one warehouse workbook with a validation marker. + - Import only matching current-stocktake/current-warehouse workbooks. + - Reset previous imported preview rows before re-import. + +- Modify: `backend/app/api/routes/inventory.py` + - Filter stocktake history by current warehouse with `warehouse_id`. + - Keep import/export route shapes stable. + +- Modify: `backend/app/schemas/operations.py` + - Tighten `StocktakeStartCreate` to one warehouse in request validation if supported by current Pydantic. + +- Modify: `backend/tests/test_stocktake_excel_diff_confirm.py` + - Add backend regression tests for single-warehouse start, validation marker export, wrong stocktake import rejection, wrong warehouse import rejection, and re-import overwrite. + +- Modify: `frontend/src/components/StocktakeDialog.vue` + - Replace multi-warehouse overview cards with current warehouse flow + current warehouse records. + - Start and export in one user action. + - Open diff as modal immediately after import. + - Cancel diff modal without unlocking; allow re-import. + +- Modify: `frontend/src/utils/stocktakeWorkflow.js` + - Add current-warehouse-only helpers for active stocktake lookup and panel routing. + +- Modify: `frontend/scripts/test-stocktake-workflow.mjs` + - Update workflow tests to cover current-selected-warehouse behavior and no global fallback. + +- Modify: `frontend/src/styles/main.css` + - Style the two-block stocktake dialog, current warehouse badge, and diff modal. + +--- + +## Task 1: Backend Failing Tests for Single-Warehouse Rules + +**Files:** +- Modify: `backend/tests/test_stocktake_excel_diff_confirm.py` + +- [ ] **Step 1: Add imports used by new tests** + +Ensure this import line includes `Workbook`: + +```python +from openpyxl import Workbook, load_workbook +``` + +If the file currently imports `Workbook` inside a test method, leave local imports alone unless duplicate imports become confusing. + +- [ ] **Step 2: Add a test that multi-warehouse stocktake creation is rejected** + +Append this method to `StocktakeExcelDiffConfirmTest`: + +```python + def test_start_stocktake_rejects_multiple_warehouses_for_current_bar_flow(self) -> None: + from fastapi import HTTPException + + now = datetime.now(UTC) + aux = Warehouse( + id=2, + warehouse_code="WH-AUX-01", + warehouse_name="辅料库", + warehouse_type="AUX", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add(aux) + self.db.commit() + + with self.assertRaises(HTTPException) as exc: + start_stocktake(self.db, warehouse_ids=[self.raw.id, aux.id], user_id=1, remark="多库盘库") + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("一次只能盘一个仓库", exc.exception.detail) +``` + +- [ ] **Step 3: Add a test that export includes a validation marker and one warehouse sheet** + +Append this method: + +```python + def test_single_warehouse_export_contains_validation_marker(self) -> None: + stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="原材料盘库") + workbook = build_stocktake_workbook(self.db, stocktake.id) + output = BytesIO() + workbook.save(output) + loaded = load_workbook(BytesIO(output.getvalue()), read_only=True, data_only=True) + + self.assertEqual(loaded.sheetnames, ["原材料库"]) + rows = list(loaded["原材料库"].iter_rows(values_only=True)) + headers = rows[0] + + self.assertIn("盘库校验码", headers) + self.assertIn("仓库ID", headers) + self.assertEqual(rows[1][headers.index("盘库单号")], stocktake.stocktake_no) + self.assertEqual(rows[1][headers.index("仓库ID")], self.raw.id) + self.assertEqual( + rows[1][headers.index("盘库校验码")], + f"{stocktake.stocktake_no}|{self.raw.id}|RAW", + ) +``` + +- [ ] **Step 4: Add a helper for workbook mutation in tests** + +Append this private helper method to the test class: + +```python + def _exported_workbook_bytes(self, stocktake) -> bytes: + workbook = build_stocktake_workbook(self.db, stocktake.id) + output = BytesIO() + workbook.save(output) + return output.getvalue() +``` + +- [ ] **Step 5: Add a test that wrong stocktake number is rejected** + +Append this method: + +```python + def test_import_rejects_workbook_from_other_stocktake_number(self) -> None: + from app.services.stocktake import import_stocktake_workbook + + stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="原材料盘库") + workbook = load_workbook(BytesIO(self._exported_workbook_bytes(stocktake))) + worksheet = workbook["原材料库"] + headers = [cell.value for cell in worksheet[1]] + worksheet.cell(row=2, column=headers.index("盘库单号") + 1).value = "PK-OTHER-001" + worksheet.cell(row=2, column=headers.index("盘库校验码") + 1).value = "PK-OTHER-001|1|RAW" + output = BytesIO() + workbook.save(output) + + with self.assertRaises(ValueError) as exc: + import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1) + + self.assertIn("不属于当前盘库单", str(exc.exception)) +``` + +- [ ] **Step 6: Add a test that wrong warehouse marker is rejected** + +Append this method: + +```python + def test_import_rejects_workbook_from_other_warehouse(self) -> None: + from app.services.stocktake import import_stocktake_workbook + + stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="原材料盘库") + workbook = load_workbook(BytesIO(self._exported_workbook_bytes(stocktake))) + worksheet = workbook["原材料库"] + headers = [cell.value for cell in worksheet[1]] + worksheet.cell(row=2, column=headers.index("仓库") + 1).value = "成品库" + worksheet.cell(row=2, column=headers.index("仓库ID") + 1).value = 999 + worksheet.cell(row=2, column=headers.index("盘库校验码") + 1).value = f"{stocktake.stocktake_no}|999|FINISHED" + output = BytesIO() + workbook.save(output) + + with self.assertRaises(ValueError) as exc: + import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1) + + self.assertIn("不属于当前仓库", str(exc.exception)) +``` + +- [ ] **Step 7: Add a test that re-import overwrites previous diff preview** + +Append this method: + +```python + def test_reimport_overwrites_previous_stocktake_preview(self) -> None: + from app.services.stocktake import import_stocktake_workbook + + stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="原材料盘库") + + workbook = load_workbook(BytesIO(self._exported_workbook_bytes(stocktake))) + worksheet = workbook["原材料库"] + headers = [cell.value for cell in worksheet[1]] + worksheet.cell(row=2, column=headers.index("盘点重量(kg)") + 1).value = 90 + output = BytesIO() + workbook.save(output) + first_preview = import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1) + self.assertEqual(first_preview["summary"]["loss_count"], 1) + + workbook = load_workbook(BytesIO(self._exported_workbook_bytes(stocktake))) + worksheet = workbook["原材料库"] + headers = [cell.value for cell in worksheet[1]] + worksheet.cell(row=2, column=headers.index("盘点重量(kg)") + 1).value = 100 + output = BytesIO() + workbook.save(output) + second_preview = import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1) + + self.assertEqual(second_preview["summary"]["loss_count"], 0) + self.assertEqual(second_preview["summary"]["match_count"], 1) + line = self.db.scalar(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake.id)) + self.assertEqual(line.diff_type, "MATCH") + self.assertEqual(float(line.counted_weight_kg), 100) +``` + +- [ ] **Step 8: Run tests and confirm they fail for missing implementation** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m unittest tests.test_stocktake_excel_diff_confirm +``` + +Expected: fail because current backend still allows multi-warehouse stocktakes and does not export/validate `盘库校验码` and `仓库ID`. + +--- + +## Task 2: Backend Single-Warehouse Service Implementation + +**Files:** +- Modify: `backend/app/services/stocktake.py` +- Modify: `backend/app/schemas/operations.py` + +- [ ] **Step 1: Add workbook validation headers** + +In `backend/app/services/stocktake.py`, extend `STOCKTAKE_EXCEL_HEADERS` so the first columns are: + +```python +STOCKTAKE_EXCEL_HEADERS = [ + "盘库单号", + "盘库校验码", + "仓库ID", + "快照行ID", + "仓库", + "库位", + "物料编码", + "物料名称", + "批次号", + "来源材料分批次号", + "系统数量", + "系统重量(kg)", + "系统单价", + "盘点数量", + "盘点重量(kg)", + "盘点备注", +] +``` + +- [ ] **Step 2: Add validation token helper** + +Add below `stocktake_warehouse_type_label`: + +```python +def stocktake_validation_token(stocktake_no: str, warehouse_id: int, warehouse_type: str | None) -> str: + return f"{stocktake_no}|{int(warehouse_id)}|{str(warehouse_type or '').upper()}" +``` + +- [ ] **Step 3: Enforce one warehouse in service** + +In `start_stocktake`, after `ids = ...`, add: + +```python + if len(set(ids)) != 1: + raise HTTPException(status_code=400, detail="一次只能盘一个仓库,请先在仓库栏选中单个仓库") +``` + +Keep the existing empty and invalid warehouse checks. + +- [ ] **Step 4: Tighten schema request validation** + +In `backend/app/schemas/operations.py`, change: + +```python +class StocktakeStartCreate(BaseModel): + warehouse_ids: list[int] = Field(min_length=1) + remark: str | None = None +``` + +to: + +```python +class StocktakeStartCreate(BaseModel): + warehouse_ids: list[int] = Field(min_length=1, max_length=1) + remark: str | None = None +``` + +- [ ] **Step 5: Update export rows to include token and warehouse ID** + +In `build_stocktake_workbook`, compute token per stocktake warehouse. Replace worksheet append rows so empty marker rows start with: + +```python +token = stocktake_validation_token(stocktake.stocktake_no, warehouse.id, warehouse.warehouse_type) +worksheet.append( + [ + stocktake.stocktake_no, + token, + warehouse.id, + "", + sheet_name, + "", + "", + "", + "", + "", + 0, + 0, + 0, + 0, + 0, + EMPTY_STOCKTAKE_MARKER_REMARK, + ] +) +``` + +For normal stocktake lines, row values must start with: + +```python +[ + stocktake.stocktake_no, + stocktake_validation_token(stocktake.stocktake_no, line.warehouse_id, line.warehouse_type), + line.warehouse_id, + line.id, + line.warehouse_name, + ... +] +``` + +Update column widths list to 16 columns: + +```python +[18, 30, 10, 12, 14, 14, 18, 28, 22, 24, 14, 16, 12, 14, 16, 24] +``` + +- [ ] **Step 6: Add helper to get the single stocktake warehouse** + +Add below `_stocktake_lines`: + +```python +def _single_stocktake_warehouse(db: Session, stocktake_id: int) -> StocktakeWarehouse: + rows = db.scalars( + select(StocktakeWarehouse).where(StocktakeWarehouse.stocktake_id == stocktake_id).order_by(StocktakeWarehouse.id.asc()) + ).all() + if len(rows) != 1: + raise HTTPException(status_code=400, detail="当前盘库单不是单仓库盘库,无法继续处理") + return rows[0] +``` + +- [ ] **Step 7: Update empty marker detection for shifted columns** + +No logic change is needed if `_is_empty_stocktake_marker_row` uses header labels. Verify it still checks `快照行ID`, material fields, and numeric columns by name. + +- [ ] **Step 8: Add workbook validation helper** + +Add below `_is_empty_stocktake_marker_row`: + +```python +def _validate_stocktake_row_marker(row: dict[str, object], stocktake: Stocktake, warehouse: StocktakeWarehouse) -> None: + row_stocktake_no = _clean_text(row.get("盘库单号")) + if row_stocktake_no != stocktake.stocktake_no: + raise ValueError(f"存在不属于当前盘库单的行:{row_stocktake_no or '空盘库单号'}") + + row_warehouse_id = _clean_text(row.get("仓库ID")) + if row_warehouse_id and int(float(row_warehouse_id)) != int(warehouse.warehouse_id): + raise ValueError("导入清单不属于当前仓库,请确认仓库栏选中项和盘库清单一致") + + row_warehouse_name = _clean_text(row.get("仓库")) + if row_warehouse_name and row_warehouse_name != warehouse.warehouse_name: + raise ValueError("导入清单不属于当前仓库,请确认仓库栏选中项和盘库清单一致") + + expected_token = stocktake_validation_token(stocktake.stocktake_no, warehouse.warehouse_id, warehouse.warehouse_type) + row_token = _clean_text(row.get("盘库校验码")) + if row_token != expected_token: + raise ValueError("盘库校验码不匹配,请导入本次盘库导出的仓库清单") +``` + +- [ ] **Step 9: Add re-import reset helper** + +Add below `_validate_stocktake_row_marker`: + +```python +def _reset_stocktake_preview_for_reimport(db: Session, stocktake_id: int, warehouse_id: int) -> dict[int, StocktakeLine]: + lines = db.scalars( + select(StocktakeLine) + .where(StocktakeLine.stocktake_id == stocktake_id, StocktakeLine.warehouse_id == warehouse_id) + .order_by(StocktakeLine.line_no.asc()) + ).all() + existing_lines: dict[int, StocktakeLine] = {} + for line in lines: + if line.lot_id is None and line.diff_type == "NEW_LOT": + db.delete(line) + continue + line.counted_qty = None + line.counted_weight_kg = None + line.diff_qty = Decimal("0") + line.diff_weight_kg = Decimal("0") + line.diff_amount = Decimal("0") + line.diff_type = "MATCH" + line.row_status = "SNAPSHOT" + line.error_message = None + line.remark = None + db.add(line) + existing_lines[line.id] = line + db.flush() + return existing_lines +``` + +- [ ] **Step 10: Rewrite import to validate current warehouse and overwrite previous preview** + +In `import_stocktake_workbook`, after stocktake status check, add: + +```python + stocktake_warehouse = _single_stocktake_warehouse(db, stocktake_id) +``` + +Then replace `existing_lines = {...}` with: + +```python + existing_lines = _reset_stocktake_preview_for_reimport(db, stocktake_id, stocktake_warehouse.warehouse_id) +``` + +Inside `for row in rows`, replace the current stocktake number validation with: + +```python + _validate_stocktake_row_marker(row, stocktake, stocktake_warehouse) +``` + +For new rows without `快照行ID`, remove lookup by arbitrary warehouse name and force the stocktake warehouse: + +```python + warehouse = db.get(Warehouse, stocktake_warehouse.warehouse_id) + if not item or not warehouse: + raise ValueError(f"新增批次行物料或仓库不存在:{stocktake_warehouse.warehouse_name} / {item_code}") +``` + +When creating `StocktakeLine` for new rows, set: + +```python +stocktake_warehouse_id=stocktake_warehouse.id, +warehouse_id=warehouse.id, +warehouse_name=warehouse.warehouse_name, +warehouse_type=str(warehouse.warehouse_type or "").upper(), +``` + +In the missing-line loop, only iterate current `existing_lines`. + +- [ ] **Step 11: Return only current warehouse lines after import** + +At the end of `import_stocktake_workbook`, replace final `lines = ...` query with: + +```python + lines = db.scalars( + select(StocktakeLine) + .where(StocktakeLine.stocktake_id == stocktake_id, StocktakeLine.warehouse_id == stocktake_warehouse.warehouse_id) + .order_by(StocktakeLine.line_no.asc()) + ).all() +``` + +- [ ] **Step 12: Run backend stocktake tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m unittest tests.test_stocktake_excel_diff_confirm +.venv/bin/python -m unittest tests.test_stocktake_locking tests.test_stocktake_excel_diff_confirm +``` + +Expected: both commands pass with `OK`. + +--- + +## Task 3: Backend Routes for Current-Warehouse History and Lines + +**Files:** +- Modify: `backend/app/api/routes/inventory.py` + +- [ ] **Step 1: Add warehouse filter to stocktake read summary** + +Modify `_stocktake_read` signature: + +```python +def _stocktake_read(db: Session, stocktake_id: int, warehouse_id: int | None = None) -> StocktakeRead: +``` + +Replace: + +```python +lines = stocktake_lines_for_read(db, stocktake_id) +``` + +with: + +```python +lines = stocktake_lines_for_read(db, stocktake_id) +if warehouse_id: + lines = [line for line in lines if int(line.warehouse_id) == int(warehouse_id)] +``` + +- [ ] **Step 2: Filter `/inventory/stocktakes` by warehouse ID** + +Change route signature: + +```python +def list_inventory_stocktakes( + limit: int = Query(default=100, ge=1, le=500), + warehouse_id: int | None = Query(default=None), + db: Session = Depends(get_db), +) -> list[StocktakeRead]: +``` + +Replace the query with: + +```python + stmt = select(Stocktake).order_by(Stocktake.started_at.desc(), Stocktake.id.desc()).limit(limit) + if warehouse_id: + stmt = ( + select(Stocktake) + .join(StocktakeWarehouse, StocktakeWarehouse.stocktake_id == Stocktake.id) + .where(StocktakeWarehouse.warehouse_id == warehouse_id) + .order_by(Stocktake.started_at.desc(), Stocktake.id.desc()) + .limit(limit) + ) + rows = db.scalars(stmt).all() + return [_stocktake_read(db, row.id, warehouse_id=warehouse_id) for row in rows] +``` + +- [ ] **Step 3: Filter lines endpoint by warehouse ID** + +Change route signature: + +```python +def list_inventory_stocktake_lines( + stocktake_id: int, + warehouse_id: int | None = Query(default=None), + db: Session = Depends(get_db), +) -> list[StocktakeLineRead]: +``` + +Replace return with: + +```python + lines = stocktake_lines_for_read(db, stocktake_id) + if warehouse_id: + lines = [line for line in lines if int(line.warehouse_id) == int(warehouse_id)] + return [StocktakeLineRead.model_validate(_stocktake_line_read(line)) for line in lines] +``` + +- [ ] **Step 4: Preserve import preview response** + +In `_stocktake_preview_read`, do not filter because backend import is now single-warehouse. Confirm it still returns `result["lines"]`. + +- [ ] **Step 5: Run route-adjacent tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m unittest tests.test_stocktake_locking tests.test_stocktake_excel_diff_confirm +``` + +Expected: pass with `OK`. + +--- + +## Task 4: Frontend Workflow Helper for Current Warehouse Only + +**Files:** +- Modify: `frontend/src/utils/stocktakeWorkflow.js` +- Modify: `frontend/scripts/test-stocktake-workflow.mjs` + +- [ ] **Step 1: Add helper for current warehouse history filtering** + +In `frontend/src/utils/stocktakeWorkflow.js`, add: + +```js +export function stocktakeBelongsToWarehouse(row, warehouseId) { + const id = Number(warehouseId || 0); + if (!id || !Array.isArray(row?.warehouses)) return false; + return row.warehouses.some((warehouse) => Number(warehouse.warehouse_id) === id); +} + +export function filterStocktakesForWarehouse(historyRows = [], warehouseId) { + return historyRows.filter((row) => stocktakeBelongsToWarehouse(row, warehouseId)); +} +``` + +- [ ] **Step 2: Replace global fallback helper usage policy in tests** + +In `frontend/scripts/test-stocktake-workflow.mjs`, add imports: + +```js + filterStocktakesForWarehouse, + stocktakeBelongsToWarehouse, +``` + +Add assertions: + +```js +assert.equal(stocktakeBelongsToWarehouse(lockedRaw, 1), true); +assert.equal(stocktakeBelongsToWarehouse(lockedRaw, 4), false); +assert.deepEqual(filterStocktakesForWarehouse([lockedRaw, emptyExported], 1).map((row) => row.id), [12]); +``` + +- [ ] **Step 3: Add source assertions that global fallback is not used by dialog** + +Add after existing component source assertions: + +```js +assert.doesNotMatch(componentSource, /findOnlyActiveStocktake\\(historyRows\\.value\\)/); +assert.match(componentSource, /findActiveStocktakeForWarehouse\\(historyRows\\.value, currentWarehouseId\\.value\\)/); +assert.match(componentSource, /\\/inventory\\/stocktakes\\?warehouse_id=\\$\\{currentWarehouseId\\.value\\}/); +``` + +- [ ] **Step 4: Run frontend helper test and confirm it fails before dialog implementation** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node scripts/test-stocktake-workflow.mjs +``` + +Expected: fail because `StocktakeDialog.vue` still uses `findOnlyActiveStocktake(historyRows.value)` and does not request `warehouse_id`. + +--- + +## Task 5: Frontend Single-Warehouse Dialog Flow + +**Files:** +- Modify: `frontend/src/components/StocktakeDialog.vue` + +- [ ] **Step 1: Update imports** + +Remove unused imports if no longer needed: + +```js +findOnlyActiveStocktake, +warehouseStocktakeStatus +``` + +Add: + +```js +filterStocktakesForWarehouse, +``` + +- [ ] **Step 2: Replace multi-step labels** + +Change `flowSteps` to: + +```js +const flowSteps = [ + { key: "export", no: "01", label: "确认盘库并导出" }, + { key: "import", no: "02", label: "导入清单" } +]; +``` + +- [ ] **Step 3: Replace selection state with current warehouse state** + +Remove: + +```js +const selectedWarehouseIds = ref([]); +const activeWarehouseStatuses = computed(...); +const selectedStartableWarehouseIds = computed(...); +const activeWarehouseNames = computed(...); +``` + +Add: + +```js +const currentWarehouseId = computed(() => Number(props.defaultWarehouseId || 0)); +const currentWarehouse = computed( + () => props.warehouses.find((warehouse) => Number(warehouse.id) === currentWarehouseId.value) || null +); +const currentWarehouseName = computed(() => currentWarehouse.value?.warehouse_name || "未选择仓库"); +const currentWarehouseHistoryRows = computed(() => filterStocktakesForWarehouse(historyRows.value, currentWarehouseId.value)); +``` + +- [ ] **Step 4: Replace open watcher** + +Replace watcher body with: + +```js +watch( + () => props.open, + async (open) => { + if (!open) return; + activePanel.value = "export"; + activeStocktake.value = null; + previewLines.value = []; + previewSummary.value = {}; + diffFilter.value = "ALL"; + confirmDialogOpen.value = false; + diffDialogOpen.value = false; + dialogMessage.value = ""; + dialogError.value = ""; + if (!currentWarehouseId.value) { + setDialogError("请先在仓库栏选中一个仓库,再进行盘库"); + return; + } + await loadHistory(); + const activeRow = findActiveStocktakeForWarehouse(historyRows.value, currentWarehouseId.value); + if (activeRow) { + await continueStocktake(activeRow, { silent: true }); + } + } +); +``` + +- [ ] **Step 5: Add diff modal state** + +Add near `confirmDialogOpen`: + +```js +const diffDialogOpen = ref(false); +``` + +In `applyLoadedStocktake`, set `diffDialogOpen.value = false` before loading state. + +- [ ] **Step 6: Replace `loadHistory` with warehouse-filtered API call** + +Replace `loadHistory` with: + +```js +async function loadHistory() { + try { + if (!currentWarehouseId.value) { + historyRows.value = []; + return; + } + historyRows.value = await fetchResource(`/inventory/stocktakes?warehouse_id=${currentWarehouseId.value}&limit=100`, []); + } catch (error) { + setDialogError(error.message || "读取盘库记录失败"); + } +} +``` + +- [ ] **Step 7: Replace start flow with start-and-export action** + +Replace `startStocktake` with: + +```js +async function startAndExportStocktake() { + clearDialogFeedback(); + if (!currentWarehouseId.value) { + setDialogError("请先在仓库栏选中一个仓库"); + return; + } + busy.value = true; + try { + const stocktake = await postResource("/inventory/stocktakes", { + warehouse_ids: [currentWarehouseId.value], + remark: `${currentWarehouseName.value}盘库` + }); + activeStocktake.value = stocktake; + await downloadResource(`/inventory/stocktakes/${stocktake.id}/export`, `${stocktake.stocktake_no}_${currentWarehouseName.value}_盘库清单.xlsx`); + const exportedStocktake = await fetchResource(`/inventory/stocktakes/${stocktake.id}`, null); + if (!exportedStocktake) { + throw new Error("导出后读取盘库单失败,请重新打开盘库"); + } + await loadHistory(); + await applyLoadedStocktake(exportedStocktake, { silent: true }); + setDialogMessage(`盘库单 ${exportedStocktake.stocktake_no} 已导出,${currentWarehouseName.value} 已锁库`); + } catch (error) { + setDialogError(error.message || "开始盘库并导出失败"); + } finally { + busy.value = false; + } +} +``` + +Keep `exportStocktake` only if used for continuing a `LOCKED` stocktake that has not exported. If kept, make it use `currentWarehouseName`. + +- [ ] **Step 8: Update import stocktake line loading to current warehouse** + +In `applyLoadedStocktake`, change line fetch: + +```js +const lineRows = await fetchResource(`/inventory/stocktakes/${stocktake.id}/lines?warehouse_id=${currentWarehouseId.value}`, null); +``` + +- [ ] **Step 9: Replace import fallback** + +In `importStocktake`, replace fallback lookup with: + +```js +const fallbackRow = findActiveStocktakeForWarehouse(historyRows.value, currentWarehouseId.value); +``` + +- [ ] **Step 10: Open diff modal immediately after import success** + +In `importStocktake`, after setting `activePanel.value = "diff";`, add: + +```js +diffDialogOpen.value = true; +activePanel.value = "import"; +``` + +This keeps the flow block on the import step while the modal shows differences. + +- [ ] **Step 11: Replace template overview/export/import/history sections** + +Replace the current overview/export/import blocks with this two-block flow: + +```vue +
+
+ 当前仓库:{{ currentWarehouseName }} + 01 确认盘库并导出仓库清单 +

点击后系统会锁定当前仓库,并导出本次盘库 Excel 清单。盘库结束前该仓库不能出入库。

+
+ + +
+
+
+ +
+
+ 当前盘库单:{{ activeStocktake?.stocktake_no || "-" }} + 02 导入盘点后的 Excel +

只允许导入当前仓库、本次盘库单导出的清单。导入成功后会立刻弹出差异对比。

+ +
+
+``` + +- [ ] **Step 12: Move diff table into modal** + +Wrap the existing diff panel content in a modal: + +```vue +
+
+
+
+

盘库差异对比

+

{{ activeStocktake?.stocktake_no }} · {{ currentWarehouseName }}

+
+ +
+ + +
+
+``` + +Add: + +```js +function closeDiffDialog() { + if (busy.value) return; + diffDialogOpen.value = false; +} +``` + +- [ ] **Step 13: Filter history display to current warehouse** + +In history rows template, replace: + +```vue +v-for="row in historyRows" +``` + +with: + +```vue +v-for="row in currentWarehouseHistoryRows" +``` + +Replace empty state condition: + +```vue +v-if="!currentWarehouseHistoryRows.length" +``` + +- [ ] **Step 14: Update confirm success behavior** + +In `confirmStocktake`, after successful confirm, replace `emit("close")` with: + +```js +diffDialogOpen.value = false; +activePanel.value = "export"; +activeStocktake.value = null; +previewLines.value = []; +previewSummary.value = {}; +emit("changed"); +``` + +Do not close the whole stocktake dialog automatically unless the user explicitly closes it. + +- [ ] **Step 15: Run frontend checks** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node scripts/test-stocktake-workflow.mjs +npm run build +``` + +Expected: workflow test passes and Vite exits code 0. + +--- + +## Task 6: Frontend Styles for Single-Warehouse Flow and Diff Modal + +**Files:** +- Modify: `frontend/src/styles/main.css` + +- [ ] **Step 1: Add single warehouse flow styles** + +Append near existing stocktake styles: + +```css +.stocktake-single-panel { + display: grid; + gap: 16px; + align-content: start; +} + +.stocktake-diff-modal-mask { + position: absolute; + inset: 0; + z-index: 3; + display: grid; + place-items: center; + padding: 22px; + background: rgba(15, 23, 42, 0.34); +} + +.stocktake-diff-modal { + display: grid; + grid-template-rows: auto auto auto minmax(0, 1fr) auto; + width: min(1320px, calc(100vw - 64px)); + height: min(820px, calc(100vh - 64px)); + min-height: 0; + overflow: hidden; + border: 1px solid rgba(148, 163, 184, 0.5); + border-radius: 24px; + background: rgba(248, 250, 252, 0.98); + box-shadow: 0 28px 80px rgba(15, 23, 42, 0.3); +} + +.stocktake-diff-modal-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px 22px; + border-bottom: 1px solid rgba(148, 163, 184, 0.35); +} + +.stocktake-diff-modal .stocktake-summary-grid, +.stocktake-diff-modal .stocktake-empty-state, +.stocktake-diff-modal .stocktake-diff-toolbar { + margin: 14px 18px 0; +} + +.stocktake-diff-modal .stocktake-diff-table-v2 { + margin: 14px 18px; + min-height: 0; +} + +.stocktake-diff-modal .stocktake-footer { + padding: 14px 18px 18px; + border-top: 1px solid rgba(148, 163, 184, 0.35); +} +``` + +- [ ] **Step 2: Remove or neutralize obsolete overview card styles only if no longer used** + +If `.stocktake-overview-card` styles remain unused, leave them in place for now to avoid broad CSS churn. Do not delete large CSS blocks in this task. + +- [ ] **Step 3: Run frontend build** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +npm run build +``` + +Expected: Vite exits code 0. + +--- + +## Task 7: End-to-End Backend and Frontend Verification + +**Files:** +- Verify only. + +- [ ] **Step 1: Run backend stocktake tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m unittest tests.test_stocktake_locking tests.test_stocktake_excel_diff_confirm +``` + +Expected: pass with `OK`. + +- [ ] **Step 2: Run backend compile check** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m compileall app +``` + +Expected: exit code 0. + +- [ ] **Step 3: Run frontend workflow self-test** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node scripts/test-stocktake-workflow.mjs +``` + +Expected output: + +```text +stocktake workflow tests passed +``` + +- [ ] **Step 4: Run frontend build** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +npm run build +``` + +Expected: Vite exits code 0. Existing chunk warning is acceptable. + +- [ ] **Step 5: Manual browser smoke test** + +Use the running ERP UI and verify: + +```text +1. Select 原材料库 in 嘉恒仓库 warehouse bar. +2. Click 盘库. +3. Dialog shows 当前仓库:原材料库 and no multi-warehouse checkbox cards. +4. Click 确认盘库并导出仓库清单. +5. Exported Excel has one sheet and includes 盘库单号、盘库校验码、仓库ID. +6. Warehouse is locked and stocktake dialog reopens to 导入清单 for the same warehouse. +7. Import the same Excel. Diff modal appears immediately. +8. Click 取消,重新导入. Modal closes, warehouse remains locked, import control remains available. +9. Modify counted weight and import again. New diff replaces old diff. +10. Try importing an Excel from a different stocktake number. System rejects it. +11. Try importing an Excel with wrong warehouse marker. System rejects it. +12. Click 确认更新库存并解锁. Stock is reset to imported workbook values, stocktake is completed, warehouse unlocks. +13. Select another warehouse and click 盘库. Records shown are only for that selected warehouse. +``` + +--- + +## Self-Review + +- Spec coverage: covers all six user rules: warehouse-bar selected warehouse determines stocktake, flow has confirm/export first step, user can edit sheet offline, reopening same warehouse resumes import step, import opens diff modal with confirm/cancel, and workbook validation rejects wrong stocktake/warehouse files. +- Placeholder scan: no `TBD`, `TODO`, or unspecified edge handling remains. +- Type consistency: backend token format is consistently `"{stocktake_no}|{warehouse_id}|{warehouse_type}"`; frontend current warehouse ID is consistently `currentWarehouseId`. +- Scope risk: existing `wh_stocktake` can technically contain historical multi-warehouse rows from older tests/data. New service rejects new multi-warehouse stocktakes; old records can remain visible in history unless filtered by `warehouse_id`. +- Verification: final plan includes backend unit tests, backend compile, frontend workflow script, frontend build, and browser smoke test. diff --git a/docs/superpowers/plans/2026-05-30-stocktake-locking.md b/docs/superpowers/plans/2026-05-30-stocktake-locking.md new file mode 100644 index 0000000..0e710cb --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-stocktake-locking.md @@ -0,0 +1,2737 @@ +# Locked Warehouse Stocktake Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a "盘库" feature in 嘉恒仓库 that locks selected warehouses, exports stocktake sheets, imports counted results, shows a side-by-side diff, posts adjustments, records audit logs, and unlocks warehouses after confirmation. + +**Architecture:** Backend owns stocktake locking, snapshots, Excel import/export, diff calculation, and inventory adjustment transactions. Frontend adds a stocktake entry above the warehouse bar, drives a wizard-style dialog, and renders a code-diff-like two-table comparison before confirmation. Inventory mutation endpoints must call a shared lock guard so locked warehouses cannot be changed outside stocktake. + +**Tech Stack:** FastAPI, SQLAlchemy ORM, openpyxl, unittest, Vue 3 Composition API, existing `FormDrawer`, `TableControls`, `PaginationBar`, `downloadResource`, `uploadResource`, `postResource`, Vite. + +--- + +## File Structure + +- Create `backend/app/services/stocktake.py` + - Builds stocktake numbers. + - Checks active warehouse locks. + - Creates stocktake snapshots. + - Builds and parses Excel workbooks. + - Calculates diff rows. + - Applies confirmed adjustments. + +- Modify `backend/app/models/operations.py` + - Add `Stocktake`, `StocktakeWarehouse`, `StocktakeLine`, and `StocktakeAdjustment` SQLAlchemy models. + +- Modify `backend/app/schemas/operations.py` + - Add request/response schemas for stocktake start, import preview, diff rows, confirm, cancel, and history. + +- Modify `backend/app/api/routes/inventory.py` + - Add `/inventory/stocktakes` routes. + - Add lock guard calls to `/inventory/inbound`, `/inventory/outbound`, and `/inventory/customer-supplied-inbound`. + +- Modify `backend/app/api/routes/purchase.py` + - Guard purchase receipt creation when target warehouse is locked. + +- Modify `backend/app/api/routes/production.py` + - Guard production work order auto issue and completion receipt posting when affected warehouse is locked. + +- Modify `backend/app/api/routes/sales.py` + - Guard delivery creation when finished warehouse is locked. + +- Create `backend/sql/add_stocktake_tables.sql` + - MySQL patch for production databases. + +- Create `backend/tests/test_stocktake_locking.py` + - Unit tests for lock guard and route-level blocking. + +- Create `backend/tests/test_stocktake_excel_diff_confirm.py` + - Unit tests for snapshot export, import diff, and confirmation adjustments. + +- Modify `frontend/src/views/InventoryLedgerView.vue` + - Add stocktake action above warehouse bar. + - Add stocktake dialog state and API calls. + - Include stocktake dialog component. + +- Create `frontend/src/components/StocktakeDialog.vue` + - Wizard UI: choose warehouses, export, import, diff preview, confirm, history. + +- Modify `frontend/src/styles/main.css` + - Add stocktake button, wizard, and side-by-side diff styles. + +- Modify `frontend/src/utils/dictionaries.js` + - Add stocktake status and diff type labels if central dictionary is already used for labels. + +--- + +## Task 1: Backend Model and Lock Guard Tests + +**Files:** +- Create: `backend/tests/test_stocktake_locking.py` +- Modify: `backend/app/models/operations.py` +- Create: `backend/app/services/stocktake.py` + +- [ ] **Step 1: Write failing tests for active warehouse locks** + +Create `backend/tests/test_stocktake_locking.py` with this content: + +```python +from __future__ import annotations + +import unittest +from datetime import UTC, datetime + +from fastapi import HTTPException +from sqlalchemy import BigInteger, create_engine +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Warehouse # noqa: E402 +from app.models.operations import Stocktake, StocktakeWarehouse # noqa: E402 +from app.services.stocktake import ensure_warehouses_unlocked, get_locked_warehouse_ids # noqa: E402 + + +class StocktakeLockingTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self._seed() + + def tearDown(self) -> None: + self.db.close() + + def _seed(self) -> None: + now = datetime.now(UTC) + self.raw = Warehouse( + id=1, + warehouse_code="WH-RAW-01", + warehouse_name="原材料库", + warehouse_type="RAW", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.finished = Warehouse( + id=2, + warehouse_code="WH-FG-01", + warehouse_name="成品库", + warehouse_type="FINISHED", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add_all([self.raw, self.finished]) + self.db.flush() + stocktake = Stocktake( + stocktake_no="PK-20260530-001", + status="LOCKED", + started_by_user_id=1, + started_at=now, + remark="测试锁库", + ) + self.db.add(stocktake) + self.db.flush() + self.db.add( + StocktakeWarehouse( + stocktake_id=stocktake.id, + warehouse_id=self.raw.id, + warehouse_type="RAW", + warehouse_name="原材料库", + status="LOCKED", + ) + ) + self.db.commit() + + def test_get_locked_warehouse_ids_returns_only_active_stocktakes(self) -> None: + locked = get_locked_warehouse_ids(self.db, [self.raw.id, self.finished.id]) + + self.assertEqual(locked, {self.raw.id}) + + def test_ensure_warehouses_unlocked_raises_for_locked_warehouse(self) -> None: + with self.assertRaises(HTTPException) as exc: + ensure_warehouses_unlocked(self.db, [self.raw.id], "入库") + + self.assertEqual(exc.exception.status_code, 423) + self.assertIn("原材料库正在盘库", exc.exception.detail) + + def test_ensure_warehouses_unlocked_allows_unlocked_warehouse(self) -> None: + ensure_warehouses_unlocked(self.db, [self.finished.id], "销售出库") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the lock tests and confirm they fail because models/service are missing** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m unittest tests.test_stocktake_locking +``` + +Expected: FAIL with import errors for `Stocktake`, `StocktakeWarehouse`, or `app.services.stocktake`. + +--- + +## Task 2: Add Stocktake Models, Schemas, SQL Patch, and Lock Service + +**Files:** +- Modify: `backend/app/models/operations.py` +- Modify: `backend/app/schemas/operations.py` +- Create: `backend/app/services/stocktake.py` +- Create: `backend/sql/add_stocktake_tables.sql` +- Test: `backend/tests/test_stocktake_locking.py` + +- [ ] **Step 1: Add stocktake ORM models** + +Modify `backend/app/models/operations.py` by adding these classes after `InventoryTxn`: + +```python +class Stocktake(Base): + __tablename__ = "wh_stocktake" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + stocktake_no: Mapped[str] = mapped_column(String(50)) + status: Mapped[str] = mapped_column(String(32)) + started_by_user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + imported_by_user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + confirmed_by_user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + canceled_by_user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + started_at: Mapped[object] = mapped_column(DateTime) + exported_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) + imported_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) + confirmed_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) + canceled_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class StocktakeWarehouse(Base): + __tablename__ = "wh_stocktake_warehouse" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + stocktake_id: Mapped[int] = mapped_column(ForeignKey("wh_stocktake.id")) + warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id")) + warehouse_type: Mapped[str] = mapped_column(String(32)) + warehouse_name: Mapped[str] = mapped_column(String(100)) + status: Mapped[str] = mapped_column(String(32)) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class StocktakeLine(Base): + __tablename__ = "wh_stocktake_line" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + stocktake_id: Mapped[int] = mapped_column(ForeignKey("wh_stocktake.id")) + stocktake_warehouse_id: Mapped[int | None] = mapped_column(ForeignKey("wh_stocktake_warehouse.id"), nullable=True) + line_no: Mapped[int] = mapped_column(Integer) + warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id")) + warehouse_name: Mapped[str] = mapped_column(String(100)) + warehouse_type: Mapped[str] = mapped_column(String(32)) + location_id: Mapped[int | None] = mapped_column(ForeignKey("wh_location.id"), nullable=True) + location_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + item_code: Mapped[str] = mapped_column(String(50)) + item_name: Mapped[str] = mapped_column(String(200)) + item_type: Mapped[str] = mapped_column(String(32)) + lot_id: Mapped[int | None] = mapped_column(ForeignKey("wh_stock_lot.id"), nullable=True) + lot_no: Mapped[str | None] = mapped_column(String(80), nullable=True) + source_material_sub_batch_no: Mapped[str | None] = mapped_column(String(80), nullable=True) + snapshot_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + snapshot_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + snapshot_unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0")) + counted_qty: Mapped[float | None] = mapped_column(DECIMAL(18, 6), nullable=True) + counted_weight_kg: Mapped[float | None] = mapped_column(DECIMAL(18, 6), nullable=True) + diff_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + diff_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + diff_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + diff_type: Mapped[str] = mapped_column(String(32)) + row_status: Mapped[str] = mapped_column(String(32)) + error_message: Mapped[str | None] = mapped_column(String(500), nullable=True) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class StocktakeAdjustment(Base): + __tablename__ = "wh_stocktake_adjustment" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + stocktake_id: Mapped[int] = mapped_column(ForeignKey("wh_stocktake.id")) + stocktake_line_id: Mapped[int] = mapped_column(ForeignKey("wh_stocktake_line.id")) + warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id")) + item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + lot_id: Mapped[int | None] = mapped_column(ForeignKey("wh_stock_lot.id"), nullable=True) + adjustment_lot_id: Mapped[int | None] = mapped_column(ForeignKey("wh_stock_lot.id"), nullable=True) + inventory_txn_id: Mapped[int | None] = mapped_column(ForeignKey("wh_inventory_txn.id"), nullable=True) + adjustment_type: Mapped[str] = mapped_column(String(32)) + before_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + after_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + before_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + after_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + qty_change: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + weight_change_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0")) + amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) +``` + +- [ ] **Step 2: Add stocktake schemas** + +Modify `backend/app/schemas/operations.py` by adding: + +```python +class StocktakeStartCreate(BaseModel): + warehouse_ids: list[int] = Field(min_length=1) + remark: str | None = None + + +class StocktakeWarehouseRead(BaseModel): + id: int + warehouse_id: int + warehouse_name: str + warehouse_type: str + status: str + + +class StocktakeLineRead(BaseModel): + id: int + stocktake_id: int + line_no: int + warehouse_id: int + warehouse_name: str + warehouse_type: str + location_id: int | None = None + location_name: str | None = None + item_id: int + item_code: str + item_name: str + item_type: str + lot_id: int | None = None + lot_no: str | None = None + source_material_sub_batch_no: str | None = None + snapshot_qty: float + snapshot_weight_kg: float + snapshot_unit_cost: float + counted_qty: float | None = None + counted_weight_kg: float | None = None + diff_qty: float + diff_weight_kg: float + diff_amount: float + diff_type: str + row_status: str + error_message: str | None = None + remark: str | None = None + + +class StocktakeRead(BaseModel): + id: int + stocktake_no: str + status: str + started_by_user_id: int | None = None + imported_by_user_id: int | None = None + confirmed_by_user_id: int | None = None + canceled_by_user_id: int | None = None + started_at: datetime + exported_at: datetime | None = None + imported_at: datetime | None = None + confirmed_at: datetime | None = None + canceled_at: datetime | None = None + remark: str | None = None + warehouses: list[StocktakeWarehouseRead] = Field(default_factory=list) + summary: dict[str, float | int] = Field(default_factory=dict) + + +class StocktakeImportPreviewRead(BaseModel): + stocktake: StocktakeRead + lines: list[StocktakeLineRead] + summary: dict[str, float | int] + + +class StocktakeConfirmCreate(BaseModel): + confirm_text: str + remark: str | None = None +``` + +If `Field` is not imported in `backend/app/schemas/operations.py`, add it to the existing Pydantic import. + +- [ ] **Step 3: Create SQL patch for production MySQL** + +Create `backend/sql/add_stocktake_tables.sql`: + +```sql +CREATE TABLE IF NOT EXISTS wh_stocktake ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stocktake_no VARCHAR(50) NOT NULL UNIQUE, + status VARCHAR(32) NOT NULL, + started_by_user_id BIGINT NULL, + imported_by_user_id BIGINT NULL, + confirmed_by_user_id BIGINT NULL, + canceled_by_user_id BIGINT NULL, + started_at DATETIME NOT NULL, + exported_at DATETIME NULL, + imported_at DATETIME NULL, + confirmed_at DATETIME NULL, + canceled_at DATETIME NULL, + remark VARCHAR(255) NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + INDEX idx_wh_stocktake_status (status), + INDEX idx_wh_stocktake_started_at (started_at) +); + +CREATE TABLE IF NOT EXISTS wh_stocktake_warehouse ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stocktake_id BIGINT NOT NULL, + warehouse_id BIGINT NOT NULL, + warehouse_type VARCHAR(32) NOT NULL, + warehouse_name VARCHAR(100) NOT NULL, + status VARCHAR(32) NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + INDEX idx_wh_stocktake_warehouse_stocktake (stocktake_id), + INDEX idx_wh_stocktake_warehouse_lock (warehouse_id, status), + CONSTRAINT fk_stocktake_warehouse_stocktake FOREIGN KEY (stocktake_id) REFERENCES wh_stocktake(id), + CONSTRAINT fk_stocktake_warehouse_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse(id) +); + +CREATE TABLE IF NOT EXISTS wh_stocktake_line ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stocktake_id BIGINT NOT NULL, + stocktake_warehouse_id BIGINT NULL, + line_no INT NOT NULL, + warehouse_id BIGINT NOT NULL, + warehouse_name VARCHAR(100) NOT NULL, + warehouse_type VARCHAR(32) NOT NULL, + location_id BIGINT NULL, + location_name VARCHAR(100) NULL, + item_id BIGINT NOT NULL, + item_code VARCHAR(50) NOT NULL, + item_name VARCHAR(200) NOT NULL, + item_type VARCHAR(32) NOT NULL, + lot_id BIGINT NULL, + lot_no VARCHAR(80) NULL, + source_material_sub_batch_no VARCHAR(80) NULL, + snapshot_qty DECIMAL(18,6) NOT NULL DEFAULT 0, + snapshot_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0, + snapshot_unit_cost DECIMAL(18,4) NOT NULL DEFAULT 0, + counted_qty DECIMAL(18,6) NULL, + counted_weight_kg DECIMAL(18,6) NULL, + diff_qty DECIMAL(18,6) NOT NULL DEFAULT 0, + diff_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0, + diff_amount DECIMAL(18,2) NOT NULL DEFAULT 0, + diff_type VARCHAR(32) NOT NULL, + row_status VARCHAR(32) NOT NULL, + error_message VARCHAR(500) NULL, + remark VARCHAR(255) NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + INDEX idx_wh_stocktake_line_stocktake (stocktake_id), + INDEX idx_wh_stocktake_line_lot (lot_id), + INDEX idx_wh_stocktake_line_diff (diff_type, row_status), + CONSTRAINT fk_stocktake_line_stocktake FOREIGN KEY (stocktake_id) REFERENCES wh_stocktake(id), + CONSTRAINT fk_stocktake_line_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse(id), + CONSTRAINT fk_stocktake_line_item FOREIGN KEY (item_id) REFERENCES md_item(id) +); + +CREATE TABLE IF NOT EXISTS wh_stocktake_adjustment ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stocktake_id BIGINT NOT NULL, + stocktake_line_id BIGINT NOT NULL, + warehouse_id BIGINT NOT NULL, + item_id BIGINT NOT NULL, + lot_id BIGINT NULL, + adjustment_lot_id BIGINT NULL, + inventory_txn_id BIGINT NULL, + adjustment_type VARCHAR(32) NOT NULL, + before_qty DECIMAL(18,6) NOT NULL DEFAULT 0, + after_qty DECIMAL(18,6) NOT NULL DEFAULT 0, + before_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0, + after_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0, + qty_change DECIMAL(18,6) NOT NULL DEFAULT 0, + weight_change_kg DECIMAL(18,6) NOT NULL DEFAULT 0, + unit_cost DECIMAL(18,4) NOT NULL DEFAULT 0, + amount DECIMAL(18,2) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + INDEX idx_wh_stocktake_adjustment_stocktake (stocktake_id), + CONSTRAINT fk_stocktake_adjustment_stocktake FOREIGN KEY (stocktake_id) REFERENCES wh_stocktake(id), + CONSTRAINT fk_stocktake_adjustment_line FOREIGN KEY (stocktake_line_id) REFERENCES wh_stocktake_line(id) +); +``` + +- [ ] **Step 4: Create the stocktake lock service** + +Create `backend/app/services/stocktake.py` with the lock constants and guard functions: + +```python +from __future__ import annotations + +from datetime import date, datetime +from decimal import Decimal + +from fastapi import HTTPException +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models.master_data import Warehouse +from app.models.operations import Stocktake, StocktakeWarehouse + +ACTIVE_STOCKTAKE_STATUSES = {"LOCKED", "EXPORTED", "IMPORTED"} + + +def build_stocktake_no(db: Session) -> str: + date_part = date.today().strftime("%Y%m%d") + prefix = f"PK-{date_part}" + count = db.scalar(select(func.count(Stocktake.id)).where(Stocktake.stocktake_no.like(f"{prefix}-%"))) or 0 + return f"{prefix}-{count + 1:03d}" + + +def stocktake_warehouse_type_label(value: str | None) -> str: + return { + "RAW": "原材料库", + "SEMI": "半成品库", + "FINISHED": "成品库", + "AUX": "辅料库", + }.get(str(value or "").upper(), str(value or "未知仓库")) + + +def get_locked_warehouse_ids(db: Session, warehouse_ids: list[int] | set[int]) -> set[int]: + ids = {int(item) for item in warehouse_ids if item} + if not ids: + return set() + rows = db.scalars( + select(StocktakeWarehouse.warehouse_id) + .join(Stocktake, Stocktake.id == StocktakeWarehouse.stocktake_id) + .where( + StocktakeWarehouse.warehouse_id.in_(ids), + StocktakeWarehouse.status == "LOCKED", + Stocktake.status.in_(ACTIVE_STOCKTAKE_STATUSES), + ) + ).all() + return {int(row) for row in rows} + + +def ensure_warehouses_unlocked(db: Session, warehouse_ids: list[int] | set[int], action_label: str) -> None: + locked_ids = get_locked_warehouse_ids(db, warehouse_ids) + if not locked_ids: + return + rows = db.scalars(select(Warehouse).where(Warehouse.id.in_(locked_ids)).order_by(Warehouse.id.asc())).all() + labels = "、".join(row.warehouse_name for row in rows) + raise HTTPException(status_code=423, detail=f"{labels}正在盘库,暂不能执行{action_label}") + + +def decimal_value(value: object, places: str = "0.000001") -> Decimal: + return Decimal(str(value or 0)).quantize(Decimal(places)) + + +def current_time() -> datetime: + return datetime.now() +``` + +- [ ] **Step 5: Run lock tests and compile backend** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m unittest tests.test_stocktake_locking +.venv/bin/python -m compileall app +``` + +Expected: lock tests PASS and compileall PASS. + +--- + +## Task 3: Add Lock Guards to Inventory Mutation Routes + +**Files:** +- Modify: `backend/app/api/routes/inventory.py` +- Test: `backend/tests/test_stocktake_locking.py` + +- [ ] **Step 1: Add failing route-level test for generic inbound** + +Append this test method to `StocktakeLockingTest` in `backend/tests/test_stocktake_locking.py`: + +```python + def test_inventory_inbound_is_blocked_when_warehouse_is_locked(self) -> None: + from decimal import Decimal + + from app.api.routes.inventory import create_warehouse_inbound + from app.models.master_data import Item + from app.schemas.operations import WarehouseInboundCreate + + now = datetime.now(UTC) + item = Item( + id=1, + item_code="原材料00001", + item_name="冷轧钢板", + item_type="RAW_MATERIAL", + unit_weight_kg=Decimal("1"), + safety_stock_weight_kg=Decimal("0"), + scrap_sale_price=Decimal("0"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add(item) + self.db.commit() + + class User: + id = 1 + + class Context: + user = User() + + with self.assertRaises(HTTPException) as exc: + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="OPENING", + item_id=item.id, + warehouse_id=self.raw.id, + inbound_weight_kg=10, + unit_cost=1, + ), + context=Context(), + db=self.db, + ) + + self.assertEqual(exc.exception.status_code, 423) + self.assertIn("正在盘库", exc.exception.detail) +``` + +- [ ] **Step 2: Run test and confirm failure** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m unittest tests.test_stocktake_locking.StocktakeLockingTest.test_inventory_inbound_is_blocked_when_warehouse_is_locked +``` + +Expected: FAIL because `create_warehouse_inbound` does not yet check locks. + +- [ ] **Step 3: Add lock checks to inventory routes** + +In `backend/app/api/routes/inventory.py`, import the guard: + +```python +from app.services.stocktake import ensure_warehouses_unlocked +``` + +Add these calls: + +```python +# create_customer_supplied_inbound, after warehouse exists +ensure_warehouses_unlocked(db, [payload.warehouse_id], "客料入库") + +# create_warehouse_inbound, after warehouse exists +ensure_warehouses_unlocked(db, [payload.warehouse_id], "入库") + +# create_warehouse_outbound, after warehouse exists +ensure_warehouses_unlocked(db, [payload.warehouse_id], "出库") +``` + +- [ ] **Step 4: Run lock tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m unittest tests.test_stocktake_locking +``` + +Expected: PASS. + +--- + +## Task 4: Add Lock Guards to Purchase, Production, and Sales Posting Routes + +**Files:** +- Modify: `backend/app/api/routes/purchase.py` +- Modify: `backend/app/api/routes/production.py` +- Modify: `backend/app/api/routes/sales.py` +- Test: `backend/tests/test_stocktake_locking.py` + +- [ ] **Step 1: Add failing tests for posting-route lock helper calls** + +Append a focused service-level test to `backend/tests/test_stocktake_locking.py` so the route imports are exercised without building every domain payload: + +```python + def test_lock_guard_message_names_action(self) -> None: + with self.assertRaises(HTTPException) as exc: + ensure_warehouses_unlocked(self.db, [self.raw.id], "到货入库") + + self.assertEqual(exc.exception.status_code, 423) + self.assertIn("到货入库", exc.exception.detail) +``` + +Run it: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m unittest tests.test_stocktake_locking.StocktakeLockingTest.test_lock_guard_message_names_action +``` + +Expected: PASS. This test locks the expected user-facing message before adding guard calls to larger routes. + +- [ ] **Step 2: Guard purchase receipts** + +In `backend/app/api/routes/purchase.py`, import: + +```python +from app.services.stocktake import ensure_warehouses_unlocked +``` + +Inside `create_purchase_receipt`, after determining `warehouse_id` and before creating `PurchaseReceipt`, add: + +```python +ensure_warehouses_unlocked(db, [warehouse_id], "到货入库") +``` + +Use the existing variable name in that function. If the function uses `receipt_warehouse_id`, call: + +```python +ensure_warehouses_unlocked(db, [receipt_warehouse_id], "到货入库") +``` + +- [ ] **Step 3: Guard production work-order material issue** + +In `backend/app/api/routes/production.py`, import: + +```python +from app.services.stocktake import ensure_warehouses_unlocked +``` + +Inside `create_work_order`, before auto issue changes raw material lots, collect the warehouse ids of lots that would be consumed: + +```python +locked_check_warehouse_ids = {lot.warehouse_id for lot in available_lots} +ensure_warehouses_unlocked(db, locked_check_warehouse_ids, "生产出库") +``` + +Place this after `available_lots` is loaded and before any `remaining_weight_kg` mutation. + +- [ ] **Step 4: Guard completion receipts** + +Inside `create_completion_receipt`, after validating the target completion warehouse and before creating `CompletionReceipt`, add: + +```python +ensure_warehouses_unlocked(db, [payload.warehouse_id], "成品入库") +``` + +- [ ] **Step 5: Guard sales deliveries** + +In `backend/app/api/routes/sales.py`, import: + +```python +from app.services.stocktake import ensure_warehouses_unlocked +``` + +Inside `create_delivery`, after validating the warehouse and before creating `Delivery`, add: + +```python +ensure_warehouses_unlocked(db, [payload.warehouse_id], "销售出库") +``` + +- [ ] **Step 6: Run backend compile and existing targeted tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m compileall app +.venv/bin/python -m unittest tests.test_stocktake_locking +.venv/bin/python -m unittest tests.test_sales_order_purchase_lock_flow +``` + +Expected: compileall PASS, lock tests PASS. Existing `test_sales_order_purchase_lock_flow` may expose unrelated status behavior already present in the repo; do not change purchase status semantics in this task. + +--- + +## Task 5: Start Stocktake, Create Snapshot, and Export Workbook + +**Files:** +- Modify: `backend/app/services/stocktake.py` +- Modify: `backend/app/api/routes/inventory.py` +- Test: `backend/tests/test_stocktake_excel_diff_confirm.py` + +- [ ] **Step 1: Write failing snapshot/export tests** + +Create `backend/tests/test_stocktake_excel_diff_confirm.py`: + +```python +from __future__ import annotations + +import unittest +from datetime import UTC, datetime +from decimal import Decimal +from io import BytesIO + +from openpyxl import load_workbook +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Item, StockBalance, Warehouse # noqa: E402 +from app.models.operations import StockLot, StocktakeLine # noqa: E402 +from app.services.stocktake import build_stocktake_workbook, start_stocktake # noqa: E402 + + +class StocktakeExcelDiffConfirmTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self._seed() + + def tearDown(self) -> None: + self.db.close() + + def _seed(self) -> None: + now = datetime.now(UTC) + self.raw = Warehouse( + id=1, + warehouse_code="WH-RAW-01", + warehouse_name="原材料库", + warehouse_type="RAW", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.item = Item( + id=1, + item_code="原材料00001", + item_name="冷轧钢板", + item_type="RAW_MATERIAL", + unit_weight_kg=Decimal("1"), + safety_stock_weight_kg=Decimal("0"), + scrap_sale_price=Decimal("0"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add_all([self.raw, self.item]) + self.db.flush() + self.lot = StockLot( + id=1, + lot_no="LOT-RAW-001", + lot_role="INVENTORY", + item_id=self.item.id, + warehouse_id=self.raw.id, + source_doc_type="OPENING", + source_doc_id=1, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("100"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("100"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("2.5"), + quality_status="PASS", + status="AVAILABLE", + created_at=now, + updated_at=now, + ) + self.balance = StockBalance( + item_id=self.item.id, + warehouse_id=self.raw.id, + qty_on_hand=Decimal("0"), + weight_on_hand_kg=Decimal("100"), + qty_available=Decimal("0"), + weight_available_kg=Decimal("100"), + qty_allocated=Decimal("0"), + weight_allocated_kg=Decimal("0"), + avg_unit_cost=Decimal("2.5"), + created_at=now, + updated_at=now, + ) + self.db.add_all([self.lot, self.balance]) + self.db.commit() + + def test_start_stocktake_locks_warehouse_and_snapshots_lots(self) -> None: + stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="月底盘库") + + self.assertEqual(stocktake.status, "LOCKED") + line = self.db.scalar(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake.id)) + self.assertIsNotNone(line) + self.assertEqual(line.lot_no, "LOT-RAW-001") + self.assertEqual(float(line.snapshot_weight_kg), 100) + self.assertEqual(line.diff_type, "MATCH") + + def test_export_workbook_has_one_sheet_per_selected_warehouse(self) -> None: + stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="月底盘库") + + workbook = build_stocktake_workbook(self.db, stocktake.id) + output = BytesIO() + workbook.save(output) + loaded = load_workbook(BytesIO(output.getvalue()), read_only=True, data_only=True) + + self.assertEqual(loaded.sheetnames, ["原材料库"]) + rows = list(loaded["原材料库"].iter_rows(values_only=True)) + self.assertIn("快照行ID", rows[0]) + self.assertIn("盘点重量(kg)", rows[0]) + self.assertEqual(rows[1][rows[0].index("批次号")], "LOT-RAW-001") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run tests and confirm failure** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m unittest tests.test_stocktake_excel_diff_confirm +``` + +Expected: FAIL because `start_stocktake` and `build_stocktake_workbook` do not exist. + +- [ ] **Step 3: Implement `start_stocktake` in `backend/app/services/stocktake.py`** + +Add imports: + +```python +from openpyxl import Workbook, load_workbook +from sqlalchemy import delete + +from app.models.master_data import Item, StockBalance +from app.models.operations import InventoryTxn, StockLot, StocktakeAdjustment, StocktakeLine +from app.services.operations import create_inventory_txn, get_location_or_default, to_decimal, upsert_stock_balance +``` + +Add constants: + +```python +STOCKTAKE_EXCEL_HEADERS = [ + "盘库单号", + "快照行ID", + "仓库", + "库位", + "物料编码", + "物料名称", + "批次号", + "来源材料分批次号", + "系统数量", + "系统重量(kg)", + "系统单价", + "盘点数量", + "盘点重量(kg)", + "盘点备注", +] +``` + +Add `start_stocktake`: + +```python +def start_stocktake(db: Session, *, warehouse_ids: list[int], user_id: int | None, remark: str | None = None) -> Stocktake: + ids = [int(item) for item in warehouse_ids if item] + if not ids: + raise HTTPException(status_code=400, detail="请选择需要盘库的仓库") + ensure_warehouses_unlocked(db, ids, "开始盘库") + warehouses = db.scalars(select(Warehouse).where(Warehouse.id.in_(ids)).order_by(Warehouse.id.asc())).all() + if len(warehouses) != len(set(ids)): + raise HTTPException(status_code=404, detail="存在无效仓库,无法开始盘库") + + now = current_time() + stocktake = Stocktake( + stocktake_no=build_stocktake_no(db), + status="LOCKED", + started_by_user_id=user_id, + started_at=now, + remark=remark, + ) + db.add(stocktake) + db.flush() + + stocktake_warehouses_by_id: dict[int, StocktakeWarehouse] = {} + for warehouse in warehouses: + row = StocktakeWarehouse( + stocktake_id=stocktake.id, + warehouse_id=warehouse.id, + warehouse_type=str(warehouse.warehouse_type or "").upper(), + warehouse_name=warehouse.warehouse_name, + status="LOCKED", + ) + db.add(row) + db.flush() + stocktake_warehouses_by_id[warehouse.id] = row + + lot_rows = db.execute( + select( + StockLot, + Item, + ) + .join(Item, Item.id == StockLot.item_id) + .where( + StockLot.warehouse_id.in_(ids), + StockLot.status == "AVAILABLE", + StockLot.remaining_weight_kg > 0, + ) + .order_by(StockLot.warehouse_id.asc(), Item.item_code.asc(), StockLot.lot_no.asc(), StockLot.id.asc()) + ).all() + + line_no = 1 + for lot, item in lot_rows: + warehouse = next(row for row in warehouses if row.id == lot.warehouse_id) + location_name = None + if lot.location_id: + from app.models.operations import WarehouseLocation + + location_name = db.scalar(select(WarehouseLocation.location_name).where(WarehouseLocation.id == lot.location_id)) + db.add( + StocktakeLine( + stocktake_id=stocktake.id, + stocktake_warehouse_id=stocktake_warehouses_by_id[warehouse.id].id, + line_no=line_no, + warehouse_id=warehouse.id, + warehouse_name=warehouse.warehouse_name, + warehouse_type=str(warehouse.warehouse_type or "").upper(), + location_id=lot.location_id, + location_name=location_name, + item_id=item.id, + item_code=item.item_code, + item_name=item.item_name, + item_type=item.item_type, + lot_id=lot.id, + lot_no=lot.lot_no, + source_material_sub_batch_no=lot.source_material_sub_batch_no or lot.material_sub_batch_no, + snapshot_qty=Decimal("0") if item.item_type == "RAW_MATERIAL" else to_decimal(lot.remaining_qty), + snapshot_weight_kg=to_decimal(lot.remaining_weight_kg), + snapshot_unit_cost=to_decimal(lot.unit_cost, "0.0001"), + counted_qty=None, + counted_weight_kg=None, + diff_qty=Decimal("0"), + diff_weight_kg=Decimal("0"), + diff_amount=Decimal("0"), + diff_type="MATCH", + row_status="SNAPSHOT", + remark=None, + ) + ) + line_no += 1 + + db.commit() + db.refresh(stocktake) + return stocktake +``` + +- [ ] **Step 4: Implement workbook export** + +Add: + +```python +def _stocktake_lines(db: Session, stocktake_id: int) -> list[StocktakeLine]: + return db.scalars( + select(StocktakeLine) + .where(StocktakeLine.stocktake_id == stocktake_id) + .order_by(StocktakeLine.warehouse_id.asc(), StocktakeLine.line_no.asc()) + ).all() + + +def build_stocktake_workbook(db: Session, stocktake_id: int) -> Workbook: + stocktake = db.get(Stocktake, stocktake_id) + if not stocktake: + raise HTTPException(status_code=404, detail="盘库单不存在") + lines = _stocktake_lines(db, stocktake_id) + workbook = Workbook() + default_sheet = workbook.active + workbook.remove(default_sheet) + grouped: dict[str, list[StocktakeLine]] = {} + for line in lines: + grouped.setdefault(stocktake_warehouse_type_label(line.warehouse_type), []).append(line) + if not grouped: + for warehouse in db.scalars(select(StocktakeWarehouse).where(StocktakeWarehouse.stocktake_id == stocktake_id)).all(): + grouped.setdefault(stocktake_warehouse_type_label(warehouse.warehouse_type), []) + for sheet_name, sheet_lines in grouped.items(): + worksheet = workbook.create_sheet(title=sheet_name[:31]) + worksheet.freeze_panes = "A2" + worksheet.append(STOCKTAKE_EXCEL_HEADERS) + for line in sheet_lines: + worksheet.append( + [ + stocktake.stocktake_no, + line.id, + line.warehouse_name, + line.location_name or "", + line.item_code, + line.item_name, + line.lot_no or "", + line.source_material_sub_batch_no or "", + float(line.snapshot_qty or 0), + float(line.snapshot_weight_kg or 0), + float(line.snapshot_unit_cost or 0), + float(line.snapshot_qty or 0), + float(line.snapshot_weight_kg or 0), + "", + ] + ) + for index, width in enumerate([18, 12, 14, 14, 18, 28, 22, 24, 14, 16, 12, 14, 16, 24], start=1): + worksheet.column_dimensions[worksheet.cell(row=1, column=index).column_letter].width = width + stocktake.exported_at = current_time() + stocktake.status = "EXPORTED" + db.add(stocktake) + db.commit() + return workbook +``` + +- [ ] **Step 5: Add start/export API routes** + +In `backend/app/api/routes/inventory.py`, import: + +```python +from io import BytesIO +from urllib.parse import quote +from fastapi import File, UploadFile +from fastapi.responses import Response +from app.schemas.operations import StocktakeConfirmCreate, StocktakeImportPreviewRead, StocktakeRead, StocktakeStartCreate +from app.services.stocktake import build_stocktake_workbook, start_stocktake +``` + +Add response helper: + +```python +def _excel_response(workbook, filename: str) -> Response: + output = BytesIO() + workbook.save(output) + encoded_filename = quote(filename) + return Response( + content=output.getvalue(), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f"attachment; filename=\"stocktake.xlsx\"; filename*=UTF-8''{encoded_filename}"}, + ) +``` + +Add routes: + +```python +@router.post("/stocktakes", response_model=StocktakeRead) +def start_inventory_stocktake( + payload: StocktakeStartCreate, + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> StocktakeRead: + stocktake = start_stocktake(db, warehouse_ids=payload.warehouse_ids, user_id=context.user.id, remark=payload.remark) + return _stocktake_read(db, stocktake.id) + + +@router.get("/stocktakes/{stocktake_id}/export") +def export_inventory_stocktake(stocktake_id: int, db: Session = Depends(get_db)) -> Response: + workbook = build_stocktake_workbook(db, stocktake_id) + stocktake = db.get(Stocktake, stocktake_id) + return _excel_response(workbook, f"{stocktake.stocktake_no}_盘库清单.xlsx") +``` + +Also add `_stocktake_read` in `inventory.py` or `stocktake.py`; keep it in `stocktake.py` if multiple routes need it. + +- [ ] **Step 6: Run tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m unittest tests.test_stocktake_excel_diff_confirm +.venv/bin/python -m compileall app +``` + +Expected: tests PASS and compileall PASS. + +--- + +## Task 6: Import Counted Workbook and Calculate Diff Preview + +**Files:** +- Modify: `backend/app/services/stocktake.py` +- Modify: `backend/app/api/routes/inventory.py` +- Test: `backend/tests/test_stocktake_excel_diff_confirm.py` + +- [ ] **Step 1: Add failing import/diff test** + +Append this test to `StocktakeExcelDiffConfirmTest`: + +```python + def test_import_counted_workbook_marks_loss_gain_and_missing_rows(self) -> None: + from openpyxl import Workbook + from app.services.stocktake import import_stocktake_workbook + + stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="月底盘库") + line = self.db.scalar(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake.id)) + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "原材料库" + worksheet.append([ + "盘库单号", + "快照行ID", + "仓库", + "库位", + "物料编码", + "物料名称", + "批次号", + "来源材料分批次号", + "系统数量", + "系统重量(kg)", + "系统单价", + "盘点数量", + "盘点重量(kg)", + "盘点备注", + ]) + worksheet.append([ + stocktake.stocktake_no, + line.id, + "原材料库", + "", + "原材料00001", + "冷轧钢板", + "LOT-RAW-001", + "", + 0, + 100, + 2.5, + 0, + 90, + "实盘少10kg", + ]) + output = BytesIO() + workbook.save(output) + + preview = import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1) + + self.assertEqual(preview["summary"]["loss_count"], 1) + loss_line = self.db.get(StocktakeLine, line.id) + self.assertEqual(loss_line.diff_type, "LOSS") + self.assertEqual(float(loss_line.diff_weight_kg), -10) + self.assertEqual(loss_line.row_status, "READY") +``` + +- [ ] **Step 2: Run the test and confirm failure** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m unittest tests.test_stocktake_excel_diff_confirm.StocktakeExcelDiffConfirmTest.test_import_counted_workbook_marks_loss_gain_and_missing_rows +``` + +Expected: FAIL because `import_stocktake_workbook` is missing. + +- [ ] **Step 3: Implement import parser and diff calculation** + +In `backend/app/services/stocktake.py`, add: + +```python +def _clean_text(value: object) -> str: + return str(value or "").strip() + + +def _read_workbook_rows(content: bytes) -> list[dict[str, object]]: + try: + workbook = load_workbook(BytesIO(content), read_only=True, data_only=True) + except Exception as exc: + raise ValueError("Excel 文件读取失败,请确认上传的是盘库清单 xlsx 文件") from exc + rows: list[dict[str, object]] = [] + for worksheet in workbook.worksheets: + values = list(worksheet.iter_rows(values_only=True)) + if not values: + continue + headers = [_clean_text(item) for item in values[0]] + missing = [header for header in STOCKTAKE_EXCEL_HEADERS if header not in headers] + if missing: + raise ValueError(f"{worksheet.title} 缺少必要列:{'、'.join(missing)}") + for row in values[1:]: + if not any(_clean_text(item) for item in row): + continue + payload = {headers[index]: value for index, value in enumerate(row) if index < len(headers)} + payload["_sheet_name"] = worksheet.title + rows.append(payload) + return rows + + +def _line_diff_type(qty_diff: Decimal, weight_diff: Decimal, *, is_new: bool = False, is_missing: bool = False) -> str: + if is_new: + return "NEW_LOT" + if is_missing: + return "MISSING" + basis = weight_diff if weight_diff != 0 else qty_diff + if basis > 0: + return "GAIN" + if basis < 0: + return "LOSS" + return "MATCH" + + +def _stocktake_summary(lines: list[StocktakeLine]) -> dict[str, int | float]: + summary = { + "match_count": 0, + "gain_count": 0, + "loss_count": 0, + "new_lot_count": 0, + "missing_count": 0, + "error_count": 0, + "diff_amount": 0.0, + } + for line in lines: + key = { + "MATCH": "match_count", + "GAIN": "gain_count", + "LOSS": "loss_count", + "NEW_LOT": "new_lot_count", + "MISSING": "missing_count", + }.get(line.diff_type) + if key: + summary[key] += 1 + if line.row_status == "ERROR": + summary["error_count"] += 1 + summary["diff_amount"] += float(line.diff_amount or 0) + return summary +``` + +Implement: + +```python +def import_stocktake_workbook(db: Session, stocktake_id: int, content: bytes, user_id: int | None) -> dict[str, object]: + stocktake = db.get(Stocktake, stocktake_id) + if not stocktake: + raise HTTPException(status_code=404, detail="盘库单不存在") + if stocktake.status not in {"LOCKED", "EXPORTED", "IMPORTED"}: + raise HTTPException(status_code=400, detail="当前盘库单状态不允许导入") + + rows = _read_workbook_rows(content) + if not rows: + raise ValueError("Excel 中没有可导入的盘点数据") + + existing_lines = { + line.id: line + for line in db.scalars(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake_id)).all() + } + seen_line_ids: set[int] = set() + next_line_no = max((line.line_no for line in existing_lines.values()), default=0) + 1 + + for row in rows: + row_stocktake_no = _clean_text(row.get("盘库单号")) + if row_stocktake_no != stocktake.stocktake_no: + raise ValueError(f"存在不属于当前盘库单的行:{row_stocktake_no or '空盘库单号'}") + line_id_text = _clean_text(row.get("快照行ID")) + counted_qty = decimal_value(row.get("盘点数量")) + counted_weight = decimal_value(row.get("盘点重量(kg)")) + remark = _clean_text(row.get("盘点备注")) or None + if line_id_text: + line_id = int(float(line_id_text)) + line = existing_lines.get(line_id) + if not line: + raise ValueError(f"快照行ID {line_id} 不存在于当前盘库单") + if line_id in seen_line_ids: + line.row_status = "ERROR" + line.error_message = "导入清单中该快照行重复" + seen_line_ids.add(line_id) + snapshot_qty = decimal_value(line.snapshot_qty) + snapshot_weight = decimal_value(line.snapshot_weight_kg) + qty_diff = Decimal("0") if line.item_type == "RAW_MATERIAL" else counted_qty - snapshot_qty + weight_diff = counted_weight - snapshot_weight + line.counted_qty = Decimal("0") if line.item_type == "RAW_MATERIAL" else counted_qty + line.counted_weight_kg = counted_weight + line.diff_qty = qty_diff + line.diff_weight_kg = weight_diff + line.diff_amount = (weight_diff * decimal_value(line.snapshot_unit_cost, "0.0001")).quantize(Decimal("0.01")) + line.diff_type = _line_diff_type(qty_diff, weight_diff) + line.row_status = "ERROR" if line.error_message else "READY" + line.remark = remark + db.add(line) + continue + + warehouse_name = _clean_text(row.get("仓库")) + item_code = _clean_text(row.get("物料编码")) + item = db.scalar(select(Item).where(Item.item_code == item_code).limit(1)) + warehouse = db.scalar(select(Warehouse).where(Warehouse.warehouse_name == warehouse_name).limit(1)) + if not item or not warehouse: + raise ValueError(f"新增批次行物料或仓库不存在:{warehouse_name} / {item_code}") + unit_cost = decimal_value(row.get("系统单价"), "0.0001") + db.add( + StocktakeLine( + stocktake_id=stocktake.id, + stocktake_warehouse_id=None, + line_no=next_line_no, + warehouse_id=warehouse.id, + warehouse_name=warehouse.warehouse_name, + warehouse_type=str(warehouse.warehouse_type or "").upper(), + location_id=None, + location_name=_clean_text(row.get("库位")) or None, + item_id=item.id, + item_code=item.item_code, + item_name=item.item_name, + item_type=item.item_type, + lot_id=None, + lot_no=_clean_text(row.get("批次号")) or None, + source_material_sub_batch_no=_clean_text(row.get("来源材料分批次号")) or None, + snapshot_qty=Decimal("0"), + snapshot_weight_kg=Decimal("0"), + snapshot_unit_cost=unit_cost, + counted_qty=Decimal("0") if item.item_type == "RAW_MATERIAL" else counted_qty, + counted_weight_kg=counted_weight, + diff_qty=Decimal("0") if item.item_type == "RAW_MATERIAL" else counted_qty, + diff_weight_kg=counted_weight, + diff_amount=(counted_weight * unit_cost).quantize(Decimal("0.01")), + diff_type="NEW_LOT", + row_status="READY", + remark=remark, + ) + ) + next_line_no += 1 + + for line_id, line in existing_lines.items(): + if line_id in seen_line_ids: + continue + line.counted_qty = Decimal("0") + line.counted_weight_kg = Decimal("0") + line.diff_qty = -decimal_value(line.snapshot_qty) + line.diff_weight_kg = -decimal_value(line.snapshot_weight_kg) + line.diff_amount = (decimal_value(line.diff_weight_kg) * decimal_value(line.snapshot_unit_cost, "0.0001")).quantize(Decimal("0.01")) + line.diff_type = "MISSING" + line.row_status = "READY" + line.remark = "导入清单缺失,按盘点为0处理" + db.add(line) + + stocktake.status = "IMPORTED" + stocktake.imported_by_user_id = user_id + stocktake.imported_at = current_time() + db.add(stocktake) + db.commit() + + lines = db.scalars(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake_id).order_by(StocktakeLine.line_no.asc())).all() + return {"stocktake": stocktake, "lines": lines, "summary": _stocktake_summary(lines)} +``` + +- [ ] **Step 4: Add import API route** + +In `backend/app/api/routes/inventory.py`, import `import_stocktake_workbook` and add: + +```python +@router.post("/stocktakes/{stocktake_id}/import", response_model=StocktakeImportPreviewRead) +async def import_inventory_stocktake( + stocktake_id: int, + file: UploadFile = File(...), + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> StocktakeImportPreviewRead: + content = await file.read() + try: + result = import_stocktake_workbook(db, stocktake_id, content, user_id=context.user.id) + except ValueError as exc: + db.rollback() + raise HTTPException(status_code=400, detail=str(exc)) from exc + return _stocktake_preview_read(db, result) +``` + +- [ ] **Step 5: Run tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m unittest tests.test_stocktake_excel_diff_confirm +.venv/bin/python -m compileall app +``` + +Expected: tests PASS and compileall PASS. + +--- + +## Task 7: Confirm Stocktake and Post Inventory Adjustments + +**Files:** +- Modify: `backend/app/services/stocktake.py` +- Modify: `backend/app/api/routes/inventory.py` +- Test: `backend/tests/test_stocktake_excel_diff_confirm.py` + +- [ ] **Step 1: Add failing confirmation test** + +Append: + +```python + def test_confirm_stocktake_adjusts_lot_balance_and_writes_txn(self) -> None: + from openpyxl import Workbook + from app.models.operations import InventoryTxn, StocktakeAdjustment + from app.services.stocktake import confirm_stocktake, import_stocktake_workbook + + stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="月底盘库") + line = self.db.scalar(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake.id)) + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "原材料库" + worksheet.append([ + "盘库单号", + "快照行ID", + "仓库", + "库位", + "物料编码", + "物料名称", + "批次号", + "来源材料分批次号", + "系统数量", + "系统重量(kg)", + "系统单价", + "盘点数量", + "盘点重量(kg)", + "盘点备注", + ]) + worksheet.append([stocktake.stocktake_no, line.id, "原材料库", "", "原材料00001", "冷轧钢板", "LOT-RAW-001", "", 0, 100, 2.5, 0, 90, "实盘少10kg"]) + output = BytesIO() + workbook.save(output) + import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1) + + confirmed = confirm_stocktake(self.db, stocktake.id, user_id=2, confirm_text="确认盘库", remark="确认调整") + + self.assertEqual(confirmed.status, "CONFIRMED") + self.db.refresh(self.lot) + self.db.refresh(self.balance) + self.assertEqual(float(self.lot.remaining_weight_kg), 90) + self.assertEqual(float(self.balance.weight_on_hand_kg), 90) + txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "STOCKTAKE_LOSS")) + self.assertIsNotNone(txn) + adjustment = self.db.scalar(select(StocktakeAdjustment).where(StocktakeAdjustment.stocktake_id == stocktake.id)) + self.assertIsNotNone(adjustment) + self.assertEqual(float(adjustment.weight_change_kg), -10) +``` + +- [ ] **Step 2: Run test and confirm failure** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m unittest tests.test_stocktake_excel_diff_confirm.StocktakeExcelDiffConfirmTest.test_confirm_stocktake_adjusts_lot_balance_and_writes_txn +``` + +Expected: FAIL because `confirm_stocktake` is missing. + +- [ ] **Step 3: Implement confirmation** + +In `backend/app/services/stocktake.py`, add: + +```python +def _stocktake_txn_type(diff_type: str) -> str: + return { + "GAIN": "STOCKTAKE_GAIN", + "LOSS": "STOCKTAKE_LOSS", + "MISSING": "STOCKTAKE_LOSS", + "NEW_LOT": "STOCKTAKE_NEW_LOT", + }.get(diff_type, "STOCKTAKE_GAIN") + + +def confirm_stocktake(db: Session, stocktake_id: int, *, user_id: int | None, confirm_text: str, remark: str | None = None) -> Stocktake: + if confirm_text != "确认盘库": + raise HTTPException(status_code=400, detail="请输入“确认盘库”后再确认") + stocktake = db.get(Stocktake, stocktake_id) + if not stocktake: + raise HTTPException(status_code=404, detail="盘库单不存在") + if stocktake.status != "IMPORTED": + raise HTTPException(status_code=400, detail="请先导入盘点清单并确认差异后再过账") + + lines = db.scalars(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake_id).order_by(StocktakeLine.line_no.asc())).all() + error_lines = [line for line in lines if line.row_status == "ERROR"] + if error_lines: + raise HTTPException(status_code=400, detail=f"存在 {len(error_lines)} 行异常,不能确认盘库") + + now = current_time() + for line in lines: + if line.diff_type == "MATCH": + continue + unit_cost = decimal_value(line.snapshot_unit_cost, "0.0001") + qty_change = decimal_value(line.diff_qty) + weight_change = decimal_value(line.diff_weight_kg) + if line.diff_type == "NEW_LOT": + lot = StockLot( + lot_no=line.lot_no or f"{stocktake.stocktake_no}-NEW-{line.line_no:04d}", + lot_role="STOCKTAKE_NEW", + item_id=line.item_id, + warehouse_id=line.warehouse_id, + location_id=line.location_id, + source_doc_type="STOCKTAKE", + source_doc_id=stocktake.id, + source_line_id=line.id, + source_material_sub_batch_no=line.source_material_sub_batch_no, + source_material_summary="盘库新增批次", + inbound_qty=max(Decimal("0"), decimal_value(line.counted_qty)), + inbound_weight_kg=max(Decimal("0"), decimal_value(line.counted_weight_kg)), + remaining_qty=max(Decimal("0"), decimal_value(line.counted_qty)), + remaining_weight_kg=max(Decimal("0"), decimal_value(line.counted_weight_kg)), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=unit_cost, + production_date=now.date(), + quality_status="PASS", + status="AVAILABLE", + remark=line.remark or "盘库新增", + ) + db.add(lot) + db.flush() + lot_id = lot.id + before_qty = Decimal("0") + before_weight = Decimal("0") + after_qty = decimal_value(line.counted_qty) + after_weight = decimal_value(line.counted_weight_kg) + else: + lot = db.get(StockLot, line.lot_id) + if not lot: + raise HTTPException(status_code=400, detail=f"盘库行 {line.line_no} 对应批次不存在") + before_qty = Decimal("0") if line.item_type == "RAW_MATERIAL" else decimal_value(lot.remaining_qty) + before_weight = decimal_value(lot.remaining_weight_kg) + after_qty = Decimal("0") if line.item_type == "RAW_MATERIAL" else max(Decimal("0"), decimal_value(line.counted_qty)) + after_weight = max(Decimal("0"), decimal_value(line.counted_weight_kg)) + qty_change = after_qty - before_qty + weight_change = after_weight - before_weight + lot.remaining_qty = after_qty + lot.remaining_weight_kg = after_weight + lot.status = "AVAILABLE" if after_qty > 0 or after_weight > 0 else "DEPLETED" + db.add(lot) + lot_id = lot.id + + upsert_stock_balance( + db, + item_id=line.item_id, + warehouse_id=line.warehouse_id, + location_id=line.location_id, + qty_delta=qty_change, + weight_delta=weight_change, + available_qty_delta=qty_change, + available_weight_delta=weight_change, + unit_cost=unit_cost, + ) + txn = create_inventory_txn( + db, + txn_type=_stocktake_txn_type(line.diff_type), + item_id=line.item_id, + warehouse_id=line.warehouse_id, + location_id=line.location_id, + lot_id=lot_id, + qty_change=qty_change, + weight_change=weight_change, + unit_cost=unit_cost, + source_doc_type="STOCKTAKE", + source_doc_id=stocktake.id, + source_line_id=line.id, + biz_time=now, + operator_user_id=user_id, + remark=line.remark or remark or "盘库调整", + amount_basis="WEIGHT", + ) + db.flush() + db.add( + StocktakeAdjustment( + stocktake_id=stocktake.id, + stocktake_line_id=line.id, + warehouse_id=line.warehouse_id, + item_id=line.item_id, + lot_id=line.lot_id, + adjustment_lot_id=lot_id, + inventory_txn_id=txn.id, + adjustment_type=line.diff_type, + before_qty=before_qty, + after_qty=after_qty, + before_weight_kg=before_weight, + after_weight_kg=after_weight, + qty_change=qty_change, + weight_change_kg=weight_change, + unit_cost=unit_cost, + amount=(abs(weight_change) * unit_cost).quantize(Decimal("0.01")), + ) + ) + + for warehouse in db.scalars(select(StocktakeWarehouse).where(StocktakeWarehouse.stocktake_id == stocktake_id)).all(): + warehouse.status = "UNLOCKED" + db.add(warehouse) + stocktake.status = "CONFIRMED" + stocktake.confirmed_by_user_id = user_id + stocktake.confirmed_at = now + if remark: + stocktake.remark = remark + db.add(stocktake) + db.commit() + db.refresh(stocktake) + return stocktake +``` + +- [ ] **Step 4: Add confirm and cancel API routes** + +In `backend/app/api/routes/inventory.py`, import `confirm_stocktake` and add: + +```python +@router.post("/stocktakes/{stocktake_id}/confirm", response_model=StocktakeRead) +def confirm_inventory_stocktake( + stocktake_id: int, + payload: StocktakeConfirmCreate, + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> StocktakeRead: + stocktake = confirm_stocktake( + db, + stocktake_id, + user_id=context.user.id, + confirm_text=payload.confirm_text, + remark=payload.remark, + ) + return _stocktake_read(db, stocktake.id) +``` + +Add `cancel_stocktake` in service: + +```python +def cancel_stocktake(db: Session, stocktake_id: int, *, user_id: int | None, remark: str | None = None) -> Stocktake: + stocktake = db.get(Stocktake, stocktake_id) + if not stocktake: + raise HTTPException(status_code=404, detail="盘库单不存在") + if stocktake.status not in ACTIVE_STOCKTAKE_STATUSES: + raise HTTPException(status_code=400, detail="当前盘库单不能取消") + now = current_time() + for warehouse in db.scalars(select(StocktakeWarehouse).where(StocktakeWarehouse.stocktake_id == stocktake_id)).all(): + warehouse.status = "UNLOCKED" + db.add(warehouse) + stocktake.status = "CANCELED" + stocktake.canceled_by_user_id = user_id + stocktake.canceled_at = now + stocktake.remark = remark or stocktake.remark + db.add(stocktake) + db.commit() + db.refresh(stocktake) + return stocktake +``` + +Add route: + +```python +@router.post("/stocktakes/{stocktake_id}/cancel", response_model=StocktakeRead) +def cancel_inventory_stocktake( + stocktake_id: int, + payload: StocktakeConfirmCreate, + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> StocktakeRead: + stocktake = cancel_stocktake(db, stocktake_id, user_id=context.user.id, remark=payload.remark) + return _stocktake_read(db, stocktake.id) +``` + +- [ ] **Step 5: Run confirmation tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m unittest tests.test_stocktake_excel_diff_confirm +.venv/bin/python -m compileall app +``` + +Expected: tests PASS and compileall PASS. + +--- + +## Task 8: Stocktake History and Read APIs + +**Files:** +- Modify: `backend/app/services/stocktake.py` +- Modify: `backend/app/api/routes/inventory.py` +- Test: `backend/tests/test_stocktake_excel_diff_confirm.py` + +- [ ] **Step 1: Add read helpers** + +In `backend/app/services/stocktake.py`, add: + +```python +def stocktake_lines_for_read(db: Session, stocktake_id: int) -> list[StocktakeLine]: + return db.scalars( + select(StocktakeLine) + .where(StocktakeLine.stocktake_id == stocktake_id) + .order_by(StocktakeLine.warehouse_id.asc(), StocktakeLine.line_no.asc()) + ).all() + + +def stocktake_warehouses_for_read(db: Session, stocktake_id: int) -> list[StocktakeWarehouse]: + return db.scalars( + select(StocktakeWarehouse) + .where(StocktakeWarehouse.stocktake_id == stocktake_id) + .order_by(StocktakeWarehouse.id.asc()) + ).all() +``` + +- [ ] **Step 2: Add route read serializers** + +In `backend/app/api/routes/inventory.py`, add: + +```python +def _stocktake_line_read(line: StocktakeLine) -> dict[str, object]: + return { + "id": line.id, + "stocktake_id": line.stocktake_id, + "line_no": line.line_no, + "warehouse_id": line.warehouse_id, + "warehouse_name": line.warehouse_name, + "warehouse_type": line.warehouse_type, + "location_id": line.location_id, + "location_name": line.location_name, + "item_id": line.item_id, + "item_code": line.item_code, + "item_name": line.item_name, + "item_type": line.item_type, + "lot_id": line.lot_id, + "lot_no": line.lot_no, + "source_material_sub_batch_no": line.source_material_sub_batch_no, + "snapshot_qty": line.snapshot_qty, + "snapshot_weight_kg": line.snapshot_weight_kg, + "snapshot_unit_cost": line.snapshot_unit_cost, + "counted_qty": line.counted_qty, + "counted_weight_kg": line.counted_weight_kg, + "diff_qty": line.diff_qty, + "diff_weight_kg": line.diff_weight_kg, + "diff_amount": line.diff_amount, + "diff_type": line.diff_type, + "row_status": line.row_status, + "error_message": line.error_message, + "remark": line.remark, + } +``` + +Add `_stocktake_read`: + +```python +def _stocktake_read(db: Session, stocktake_id: int) -> StocktakeRead: + stocktake = db.get(Stocktake, stocktake_id) + if not stocktake: + raise HTTPException(status_code=404, detail="盘库单不存在") + warehouses = stocktake_warehouses_for_read(db, stocktake_id) + lines = stocktake_lines_for_read(db, stocktake_id) + return StocktakeRead.model_validate( + { + "id": stocktake.id, + "stocktake_no": stocktake.stocktake_no, + "status": stocktake.status, + "started_by_user_id": stocktake.started_by_user_id, + "imported_by_user_id": stocktake.imported_by_user_id, + "confirmed_by_user_id": stocktake.confirmed_by_user_id, + "canceled_by_user_id": stocktake.canceled_by_user_id, + "started_at": stocktake.started_at, + "exported_at": stocktake.exported_at, + "imported_at": stocktake.imported_at, + "confirmed_at": stocktake.confirmed_at, + "canceled_at": stocktake.canceled_at, + "remark": stocktake.remark, + "warehouses": [ + { + "id": row.id, + "warehouse_id": row.warehouse_id, + "warehouse_name": row.warehouse_name, + "warehouse_type": row.warehouse_type, + "status": row.status, + } + for row in warehouses + ], + "summary": _stocktake_summary(lines), + } + ) +``` + +Add `_stocktake_preview_read`: + +```python +def _stocktake_preview_read(db: Session, result: dict[str, object]) -> StocktakeImportPreviewRead: + stocktake = result["stocktake"] + lines = result["lines"] + return StocktakeImportPreviewRead.model_validate( + { + "stocktake": _stocktake_read(db, stocktake.id), + "lines": [_stocktake_line_read(line) for line in lines], + "summary": result["summary"], + } + ) +``` + +- [ ] **Step 3: Add list/detail/lines routes** + +Add: + +```python +@router.get("/stocktakes", response_model=list[StocktakeRead]) +def list_inventory_stocktakes( + limit: int = Query(default=100, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[StocktakeRead]: + rows = db.scalars(select(Stocktake).order_by(Stocktake.started_at.desc(), Stocktake.id.desc()).limit(limit)).all() + return [_stocktake_read(db, row.id) for row in rows] + + +@router.get("/stocktakes/{stocktake_id}", response_model=StocktakeRead) +def get_inventory_stocktake(stocktake_id: int, db: Session = Depends(get_db)) -> StocktakeRead: + return _stocktake_read(db, stocktake_id) + + +@router.get("/stocktakes/{stocktake_id}/lines", response_model=list[StocktakeLineRead]) +def list_inventory_stocktake_lines(stocktake_id: int, db: Session = Depends(get_db)) -> list[StocktakeLineRead]: + return [StocktakeLineRead.model_validate(_stocktake_line_read(line)) for line in stocktake_lines_for_read(db, stocktake_id)] +``` + +- [ ] **Step 4: Run backend verification** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m unittest tests.test_stocktake_excel_diff_confirm tests.test_stocktake_locking +.venv/bin/python -m compileall app +``` + +Expected: tests PASS and compileall PASS. + +--- + +## Task 9: Frontend Stocktake Dialog API Wiring + +**Files:** +- Modify: `frontend/src/views/InventoryLedgerView.vue` +- Create: `frontend/src/components/StocktakeDialog.vue` +- Modify: `frontend/src/utils/dictionaries.js` +- Modify: `frontend/src/styles/main.css` + +- [ ] **Step 1: Add dictionary labels** + +In `frontend/src/utils/dictionaries.js`, add exports: + +```javascript +export function formatStocktakeStatusLabel(status) { + return { + LOCKED: "盘库中", + EXPORTED: "已导出", + IMPORTED: "已导入", + CONFIRMED: "已确认", + CANCELED: "已取消" + }[String(status || "").toUpperCase()] || status || "-"; +} + +export function formatStocktakeDiffTypeLabel(type) { + return { + MATCH: "一致", + GAIN: "盘盈", + LOSS: "盘亏", + NEW_LOT: "新增批次", + MISSING: "缺失批次" + }[String(type || "").toUpperCase()] || type || "-"; +} +``` + +- [ ] **Step 2: Create dialog skeleton with props and events** + +Create `frontend/src/components/StocktakeDialog.vue`: + +```vue + +``` + +Add script: + +```vue + +``` + +- [ ] **Step 3: Verify component compiles after wiring in next task** + +Do not run build yet; `StocktakeDialog.vue` is not imported until Task 10. + +--- + +## Task 10: Add Stocktake Entry Above Warehouse Bar + +**Files:** +- Modify: `frontend/src/views/InventoryLedgerView.vue` +- Modify: `frontend/src/styles/main.css` + +- [ ] **Step 1: Import dialog and API helpers** + +Modify imports in `frontend/src/views/InventoryLedgerView.vue`: + +```javascript +import StocktakeDialog from "../components/StocktakeDialog.vue"; +``` + +Add state: + +```javascript +const stocktakeDialogOpen = ref(false); +``` + +Update `useBodyScrollLock` condition: + +```javascript +useBodyScrollLock( + computed( + () => + detailDrawerOpen.value || + genericDrawerOpen.value || + productionDrawerOpen.value || + completionDrawerOpen.value || + deliveryDrawerOpen.value || + stocktakeDialogOpen.value + ) +); +``` + +Add computed: + +```javascript +const activeWarehouseId = computed(() => activeWarehouses.value[0]?.id || 0); +``` + +Add handlers: + +```javascript +function openStocktakeDialog() { + resetFeedback(); + stocktakeDialogOpen.value = true; +} + +function closeStocktakeDialog() { + stocktakeDialogOpen.value = false; +} + +function handleStocktakeMessage(message) { + feedbackMessage.value = message; + errorMessage.value = ""; +} + +function handleStocktakeError(message) { + errorMessage.value = message; + feedbackMessage.value = ""; +} +``` + +- [ ] **Step 2: Add stocktake button above warehouse strip** + +In the template, insert between panel header and `
`: + +```vue +
+ +
+``` + +Before closing `` root, add: + +```vue + +``` + +- [ ] **Step 3: Add styles** + +Append to `frontend/src/styles/main.css`: + +```css +.inventory-stocktake-bar { + display: flex; + justify-content: flex-end; + margin: 4px 0 12px; +} + +.stocktake-launch-button { + display: inline-flex; + align-items: center; + gap: 10px; + border: 1px solid rgba(37, 99, 235, 0.38); + border-radius: 16px; + padding: 10px 18px; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.92), rgba(219, 234, 254, 0.82)); + color: #1d4ed8; + box-shadow: 0 12px 28px rgba(37, 99, 235, 0.16); + cursor: pointer; +} + +.stocktake-launch-button:hover { + transform: translateY(-2px); + box-shadow: 0 18px 38px rgba(37, 99, 235, 0.22); +} + +.stocktake-launch-icon { + width: 26px; + height: 26px; + display: inline-grid; + place-items: center; +} + +.stocktake-launch-icon svg, +.stocktake-summary-card svg { + width: 100%; + height: 100%; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.stocktake-mask { + align-items: center; + justify-content: center; +} + +.stocktake-shell { + width: min(1500px, 92vw); + height: min(860px, 90vh); + display: grid; + grid-template-rows: auto auto 1fr; + border: 1px solid rgba(148, 163, 184, 0.55); + border-radius: 26px; + background: rgba(248, 250, 252, 0.94); + box-shadow: 0 30px 90px rgba(15, 23, 42, 0.28); + overflow: hidden; +} + +.stocktake-head, +.stocktake-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px 22px; + border-bottom: 1px solid rgba(148, 163, 184, 0.35); +} + +.stocktake-steps { + display: flex; + gap: 10px; + padding: 14px 20px; + background: rgba(241, 245, 249, 0.78); +} + +.stocktake-step, +.stocktake-warehouse-card, +.stocktake-summary-card, +.stocktake-action-card { + border: 1px solid rgba(148, 163, 184, 0.42); + border-radius: 18px; + background: rgba(255, 255, 255, 0.86); + box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08); +} + +.stocktake-step { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + cursor: pointer; +} + +.stocktake-step.active { + border-color: rgba(37, 99, 235, 0.7); + color: #1d4ed8; + box-shadow: 0 16px 36px rgba(37, 99, 235, 0.16); +} + +.stocktake-panel { + overflow: auto; + padding: 20px; +} + +.stocktake-warehouse-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 14px; +} + +.stocktake-warehouse-card { + display: grid; + gap: 8px; + padding: 16px; + cursor: pointer; +} + +.stocktake-warehouse-card.active { + border-color: rgba(22, 163, 74, 0.72); + background: rgba(240, 253, 244, 0.9); +} + +.stocktake-summary-grid { + display: grid; + grid-template-columns: repeat(7, minmax(120px, 1fr)); + gap: 12px; + margin-bottom: 14px; +} + +.stocktake-summary-card { + padding: 12px 14px; +} + +.stocktake-summary-card strong { + display: block; + margin-top: 6px; + font-size: 22px; +} + +.stocktake-summary-card.gain, +.stocktake-summary-card.new { + background: rgba(240, 253, 244, 0.92); + color: #166534; +} + +.stocktake-summary-card.loss, +.stocktake-summary-card.missing { + background: rgba(254, 242, 242, 0.92); + color: #991b1b; +} + +.stocktake-summary-card.error { + background: rgba(127, 29, 29, 0.92); + color: #fff; +} + +.stocktake-diff-toolbar { + display: flex; + gap: 10px; + margin-bottom: 12px; +} + +.stocktake-diff-table { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.stocktake-diff-column { + border: 1px solid rgba(148, 163, 184, 0.42); + border-radius: 18px; + overflow: hidden; + background: rgba(255, 255, 255, 0.86); +} + +.stocktake-diff-column h4 { + margin: 0; + padding: 12px 14px; + background: rgba(226, 232, 240, 0.8); +} + +.stocktake-diff-row { + display: grid; + gap: 4px; + min-height: 76px; + padding: 12px 14px; + border-top: 1px solid rgba(226, 232, 240, 0.9); +} + +.stocktake-diff-row.diff-gain, +.stocktake-diff-row.diff-new_lot { + background: rgba(220, 252, 231, 0.86); +} + +.stocktake-diff-row.diff-loss, +.stocktake-diff-row.diff-missing { + background: rgba(254, 226, 226, 0.9); +} + +.stocktake-diff-row.diff-error { + background: #7f1d1d; + color: #fff; +} + +.stocktake-confirm-input { + min-width: 220px; + border: 1px solid rgba(148, 163, 184, 0.7); + border-radius: 12px; + padding: 10px 12px; +} +``` + +- [ ] **Step 4: Run frontend build** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +npx vite build --mode development +``` + +Expected: build PASS. + +--- + +## Task 11: Add Stocktake History View Inside Dialog + +**Files:** +- Modify: `frontend/src/components/StocktakeDialog.vue` + +- [ ] **Step 1: Add history step** + +Change `steps` in `StocktakeDialog.vue`: + +```javascript +const steps = [ + { key: "select", no: "01", label: "选择仓库" }, + { key: "export", no: "02", label: "导出清单" }, + { key: "import", no: "03", label: "导入盘点" }, + { key: "diff", no: "04", label: "差异确认" }, + { key: "history", no: "05", label: "盘库记录" } +]; +``` + +Add state: + +```javascript +const historyRows = ref([]); +``` + +Add loader: + +```javascript +async function loadHistory() { + try { + historyRows.value = await fetchResource("/inventory/stocktakes?limit=100", []); + } catch (error) { + emit("error", error.message || "读取盘库记录失败"); + } +} +``` + +Update watcher when opened: + +```javascript +watch( + () => props.open, + async (open) => { + if (!open) return; + activeStep.value = "select"; + selectedWarehouseIds.value = props.defaultWarehouseId ? [props.defaultWarehouseId] : []; + activeStocktake.value = null; + previewLines.value = []; + previewSummary.value = {}; + diffFilter.value = "ALL"; + confirmText.value = ""; + await loadHistory(); + } +); +``` + +- [ ] **Step 2: Add history template** + +Add after diff section: + +```vue +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
盘库单号仓库状态开始时间确认时间盘盈盘亏新增批次缺失批次影响金额
{{ row.stocktake_no }}{{ row.warehouses.map((item) => item.warehouse_name).join("、") }}{{ formatStocktakeStatusLabel(row.status) }}{{ row.started_at }}{{ row.confirmed_at || "-" }}{{ row.summary.gain_count || 0 }}{{ row.summary.loss_count || 0 }}{{ row.summary.new_lot_count || 0 }}{{ row.summary.missing_count || 0 }}¥{{ formatAmount(row.summary.diff_amount || 0) }}
暂无盘库记录
+
+
+``` + +- [ ] **Step 3: Run frontend build** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +npx vite build --mode development +``` + +Expected: build PASS. + +--- + +## Task 12: Final Verification and Regression Sweep + +**Files:** +- Verify all touched backend and frontend files. + +- [ ] **Step 1: Run stocktake unit tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m unittest tests.test_stocktake_locking tests.test_stocktake_excel_diff_confirm +``` + +Expected: PASS. + +- [ ] **Step 2: Run backend compile** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m compileall app +``` + +Expected: PASS. + +- [ ] **Step 3: Run frontend build** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +npx vite build --mode development +``` + +Expected: PASS. + +- [ ] **Step 4: Manual browser workflow check** + +Run the app using the project's normal backend/frontend commands, then verify: + +```text +1. 打开 采购与库存 -> 嘉恒仓库。 +2. 点击仓库 bar 上方“盘库”。 +3. 默认勾选当前仓库。 +4. 点击“开始盘库并锁库”。 +5. 在锁库期间尝试对该仓库做入库或出库,页面应收到“正在盘库”错误提示。 +6. 导出 Excel,确认 sheet 名按仓库分开。 +7. 修改“盘点重量(kg)”或“盘点数量”后导入。 +8. 差异页面左右两表对齐显示。 +9. 盘盈、盘亏、新增批次、缺失批次颜色明显。 +10. 输入“确认盘库”并确认。 +11. 库存列表刷新后显示调整后的库存。 +12. 再次进行入库或出库,锁已解除。 +13. 打开盘库记录,能看到本次盘库和差异汇总。 +``` + +- [ ] **Step 5: Run broader backend tests and document known unrelated failures** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m unittest discover -s tests +``` + +Expected: stocktake tests PASS. If existing purchase receipt or purchase lock tests fail with previously known failures, record exact test names in the final response and do not change unrelated purchase status behavior unless the user asks for that fix. + +- [ ] **Step 6: Commit if this workspace is a git repository** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +git rev-parse --is-inside-work-tree +``` + +If output is `true`, commit: + +```bash +git add backend/app/models/operations.py backend/app/schemas/operations.py backend/app/services/stocktake.py backend/app/api/routes/inventory.py backend/app/api/routes/purchase.py backend/app/api/routes/production.py backend/app/api/routes/sales.py backend/sql/add_stocktake_tables.sql backend/tests/test_stocktake_locking.py backend/tests/test_stocktake_excel_diff_confirm.py frontend/src/views/InventoryLedgerView.vue frontend/src/components/StocktakeDialog.vue frontend/src/styles/main.css frontend/src/utils/dictionaries.js +git commit -m "feat: add locked warehouse stocktake" +``` + +If output says this is not a git repository, skip the commit and mention that the workspace has no `.git` directory. + +--- + +## Self-Review + +- Spec coverage: The plan implements lock-based stocktake, multi-warehouse Excel export with sheets, import, side-by-side diff, confirmation adjustment, audit records, history display, and lock guards on warehouse-changing flows. +- Placeholder scan: The plan contains concrete function names, file paths, validation behavior, commands, and expected outputs. +- Type consistency: `Stocktake`, `StocktakeWarehouse`, `StocktakeLine`, `StocktakeAdjustment`, `start_stocktake`, `build_stocktake_workbook`, `import_stocktake_workbook`, `confirm_stocktake`, and `ensure_warehouses_unlocked` are introduced before dependent tasks use them. diff --git a/docs/superpowers/plans/2026-05-30-stocktake-ux-refactor.md b/docs/superpowers/plans/2026-05-30-stocktake-ux-refactor.md new file mode 100644 index 0000000..2962d00 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-stocktake-ux-refactor.md @@ -0,0 +1,1269 @@ +# Stocktake UX Refactor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rebuild the 嘉恒仓库盘库 interaction so users can clearly start locked stocktakes, export/import sheets, review side-by-side differences, confirm adjustments, and continue unfinished stocktakes without confusing tab jumps or silent states. + +**Architecture:** Keep the existing backend stocktake tables and core service flow. Add a small frontend workflow helper to centralize stocktake state decisions, then refactor `StocktakeDialog.vue` into a status-driven panel instead of a freely clickable five-tab wizard. Backend changes are limited to regression tests unless implementation finds an API defect. + +**Tech Stack:** FastAPI, SQLAlchemy ORM, openpyxl, Python unittest, Vue 3 Composition API, Node built-in `assert`, Vite. + +--- + +## Scope Rules + +- Do not replace `wh_stocktake`, `wh_stocktake_warehouse`, `wh_stocktake_line`, or `wh_stocktake_adjustment`. +- Do not redesign Excel import/export columns in this iteration. +- Do not allow users to jump to invalid states: import requires an active stocktake, confirm requires imported or clean empty stocktake data, and active locked stocktakes must be surfaced first. +- Keep the current lock behavior: selected warehouses stay locked from stocktake creation until confirm or cancel. +- The current workspace at `/Users/souplearn/Gitlab/py/ForgeFlow-ERP` is not a git repository, so this plan does not include commit steps. If execution is later moved into a git worktree, commit after each task. + +## File Structure + +- Create: `frontend/src/utils/stocktakeWorkflow.js` + - Pure functions for stocktake status normalization, next-panel decisions, active stocktake lookup, diff filtering, diff tone mapping, and warehouse card status. + +- Create: `frontend/scripts/test-stocktake-workflow.mjs` + - Node-based self-test for the pure workflow helper using `node:assert/strict`. + +- Modify: `frontend/src/components/StocktakeDialog.vue` + - Replace arbitrary step navigation with a status-driven flow. + - Add warehouse overview cards, active stocktake resume behavior, explicit empty-warehouse handling, clear upload feedback, and confirmation gating. + +- Modify: `frontend/src/styles/main.css` + - Replace the current stocktake wizard styling with overview-card, progress-rail, diff-grid, and history-card styles. + +- Modify: `backend/tests/test_stocktake_excel_diff_confirm.py` + - Add a focused regression test that confirms empty exported stocktakes and imported stocktakes produce stable API states for the new UI assumptions. + +--- + +## Task 1: Add Stocktake Workflow Helper and Self-Test + +**Files:** +- Create: `frontend/src/utils/stocktakeWorkflow.js` +- Create: `frontend/scripts/test-stocktake-workflow.mjs` + +- [ ] **Step 1: Write the failing workflow self-test** + +Create `frontend/scripts/test-stocktake-workflow.mjs` with this content: + +```js +import assert from "node:assert/strict"; + +import { + canContinueStocktake, + diffTone, + findActiveStocktakeForWarehouse, + hasBlockingDiffErrors, + isEmptyStocktakeRead, + nextStocktakePanel, + splitDiffLines, + stocktakeSummaryLineCount, + warehouseStocktakeStatus +} from "../src/utils/stocktakeWorkflow.js"; + +const emptyExported = { + id: 10, + status: "EXPORTED", + summary: { + match_count: 0, + gain_count: 0, + loss_count: 0, + new_lot_count: 0, + missing_count: 0, + error_count: 0 + }, + warehouses: [{ warehouse_id: 4, warehouse_name: "辅料库" }] +}; + +const importedWithDiff = { + id: 11, + status: "IMPORTED", + summary: { + match_count: 2, + gain_count: 1, + loss_count: 1, + new_lot_count: 0, + missing_count: 0, + error_count: 0 + }, + warehouses: [{ warehouse_id: 1, warehouse_name: "原材料库" }] +}; + +const lockedRaw = { + id: 12, + status: "LOCKED", + stocktake_no: "PK-20260530-012", + warehouses: [{ warehouse_id: 1, warehouse_name: "原材料库" }] +}; + +assert.equal(stocktakeSummaryLineCount(emptyExported.summary), 0); +assert.equal(isEmptyStocktakeRead(emptyExported), true); +assert.equal(isEmptyStocktakeRead(importedWithDiff), false); + +assert.equal(nextStocktakePanel(null), "overview"); +assert.equal(nextStocktakePanel({ status: "LOCKED" }), "export"); +assert.equal(nextStocktakePanel(emptyExported), "diff"); +assert.equal(nextStocktakePanel({ ...emptyExported, summary: { match_count: 1 } }), "import"); +assert.equal(nextStocktakePanel(importedWithDiff), "diff"); +assert.equal(nextStocktakePanel({ status: "CONFIRMED" }), "history"); + +assert.equal(canContinueStocktake({ status: "LOCKED" }), true); +assert.equal(canContinueStocktake({ status: "EXPORTED" }), true); +assert.equal(canContinueStocktake({ status: "IMPORTED" }), true); +assert.equal(canContinueStocktake({ status: "CONFIRMED" }), false); + +assert.equal(findActiveStocktakeForWarehouse([importedWithDiff, lockedRaw], 1).id, 11); +assert.equal(findActiveStocktakeForWarehouse([emptyExported], 4).id, 10); +assert.equal(findActiveStocktakeForWarehouse([emptyExported], 999), null); + +const lines = [ + { id: 1, diff_type: "MATCH", row_status: "READY" }, + { id: 2, diff_type: "LOSS", row_status: "READY" }, + { id: 3, diff_type: "GAIN", row_status: "READY" }, + { id: 4, diff_type: "MATCH", row_status: "ERROR" } +]; + +assert.deepEqual(splitDiffLines(lines, "ALL").map((line) => line.id), [4, 2, 3, 1]); +assert.deepEqual(splitDiffLines(lines, "DIFF").map((line) => line.id), [4, 2, 3]); +assert.deepEqual(splitDiffLines(lines, "ERROR").map((line) => line.id), [4]); +assert.equal(hasBlockingDiffErrors(lines), true); +assert.equal(diffTone({ diff_type: "GAIN", row_status: "READY" }), "gain"); +assert.equal(diffTone({ diff_type: "LOSS", row_status: "READY" }), "loss"); +assert.equal(diffTone({ diff_type: "NEW_LOT", row_status: "READY" }), "new"); +assert.equal(diffTone({ diff_type: "MISSING", row_status: "READY" }), "missing"); +assert.equal(diffTone({ diff_type: "MATCH", row_status: "ERROR" }), "error"); +assert.equal(diffTone({ diff_type: "MATCH", row_status: "READY" }), "match"); + +const warehouseStatus = warehouseStocktakeStatus( + { id: 1, warehouse_name: "原材料库", warehouse_type: "RAW" }, + [lockedRaw] +); + +assert.equal(warehouseStatus.locked, true); +assert.equal(warehouseStatus.activeStocktake.stocktake_no, "PK-20260530-012"); +assert.equal(warehouseStatus.label, "盘库中"); + +console.log("stocktake workflow tests passed"); +``` + +- [ ] **Step 2: Run the self-test and confirm it fails** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node scripts/test-stocktake-workflow.mjs +``` + +Expected: fail with `Cannot find module .../src/utils/stocktakeWorkflow.js`. + +- [ ] **Step 3: Add the workflow helper** + +Create `frontend/src/utils/stocktakeWorkflow.js` with this content: + +```js +const ACTIVE_STOCKTAKE_STATUSES = new Set(["LOCKED", "EXPORTED", "IMPORTED"]); + +export function normalizeStocktakeStatus(status) { + return String(status || "").trim().toUpperCase(); +} + +export function stocktakeSummaryLineCount(summary = {}) { + return ["match_count", "gain_count", "loss_count", "new_lot_count", "missing_count", "error_count"].reduce( + (total, key) => total + Number(summary?.[key] || 0), + 0 + ); +} + +export function isEmptyStocktakeRead(stocktake) { + return Boolean(stocktake) && stocktakeSummaryLineCount(stocktake.summary || {}) === 0; +} + +export function canContinueStocktake(row) { + return ACTIVE_STOCKTAKE_STATUSES.has(normalizeStocktakeStatus(row?.status)); +} + +export function nextStocktakePanel(stocktake) { + const status = normalizeStocktakeStatus(stocktake?.status); + if (!stocktake?.id) return "overview"; + if (status === "LOCKED") return "export"; + if (status === "EXPORTED") return isEmptyStocktakeRead(stocktake) ? "diff" : "import"; + if (status === "IMPORTED") return "diff"; + return "history"; +} + +export function findActiveStocktakeForWarehouse(historyRows = [], warehouseId) { + const id = Number(warehouseId || 0); + if (!id) return null; + return ( + historyRows.find( + (row) => + canContinueStocktake(row) && + Array.isArray(row.warehouses) && + row.warehouses.some((warehouse) => Number(warehouse.warehouse_id) === id) + ) || null + ); +} + +export function findOnlyActiveStocktake(historyRows = []) { + const rows = historyRows.filter((row) => canContinueStocktake(row)); + return rows.length === 1 ? rows[0] : null; +} + +export function hasBlockingDiffErrors(lines = []) { + return lines.some((line) => String(line?.row_status || "").toUpperCase() === "ERROR"); +} + +export function hasStocktakeDiff(line) { + const diffType = normalizeStocktakeStatus(line?.diff_type || "MATCH"); + return diffType !== "MATCH" || String(line?.row_status || "").toUpperCase() === "ERROR"; +} + +export function splitDiffLines(lines = [], filter = "ALL") { + const normalizedFilter = normalizeStocktakeStatus(filter || "ALL"); + return [...lines] + .filter((line) => { + if (normalizedFilter === "ERROR") return String(line?.row_status || "").toUpperCase() === "ERROR"; + if (normalizedFilter === "DIFF") return hasStocktakeDiff(line); + return true; + }) + .sort((left, right) => { + const leftError = String(left?.row_status || "").toUpperCase() === "ERROR" ? 0 : 1; + const rightError = String(right?.row_status || "").toUpperCase() === "ERROR" ? 0 : 1; + if (leftError !== rightError) return leftError - rightError; + const leftMatch = normalizeStocktakeStatus(left?.diff_type) === "MATCH" ? 1 : 0; + const rightMatch = normalizeStocktakeStatus(right?.diff_type) === "MATCH" ? 1 : 0; + if (leftMatch !== rightMatch) return leftMatch - rightMatch; + return Number(left?.line_no || left?.id || 0) - Number(right?.line_no || right?.id || 0); + }); +} + +export function diffTone(line) { + if (String(line?.row_status || "").toUpperCase() === "ERROR") return "error"; + const diffType = normalizeStocktakeStatus(line?.diff_type || "MATCH"); + if (diffType === "GAIN") return "gain"; + if (diffType === "LOSS") return "loss"; + if (diffType === "NEW_LOT") return "new"; + if (diffType === "MISSING") return "missing"; + return "match"; +} + +export function warehouseStocktakeStatus(warehouse, historyRows = []) { + const activeStocktake = findActiveStocktakeForWarehouse(historyRows, warehouse?.id); + const status = normalizeStocktakeStatus(activeStocktake?.status); + return { + locked: Boolean(activeStocktake), + activeStocktake, + status, + label: + { + LOCKED: "盘库中", + EXPORTED: "已导出待导入", + IMPORTED: "已导入待确认" + }[status] || "可盘库" + }; +} +``` + +- [ ] **Step 4: Run the workflow self-test and confirm it passes** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node scripts/test-stocktake-workflow.mjs +``` + +Expected output includes: + +```text +stocktake workflow tests passed +``` + +--- + +## Task 2: Refactor Stocktake Dialog State Model + +**Files:** +- Modify: `frontend/src/components/StocktakeDialog.vue` +- Test: `frontend/scripts/test-stocktake-workflow.mjs` + +- [ ] **Step 1: Replace local state helpers with shared workflow imports** + +In `frontend/src/components/StocktakeDialog.vue`, replace the current dictionary import block: + +```js +import { computed, ref, watch } from "vue"; + +import { downloadResource, fetchResource, postResource, uploadResource } from "../services/api"; +import { formatStocktakeDiffTypeLabel, formatStocktakeStatusLabel } from "../utils/dictionaries"; +import { formatAmount, formatDateTime, formatQty, formatWeight } from "../utils/formatters"; +``` + +with: + +```js +import { computed, ref, watch } from "vue"; + +import { downloadResource, fetchResource, postResource, uploadResource } from "../services/api"; +import { formatStocktakeDiffTypeLabel, formatStocktakeStatusLabel } from "../utils/dictionaries"; +import { formatAmount, formatDateTime, formatQty, formatWeight } from "../utils/formatters"; +import { + canContinueStocktake, + diffTone, + findActiveStocktakeForWarehouse, + findOnlyActiveStocktake, + hasBlockingDiffErrors, + isEmptyStocktakeRead, + nextStocktakePanel, + splitDiffLines, + warehouseStocktakeStatus +} from "../utils/stocktakeWorkflow"; +``` + +- [ ] **Step 2: Remove the clickable `steps` array** + +Delete this block from `StocktakeDialog.vue`: + +```js +const steps = [ + { key: "select", no: "01", label: "选择仓库" }, + { key: "export", no: "02", label: "导出清单" }, + { key: "import", no: "03", label: "导入盘点" }, + { key: "diff", no: "04", label: "差异确认" }, + { key: "history", no: "05", label: "盘库记录" } +]; +``` + +Add this block in the same location: + +```js +const flowSteps = [ + { key: "overview", no: "01", label: "选择仓库" }, + { key: "export", no: "02", label: "导出清单" }, + { key: "import", no: "03", label: "导入盘点" }, + { key: "diff", no: "04", label: "差异确认" }, + { key: "history", no: "05", label: "盘库记录" } +]; +``` + +- [ ] **Step 3: Rename the active panel state** + +Replace: + +```js +const activeStep = ref("select"); +``` + +with: + +```js +const activePanel = ref("overview"); +``` + +Replace all script references to `activeStep.value` with `activePanel.value`. + +Replace all template references to `activeStep` with `activePanel`. + +After replacement, there must be no `activeStep` reference: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +rg -n "activeStep|steps" frontend/src/components/StocktakeDialog.vue +``` + +Expected: no output. + +- [ ] **Step 4: Add computed workflow state** + +Add this block after `const historyRows = ref([]);`: + +```js +const currentFlowIndex = computed(() => Math.max(0, flowSteps.findIndex((step) => step.key === activePanel.value))); + +const activeWarehouseStatuses = computed(() => + props.warehouses.map((warehouse) => ({ + ...warehouse, + stocktakeStatus: warehouseStocktakeStatus(warehouse, historyRows.value) + })) +); + +const activeWarehouseNames = computed(() => + activeStocktake.value?.warehouses?.map((warehouse) => warehouse.warehouse_name).filter(Boolean).join("、") || "-" +); + +const canConfirmCurrentStocktake = computed( + () => Boolean(activeStocktake.value?.id) && !hasBlockingDiffErrors(previewLines.value) +); +``` + +- [ ] **Step 5: Update open watcher to use status-driven routing** + +Replace the current `watch(() => props.open, ...)` body with: + +```js +watch( + () => props.open, + async (open) => { + if (!open) return; + activePanel.value = "overview"; + selectedWarehouseIds.value = props.defaultWarehouseId ? [props.defaultWarehouseId] : []; + activeStocktake.value = null; + previewLines.value = []; + previewSummary.value = {}; + diffFilter.value = "ALL"; + confirmDialogOpen.value = false; + dialogMessage.value = ""; + dialogError.value = ""; + await loadHistory(); + const activeRow = findActiveStocktakeForWarehouse(historyRows.value, props.defaultWarehouseId) || findOnlyActiveStocktake(historyRows.value); + if (activeRow) { + await continueStocktake(activeRow, { silent: true }); + } + } +); +``` + +- [ ] **Step 6: Remove duplicate helper functions from the component** + +Delete these component-local functions because they are now imported from `stocktakeWorkflow.js`: + +```js +function stocktakeSummaryLineCount(summary = {}) { + return ["match_count", "gain_count", "loss_count", "new_lot_count", "missing_count", "error_count"].reduce( + (total, key) => total + Number(summary?.[key] || 0), + 0 + ); +} + +function isEmptyStocktakeRead(stocktake) { + return stocktake && stocktakeSummaryLineCount(stocktake.summary || {}) === 0; +} + +function diffClass(line) { + return `diff-${String(line.diff_type || "MATCH").toLowerCase()} ${line.row_status === "ERROR" ? "diff-error" : ""}`; +} + +function canContinueStocktake(row) { + return ["LOCKED", "EXPORTED", "IMPORTED"].includes(String(row?.status || "").toUpperCase()); +} + +function activeHistoryRows() { + return historyRows.value.filter((row) => canContinueStocktake(row)); +} + +function findOnlyActiveRow() { + const rows = activeHistoryRows(); + return rows.length === 1 ? rows[0] : null; +} + +function findActiveRowForDefaultWarehouse() { + if (!props.defaultWarehouseId) return null; + return historyRows.value.find( + (row) => + canContinueStocktake(row) && + row.warehouses.some((warehouse) => Number(warehouse.warehouse_id) === Number(props.defaultWarehouseId)) + ); +} +``` + +Then add this smaller display helper: + +```js +function diffClass(line) { + return `diff-${diffTone(line)}`; +} +``` + +- [ ] **Step 7: Run helper self-test and Vue build** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node scripts/test-stocktake-workflow.mjs +npm run build +``` + +Expected: + +```text +stocktake workflow tests passed +``` + +and Vite exits with code `0`. Chunk-size warnings are acceptable. + +--- + +## Task 3: Rebuild Dialog Template Into Status-Driven Panels + +**Files:** +- Modify: `frontend/src/components/StocktakeDialog.vue` +- Modify: `frontend/src/styles/main.css` + +- [ ] **Step 1: Replace the clickable navigation with a progress rail** + +In `StocktakeDialog.vue`, replace the current `
+
+ +
+ +``` + +Add functions: + +```js +function toggleSalesOrder(orderId) { + const id = Number(orderId); + if (form.sales_order_ids.map(Number).includes(id)) { + form.sales_order_ids = form.sales_order_ids.filter((value) => Number(value) !== id); + return; + } + form.sales_order_ids = [...form.sales_order_ids, id]; +} + +function removeSalesOrder(orderId) { + form.sales_order_ids = form.sales_order_ids.filter((value) => Number(value) !== Number(orderId)); +} +``` + +- [ ] **Step 3: Update payload** + +When submitting purchase orders, send: + +```js +sales_order_ids: form.purchase_type === "SALES_ORDER" ? form.sales_order_ids.map(Number) : [], +``` + +Remove all remaining payload uses of: + +```js +sales_order_id: form.sales_order_id +``` + +- [ ] **Step 4: Update demand calculation flow** + +Change `applySalesOrderPurchase` to loop selected order ids: + +```js +const allDemandRows = []; +for (const orderId of form.sales_order_ids) { + const result = await postResource(`/planning/generate-mrp/${Number(orderId)}`, {}); + allDemandRows.push(...(result.demands || [])); +} +applyDemandRowsToPurchaseItems(allDemandRows); +``` + +Add `applyDemandRowsToPurchaseItems(rows)` that groups by `material_item_id` and sums `suggested_purchase_weight_kg`, preserving one source demand id per material row for detail: + +```js +function applyDemandRowsToPurchaseItems(rows) { + const grouped = new Map(); + rows.forEach((row) => { + const key = Number(row.material_item_id); + if (!grouped.has(key)) { + grouped.set(key, { ...row, suggested_purchase_weight_kg: 0 }); + } + grouped.get(key).suggested_purchase_weight_kg += Number(row.suggested_purchase_weight_kg || 0); + }); + form.items = Array.from(grouped.values()).map((row, index) => ({ + line_no: index + 1, + material_item_id: Number(row.material_item_id), + source_demand_id: row.demand_id || row.source_demand_id || null, + order_weight_kg: Number(row.suggested_purchase_weight_kg || 0), + unit_price: Number(row.reference_unit_price || 0), + remark: `销售订单采购:${form.sales_order_ids.length}张订单合并需求` + })); +} +``` + +- [ ] **Step 5: Run frontend build** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +npm run build +``` + +Expected: PASS. + +- [ ] **Step 6: Checkpoint** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +git status --short || true +``` + +If git is available: + +```bash +git add frontend/src/views/PurchaseOrderView.vue +git commit -m "feat: select multiple sales orders for purchase" +``` + +--- + +### Task 11: Rename Downstream UI Labels And Search Fields + +**Files:** +- Modify: `frontend/src/views/CompletionReceiptView.vue` +- Modify: `frontend/src/views/DeliveryManagementView.vue` +- Modify: `frontend/src/views/SalesPlanningView.vue` +- Modify: `frontend/src/views/DashboardView.vue` +- Modify: `frontend/src/components/StocktakeDialog.vue` + +- [ ] **Step 1: Replace user-facing material-sub-batch wording** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +rg -n "材料分批次|原材料分批次|来源分批次|材料分批号|来源分批号" frontend/src +``` + +Expected: The command prints remaining frontend occurrences. + +For each occurrence that is user-facing, replace with: + +```text +库存批次号 +来源库存批次号 +``` + +Keep JavaScript object keys unchanged only when they match backend compatibility fields. + +- [ ] **Step 2: Update key table columns** + +In `frontend/src/views/CompletionReceiptView.vue`, change table headers: + +```text +材料分批次号 -> 来源库存批次号 +``` + +In `frontend/src/views/DeliveryManagementView.vue`, change table headers: + +```text +来源材料分批次号 -> 来源库存批次号 +``` + +In `frontend/src/views/SalesPlanningView.vue`, change contract and trace labels: + +```text +材料分批次号 -> 来源库存批次号 +``` + +In `frontend/src/views/DashboardView.vue`, change: + +```text +原料批次号 -> 库存批次号 +分批领料 -> 生产出库 +``` + +- [ ] **Step 3: Update search placeholder text** + +Replace placeholders such as: + +```text +搜索报废单号、材料分批次号、工序、产品、状态 +``` + +with: + +```text +搜索报废单号、库存批次号、工序、产品、状态 +``` + +- [ ] **Step 4: Run frontend text scan** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +rg -n "材料分批次|原材料分批次|来源分批次|材料分批号|来源分批号" frontend/src +``` + +Expected: No user-facing labels remain. Compatibility field names such as `source_material_sub_batch_no` may remain in script code. + +- [ ] **Step 5: Run frontend build** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +npm run build +``` + +Expected: PASS. + +- [ ] **Step 6: Checkpoint** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +git status --short || true +``` + +If git is available: + +```bash +git add frontend/src/views/CompletionReceiptView.vue frontend/src/views/DeliveryManagementView.vue frontend/src/views/SalesPlanningView.vue frontend/src/views/DashboardView.vue frontend/src/components/StocktakeDialog.vue +git commit -m "refactor: rename material sub-batch UI to inventory lot" +``` + +--- + +### Task 12: Update Dashboard DAG Backend To Use Inventory Lot Nodes + +**Files:** +- Modify: `backend/app/api/routes/dashboard.py` +- Modify: `backend/app/schemas/dashboard.py` + +- [ ] **Step 1: Rename DAG node detail labels** + +In `backend/app/api/routes/dashboard.py`, replace DAG detail labels: + +```python +("材料分批号", row["material_sub_batch_no"]) +("来源分批号", row["source_material_sub_batch_no"]) +``` + +with: + +```python +("库存批次号", row["source_lot_no"]) +("来源库存批次号", row["source_material_lot_no"]) +``` + +Add `source_lot_no` to the production issue query: + +```python +StockLot.lot_no.label("source_lot_no") +``` + +on the join to `WorkOrderMaterialIssue.source_lot_id`. + +- [ ] **Step 2: Rename production issue nodes** + +Change issue node titles from: + +```python +title=row["material_sub_batch_no"] +summary="原材料分批领料..." +``` + +to: + +```python +title=row["source_lot_no"] +summary="原材料按库存批次生产出库,记录领用重量、单价和成本。" +``` + +- [ ] **Step 3: Keep compatibility fallback** + +For older rows without `source_lot_no`, use: + +```python +source_lot_no = row["source_lot_no"] or row["material_sub_batch_no"] or "未记录库存批次号" +``` + +Use `source_lot_no` in node titles and details. + +- [ ] **Step 4: Run backend dashboard import smoke** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +../backend/.venv/bin/python - <<'PY' +import app.api.routes.dashboard as dashboard +print("dashboard import ok", dashboard.router.prefix if hasattr(dashboard.router, "prefix") else "router") +PY +``` + +Expected: Prints `dashboard import ok`. + +- [ ] **Step 5: Run backend tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +../backend/.venv/bin/python -m pytest tests/test_sales_order_delivery_trace.py tests/test_selected_stock_lot_production_issue.py -q +``` + +Expected: PASS. + +- [ ] **Step 6: Checkpoint** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +git status --short || true +``` + +If git is available: + +```bash +git add backend/app/api/routes/dashboard.py backend/app/schemas/dashboard.py +git commit -m "refactor: show inventory lot trace in dashboard dag" +``` + +--- + +### Task 13: Final Full Verification + +**Files:** +- No new files. +- Validate all modified backend and frontend files. + +- [ ] **Step 1: Run full backend test suite** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +../backend/.venv/bin/python -m pytest tests -q +``` + +Expected: All tests PASS. + +- [ ] **Step 2: Run frontend build** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +npm run build +``` + +Expected: Build PASS. Existing Vite chunk warnings are acceptable; syntax/type errors are not. + +- [ ] **Step 3: Run terminology scan** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +rg -n "材料分批次|原材料分批次|材料分批号|来源分批号" frontend/src backend/app +``` + +Expected: Only backend compatibility field names, migration comments, and intentional fallback logic remain. No user-facing Chinese labels should remain. + +- [ ] **Step 4: Run raw material quantity regression scan** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +rg -n "原材料.*数量|收货数量|领用数量|remaining_qty|received_qty|issued_qty" frontend/src/views backend/app/api backend/app/services +``` + +Expected: Raw material UI does not show quantity concepts. Backend compatibility decimal fields may remain but should be written as zero for raw material flows. + +- [ ] **Step 5: Manual smoke scenario** + +Use the local ERP UI and run this business scenario: + +```text +1. 新建或选择一个原材料。 +2. 新建采购订单,选择原材料库、备料采购,采购重量 500kg,锁单。 +3. 创建第 1 张到货入库单,入库 200kg,单价默认采购单价,可手动修改。 +4. 创建第 2 张到货入库单,入库 300kg。 +5. 确认原材料库详情显示 JH_原材料编码_0001 和 JH_原材料编码_0002。 +6. 生产出库时搜索该原材料,选择两个库存批次,分别填写领用重量。 +7. 确认工序报工和成品入库能按库存批次号追溯。 +8. 创建销售订单采购,关联两个销售订单,确认采购来源小卡片展示两个销售订单。 +9. 打开 DAG,确认链路为库存批次号 -> 生产出库/报工 -> 成品批次 -> 发货。 +``` + +Expected: No “材料分批次号”用户概念出现,所有追溯都围绕库存批次号。 + +- [ ] **Step 6: Final checkpoint** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +git status --short || true +``` + +If git is available: + +```bash +git add backend frontend docs/superpowers/plans/2026-05-31-inventory-lot-unified-trace.md +git commit -m "feat: unify raw material trace by inventory lot" +``` + +--- + +## Self-Review + +**Spec coverage:** Covered removal of “材料分批次号” as a user concept, new `原材料物料 -> 库存批次号` hierarchy, `JH_材料号_0001` numbering, split purchase arrivals, final receipt unit cost, selected multi-lot production issue, disabled depleted lots, miniapp field remapping, sales order multi-select for purchase orders, and downstream DAG/trace updates. + +**Placeholder scan:** This plan contains no `TBD`, no unresolved `TODO`, and no intentionally vague “handle errors” step. Compatibility cases are spelled out. + +**Type consistency:** Backend payload names use `sales_order_ids`, `selected_stock_lots`, `source_lot_id`, and `issued_weight_kg`. Frontend payload names match those schemas. Legacy `source_material_sub_batch_no` remains only as compatibility alias. diff --git a/docs/superpowers/plans/2026-05-31-raw-material-return-out.md b/docs/superpowers/plans/2026-05-31-raw-material-return-out.md new file mode 100644 index 0000000..c5360be --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-raw-material-return-out.md @@ -0,0 +1,1994 @@ +# Raw Material Return Outbound Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add 原材料库“退货出库” so already-inbound raw material lots can be returned to suppliers with logistics evidence, inventory deduction, purchase-order warning status, and full traceability. + +**Architecture:** Implement a dedicated purchase-return business record plus inventory outbound transactions. The return operation selects a concrete raw-material stock lot, links it back to a purchase order item through receipt history, deducts weight from that lot, and exposes “存在退货” as a warning overlay while preserving the purchase order lifecycle status. + +**Tech Stack:** FastAPI, SQLAlchemy ORM, Pydantic schemas, MySQL patch SQL, SQLite unit tests, Vue 3 single-file components, Vite build. + +--- + +## Scope And Decisions + +- Add this only under `嘉恒仓库 -> 原材料库 -> 出库 -> 退货出库`. +- This is for material that already entered the warehouse and was later found quality/performance-unqualified. +- This is not the same as purchase quality inspection rejection. Existing `PENDING_QC` / `REJECTED` quality flow stays unchanged. +- Raw material remains weight-only. Do not introduce raw-material quantity fields. +- Logistics rule for this feature: require either `运单号` or `订单/运单照片`. `运费` is optional for this feature. +- Purchase order status display must preserve lifecycle status and add a warning overlay. Example: `已收货 · 存在退货`. +- If a lot has receipt history from exactly one purchase order item, auto-bind it. If multiple purchase order items contributed to the same merged lot, require the user to choose which purchase order item receives this return. + +## File Structure + +- Modify `backend/app/models/operations.py` + - Add `PurchaseReturn` and `PurchaseReturnItem` ORM models. +- Create `backend/sql/add_purchase_return_outbound_patch.sql` + - Add `po_purchase_return` and `po_purchase_return_item` tables. +- Modify `backend/app/schemas/operations.py` + - Add return-out request/read schemas. + - Add stock-lot purchase-link read schema. + - Add purchase return overlay fields to purchase order read schemas. +- Modify `backend/app/services/logistics.py` + - Allow a logistics-required operation to require waybill/photo without requiring freight. +- Modify `backend/app/services/operations.py` + - Add purchase-link query helper for stock lots. + - Add return aggregation to purchase order query helpers. + - Add return transaction labels where query output needs them. +- Modify `backend/app/api/routes/inventory.py` + - Add stock-lot purchase-link endpoint. + - Add raw-material return-out endpoint. + - Add `PURCHASE_RETURN_OUT` to outbound labels/permission maps if the generic maps need the type. +- Modify `frontend/src/utils/dictionaries.js` + - Add labels for `PURCHASE_RETURN`, `PURCHASE_RETURN_OUT`, and `HAS_RETURN`. +- Modify `frontend/src/utils/statusBadges.js` + - Add warning tone for `存在退货` and `HAS_RETURN`. +- Modify `frontend/src/views/InventoryLedgerView.vue` + - Add the `退货出库` raw outbound button. + - Add a dedicated drawer for stock-lot purchase return. + - Submit to the new backend endpoint and refresh inventory. +- Modify `frontend/src/views/PurchaseOrderView.vue` + - Show purchase lifecycle status plus warning chip when a PO or PO line has returns. +- Create `backend/tests/test_raw_material_return_outbound.py` + - Cover backend return-out, validation, lot deduction, purchase order warning overlay, and optional freight. +- Modify or create `frontend/scripts/test-inventory-ledger-raw-return.mjs` + - Static verification that the raw return operation and labels exist. + +--- + +### Task 1: Add Purchase Return Tables And ORM Models + +**Files:** +- Modify: `backend/app/models/operations.py` +- Create: `backend/sql/add_purchase_return_outbound_patch.sql` +- Test: `backend/tests/test_raw_material_return_outbound.py` + +- [ ] **Step 1: Write the failing ORM model test** + +Create `backend/tests/test_raw_material_return_outbound.py` with this initial content: + +```python +from __future__ import annotations + +import unittest +from datetime import UTC, date, datetime +from decimal import Decimal + +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Item, StockBalance, Warehouse # noqa: E402 +from app.models.operations import ( # noqa: E402 + InventoryTxn, + PurchaseOrder, + PurchaseOrderItem, + PurchaseReceipt, + PurchaseReceiptItem, + PurchaseReturn, + PurchaseReturnItem, + StockLot, + Supplier, + WarehouseLocation, +) + + +class User: + id = 1 + + +class Context: + user = User() + + +class RawMaterialReturnOutboundTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self._seed() + + def tearDown(self) -> None: + self.db.close() + + def _seed(self) -> None: + now = datetime.now(UTC) + self.supplier = Supplier( + id=1, + supplier_code="SUP-001", + supplier_name="宁波测试供应商", + status="ACTIVE", + lead_time_days=0, + default_tax_rate=Decimal("0"), + created_at=now, + updated_at=now, + ) + self.raw_warehouse = Warehouse( + id=1, + warehouse_code="WH-RM-01", + warehouse_name="原材料库", + warehouse_type="RAW", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.raw_location = WarehouseLocation( + id=1, + warehouse_id=1, + location_code="RM-A-01", + location_name="原料主库位", + is_default=1, + is_locked=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.raw_item = Item( + id=1, + item_code="原材料00001", + item_name="冷轧钢板", + item_type="RAW_MATERIAL", + unit_weight_kg=Decimal("1"), + safety_stock_weight_kg=Decimal("0"), + scrap_sale_price=Decimal("1"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.purchase_order = PurchaseOrder( + id=1, + po_no="采购2026-00001", + supplier_id=1, + order_date=date(2026, 5, 31), + expected_date=None, + purchaser_employee_id=None, + target_warehouse_type="RAW", + tax_rate=Decimal("0"), + total_amount=Decimal("2500"), + status="RECEIVED", + remark=None, + created_at=now, + updated_at=now, + ) + self.purchase_order_item = PurchaseOrderItem( + id=1, + purchase_order_id=1, + line_no=1, + material_item_id=1, + source_demand_id=None, + order_qty=Decimal("0"), + order_weight_kg=Decimal("500"), + received_qty=Decimal("0"), + received_weight_kg=Decimal("500"), + unit_price=Decimal("5"), + line_amount=Decimal("2500"), + status="CLOSED", + remark=None, + created_at=now, + updated_at=now, + ) + self.receipt = PurchaseReceipt( + id=1, + receipt_no="入库2026-00001", + purchase_order_id=1, + warehouse_id=1, + receipt_date=now, + receiver_employee_id=None, + supplier_delivery_no="SUP-DN-001", + status="PASSED", + remark=None, + created_at=now, + updated_at=now, + ) + self.receipt_item = PurchaseReceiptItem( + id=1, + receipt_id=1, + line_no=1, + purchase_order_item_id=1, + material_item_id=1, + location_id=1, + lot_no="JH_原材料00001_0001", + merge_to_lot_id=None, + received_qty=Decimal("0"), + received_weight_kg=Decimal("500"), + accepted_qty=Decimal("0"), + accepted_weight_kg=Decimal("500"), + unit_cost=Decimal("5"), + status="PASSED", + remark=None, + created_at=now, + updated_at=now, + ) + self.lot = StockLot( + id=1, + lot_no="JH_原材料00001_0001", + parent_lot_id=None, + lot_role="INBOUND_RAW", + material_sub_batch_no=None, + item_id=1, + warehouse_id=1, + location_id=1, + source_doc_type="PURCHASE_RECEIPT", + source_doc_id=1, + source_line_id=1, + source_material_lot_id=None, + source_material_sub_batch_no=None, + source_material_summary=None, + logistics_waybill_no=None, + logistics_freight_amount=None, + logistics_photo_url=None, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("500"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("500"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("5"), + production_date=None, + expire_date=None, + quality_status="PASS", + status="AVAILABLE", + remark="质检放行", + created_at=now, + updated_at=now, + ) + self.balance = StockBalance( + id=1, + item_id=1, + warehouse_id=1, + location_id=1, + qty_on_hand=Decimal("0"), + weight_on_hand_kg=Decimal("500"), + qty_available=Decimal("0"), + weight_available_kg=Decimal("500"), + qty_allocated=Decimal("0"), + weight_allocated_kg=Decimal("0"), + avg_unit_cost=Decimal("5"), + updated_at=now, + ) + self.db.add_all( + [ + self.supplier, + self.raw_warehouse, + self.raw_location, + self.raw_item, + self.purchase_order, + self.purchase_order_item, + self.receipt, + self.receipt_item, + self.lot, + self.balance, + ] + ) + self.db.commit() + + def test_purchase_return_tables_exist(self) -> None: + now = datetime.now(UTC) + purchase_return = PurchaseReturn( + return_no="退货2026-00001", + purchase_order_id=1, + warehouse_id=1, + return_date=now, + operator_user_id=1, + logistics_waybill_no="YT123", + logistics_freight_amount=None, + logistics_photo_url=None, + reason="性能不合格", + status="CONFIRMED", + remark="测试退货", + created_at=now, + updated_at=now, + ) + self.db.add(purchase_return) + self.db.flush() + return_item = PurchaseReturnItem( + purchase_return_id=purchase_return.id, + line_no=1, + purchase_order_item_id=1, + material_item_id=1, + lot_id=1, + return_weight_kg=Decimal("20"), + unit_cost=Decimal("5"), + amount=Decimal("100"), + reason="性能不合格", + status="CONFIRMED", + remark=None, + created_at=now, + updated_at=now, + ) + self.db.add(return_item) + self.db.commit() + + saved = self.db.scalar(select(PurchaseReturn).where(PurchaseReturn.return_no == "退货2026-00001")) + saved_item = self.db.scalar(select(PurchaseReturnItem).where(PurchaseReturnItem.purchase_return_id == saved.id)) + + self.assertEqual(saved.status, "CONFIRMED") + self.assertEqual(float(saved_item.return_weight_kg), 20) +``` + +- [ ] **Step 2: Run the model test and verify it fails** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest tests/test_raw_material_return_outbound.py::RawMaterialReturnOutboundTest::test_purchase_return_tables_exist -q +``` + +Expected: FAIL with an import error for `PurchaseReturn` or `PurchaseReturnItem`. + +- [ ] **Step 3: Add ORM models** + +Modify `backend/app/models/operations.py` after `PurchaseReceiptItem` and before `StockLot`: + +```python +class PurchaseReturn(Base): + __tablename__ = "po_purchase_return" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + return_no: Mapped[str] = mapped_column(String(50)) + purchase_order_id: Mapped[int] = mapped_column(ForeignKey("po_purchase_order.id")) + warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id")) + return_date: Mapped[object] = mapped_column(DateTime) + operator_user_id: Mapped[int | None] = mapped_column(ForeignKey("sys_user.id"), nullable=True) + logistics_waybill_no: Mapped[str | None] = mapped_column(String(100), nullable=True) + logistics_freight_amount: Mapped[float | None] = mapped_column(DECIMAL(18, 2), nullable=True) + logistics_photo_url: Mapped[str | None] = mapped_column(String(255), nullable=True) + reason: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[str] = mapped_column(String(32)) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class PurchaseReturnItem(Base): + __tablename__ = "po_purchase_return_item" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + purchase_return_id: Mapped[int] = mapped_column(ForeignKey("po_purchase_return.id")) + line_no: Mapped[int] = mapped_column(Integer) + purchase_order_item_id: Mapped[int] = mapped_column(ForeignKey("po_purchase_order_item.id")) + material_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + lot_id: Mapped[int] = mapped_column(ForeignKey("wh_stock_lot.id")) + return_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0")) + amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0")) + reason: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[str] = mapped_column(String(32)) + remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) +``` + +- [ ] **Step 4: Add MySQL patch SQL** + +Create `backend/sql/add_purchase_return_outbound_patch.sql`: + +```sql +CREATE TABLE IF NOT EXISTS po_purchase_return ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + return_no VARCHAR(50) NOT NULL, + purchase_order_id BIGINT NOT NULL, + warehouse_id BIGINT NOT NULL, + return_date DATETIME NOT NULL, + operator_user_id BIGINT NULL, + logistics_waybill_no VARCHAR(100) NULL, + logistics_freight_amount DECIMAL(18, 2) NULL, + logistics_photo_url VARCHAR(255) NULL, + reason VARCHAR(255) NULL, + status VARCHAR(32) NOT NULL DEFAULT 'CONFIRMED', + remark VARCHAR(255) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_po_purchase_return_no (return_no), + KEY idx_po_purchase_return_order (purchase_order_id), + KEY idx_po_purchase_return_warehouse (warehouse_id), + CONSTRAINT fk_po_purchase_return_order FOREIGN KEY (purchase_order_id) REFERENCES po_purchase_order (id), + CONSTRAINT fk_po_purchase_return_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购退货出库单'; + +CREATE TABLE IF NOT EXISTS po_purchase_return_item ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + purchase_return_id BIGINT NOT NULL, + line_no INT NOT NULL, + purchase_order_item_id BIGINT NOT NULL, + material_item_id BIGINT NOT NULL, + lot_id BIGINT NOT NULL, + return_weight_kg DECIMAL(18, 6) NOT NULL DEFAULT 0, + unit_cost DECIMAL(18, 4) NOT NULL DEFAULT 0, + amount DECIMAL(18, 2) NOT NULL DEFAULT 0, + reason VARCHAR(255) NULL, + status VARCHAR(32) NOT NULL DEFAULT 'CONFIRMED', + remark VARCHAR(255) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_po_purchase_return_item_return (purchase_return_id), + KEY idx_po_purchase_return_item_po_item (purchase_order_item_id), + KEY idx_po_purchase_return_item_lot (lot_id), + CONSTRAINT fk_po_purchase_return_item_return FOREIGN KEY (purchase_return_id) REFERENCES po_purchase_return (id), + CONSTRAINT fk_po_purchase_return_item_po_item FOREIGN KEY (purchase_order_item_id) REFERENCES po_purchase_order_item (id), + CONSTRAINT fk_po_purchase_return_item_material FOREIGN KEY (material_item_id) REFERENCES md_item (id), + CONSTRAINT fk_po_purchase_return_item_lot FOREIGN KEY (lot_id) REFERENCES wh_stock_lot (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购退货出库明细'; +``` + +- [ ] **Step 5: Run the model test and verify it passes** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest tests/test_raw_material_return_outbound.py::RawMaterialReturnOutboundTest::test_purchase_return_tables_exist -q +``` + +Expected: PASS. + +- [ ] **Step 6: Commit this task if the workspace is a git repository** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +git status --short +git add backend/app/models/operations.py backend/sql/add_purchase_return_outbound_patch.sql backend/tests/test_raw_material_return_outbound.py +git commit -m "feat: add purchase return outbound tables" +``` + +Expected: commit succeeds when the workspace has `.git`. If `git status` says this is not a git repository, record that in the final implementation notes and continue without committing. + +--- + +### Task 2: Add Schemas, Logistics Rule, And Stock-Lot Purchase Links + +**Files:** +- Modify: `backend/app/schemas/operations.py` +- Modify: `backend/app/services/logistics.py` +- Modify: `backend/app/services/operations.py` +- Test: `backend/tests/test_raw_material_return_outbound.py` + +- [ ] **Step 1: Add failing tests for stock-lot purchase links and logistics optional freight** + +Append these tests to `RawMaterialReturnOutboundTest`: + +```python + def test_stock_lot_purchase_links_returns_receipt_purchase_order_item(self) -> None: + from app.services.operations import get_stock_lot_purchase_links + + links = get_stock_lot_purchase_links(self.db, self.lot.id) + + self.assertEqual(len(links), 1) + self.assertEqual(links[0]["purchase_order_id"], self.purchase_order.id) + self.assertEqual(links[0]["purchase_order_item_id"], self.purchase_order_item.id) + self.assertEqual(links[0]["po_no"], "采购2026-00001") + self.assertEqual(float(links[0]["accepted_weight_kg"]), 500) + self.assertEqual(float(links[0]["returned_weight_kg"]), 0) + self.assertEqual(float(links[0]["returnable_weight_kg"]), 500) + + def test_logistics_can_require_waybill_or_photo_without_freight(self) -> None: + from app.services.logistics import normalize_logistics_fields + + waybill, freight, photo_url = normalize_logistics_fields( + None, + None, + required=True, + freight_required=False, + order_photo_url="/inventory/logistics-photos/test.jpg", + ) + + self.assertIsNone(waybill) + self.assertIsNone(freight) + self.assertEqual(photo_url, "/inventory/logistics-photos/test.jpg") +``` + +- [ ] **Step 2: Run the new tests and verify they fail** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest \ + tests/test_raw_material_return_outbound.py::RawMaterialReturnOutboundTest::test_stock_lot_purchase_links_returns_receipt_purchase_order_item \ + tests/test_raw_material_return_outbound.py::RawMaterialReturnOutboundTest::test_logistics_can_require_waybill_or_photo_without_freight \ + -q +``` + +Expected: FAIL because `get_stock_lot_purchase_links` and `freight_required` do not exist. + +- [ ] **Step 3: Add schemas** + +Modify `backend/app/schemas/operations.py` after `PurchaseReceiptItemRead`: + +```python +class StockLotPurchaseLinkRead(BaseModel): + purchase_order_id: int + po_no: str + purchase_order_item_id: int + material_item_id: int + material_code: str + material_name: str + lot_id: int + lot_no: str + accepted_weight_kg: float + returned_weight_kg: float + returnable_weight_kg: float + unit_cost: float + + +class RawMaterialReturnOutboundItemCreate(BaseModel): + lot_id: int + purchase_order_item_id: int + return_weight_kg: float + reason: str | None = None + remark: str | None = None + + +class RawMaterialReturnOutboundCreate(BaseModel): + warehouse_id: int + return_date: datetime | None = None + waybill_no: str | None = None + order_photo_url: str | None = None + freight_amount: float | None = None + reason: str | None = None + remark: str | None = None + items: list[RawMaterialReturnOutboundItemCreate] + + +class PurchaseReturnItemRead(BaseModel): + purchase_return_item_id: int + purchase_return_id: int + line_no: int + purchase_order_item_id: int + material_item_id: int + material_code: str + material_name: str + lot_id: int + lot_no: str + return_weight_kg: float + unit_cost: float + amount: float + reason: str | None = None + status: str + remark: str | None = None + + +class PurchaseReturnRead(BaseModel): + purchase_return_id: int + return_no: str + purchase_order_id: int + po_no: str + warehouse_id: int + warehouse_name: str + return_date: datetime + logistics_waybill_no: str | None = None + logistics_freight_amount: float | None = None + logistics_photo_url: str | None = None + reason: str | None = None + status: str + remark: str | None = None + total_return_weight_kg: float + total_amount: float + items: list[PurchaseReturnItemRead] = Field(default_factory=list) +``` + +Modify `PurchaseOrderRead`: + +```python + has_return: bool = False + total_return_weight_kg: float = 0 +``` + +Modify `PurchaseOrderItemRead`: + +```python + returned_weight_kg: float = 0 + net_received_weight_kg: float = 0 + has_return: bool = False +``` + +- [ ] **Step 4: Extend logistics normalization without breaking existing callers** + +Modify `backend/app/services/logistics.py`: + +```python +def normalize_logistics_fields( + waybill_no: str | None, + freight_amount: Any, + *, + required: bool, + order_photo_url: str | None = None, + freight_required: bool | None = None, +) -> tuple[str | None, Decimal | None, str | None]: + clean_waybill = str(waybill_no or "").strip() + clean_photo_url = str(order_photo_url or "").strip() + freight_missing = freight_amount is None or str(freight_amount).strip() == "" + must_have_freight = required if freight_required is None else freight_required + + if required and (not clean_waybill and not clean_photo_url): + raise HTTPException(status_code=400, detail="该出入库业务必须填写运单号或上传订单照片") + if must_have_freight and freight_missing: + raise HTTPException(status_code=400, detail="该出入库业务必须填写运费") + if freight_missing: + return clean_waybill or None, None, clean_photo_url or None + + freight = to_decimal(freight_amount, "0.01") + if freight < 0: + raise HTTPException(status_code=400, detail="运费不能小于0") + return clean_waybill or None, freight, clean_photo_url or None +``` + +- [ ] **Step 5: Add stock-lot purchase-link helper** + +Modify imports in `backend/app/services/operations.py` to include the new return models: + +```python + PurchaseReturn, + PurchaseReturnItem, +``` + +Add this helper near `get_stock_lot_options_query`: + +```python +def get_stock_lot_purchase_links(db: Session, lot_id: int) -> list[dict[str, object]]: + lot = db.get(StockLot, lot_id) + if not lot: + raise HTTPException(status_code=404, detail="库存批次不存在") + + returned_subquery = ( + select( + PurchaseReturnItem.purchase_order_item_id.label("purchase_order_item_id"), + PurchaseReturnItem.lot_id.label("lot_id"), + func.coalesce(func.sum(PurchaseReturnItem.return_weight_kg), 0).label("returned_weight_kg"), + ) + .join(PurchaseReturn, PurchaseReturn.id == PurchaseReturnItem.purchase_return_id) + .where(PurchaseReturn.status != "CANCELED") + .group_by(PurchaseReturnItem.purchase_order_item_id, PurchaseReturnItem.lot_id) + .subquery() + ) + + accepted_weight = func.coalesce(func.sum(PurchaseReceiptItem.accepted_weight_kg), 0) + returned_weight = func.coalesce(returned_subquery.c.returned_weight_kg, 0) + stmt = ( + select( + PurchaseOrder.id.label("purchase_order_id"), + PurchaseOrder.po_no.label("po_no"), + PurchaseOrderItem.id.label("purchase_order_item_id"), + PurchaseOrderItem.material_item_id.label("material_item_id"), + Item.item_code.label("material_code"), + Item.item_name.label("material_name"), + StockLot.id.label("lot_id"), + StockLot.lot_no.label("lot_no"), + accepted_weight.label("accepted_weight_kg"), + returned_weight.label("returned_weight_kg"), + case( + (accepted_weight - returned_weight > StockLot.remaining_weight_kg, StockLot.remaining_weight_kg), + else_=accepted_weight - returned_weight, + ).label("returnable_weight_kg"), + PurchaseReceiptItem.unit_cost.label("unit_cost"), + ) + .join(PurchaseReceipt, PurchaseReceipt.id == PurchaseReceiptItem.receipt_id) + .join(PurchaseOrder, PurchaseOrder.id == PurchaseReceipt.purchase_order_id) + .join(PurchaseOrderItem, PurchaseOrderItem.id == PurchaseReceiptItem.purchase_order_item_id) + .join(Item, Item.id == PurchaseReceiptItem.material_item_id) + .join(StockLot, StockLot.id == lot_id) + .outerjoin( + returned_subquery, + (returned_subquery.c.purchase_order_item_id == PurchaseOrderItem.id) + & (returned_subquery.c.lot_id == StockLot.id), + ) + .where( + PurchaseReceiptItem.material_item_id == lot.item_id, + PurchaseReceiptItem.lot_no == lot.lot_no, + PurchaseReceiptItem.status == "PASSED", + ) + .group_by( + PurchaseOrder.id, + PurchaseOrder.po_no, + PurchaseOrderItem.id, + PurchaseOrderItem.material_item_id, + Item.item_code, + Item.item_name, + StockLot.id, + StockLot.lot_no, + returned_subquery.c.returned_weight_kg, + PurchaseReceiptItem.unit_cost, + StockLot.remaining_weight_kg, + ) + .order_by(PurchaseOrder.id.asc(), PurchaseOrderItem.line_no.asc()) + ) + return [dict(row) for row in db.execute(stmt).mappings().all()] +``` + +- [ ] **Step 6: Run tests and verify they pass** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest \ + tests/test_raw_material_return_outbound.py::RawMaterialReturnOutboundTest::test_stock_lot_purchase_links_returns_receipt_purchase_order_item \ + tests/test_raw_material_return_outbound.py::RawMaterialReturnOutboundTest::test_logistics_can_require_waybill_or_photo_without_freight \ + -q +``` + +Expected: PASS. + +- [ ] **Step 7: Commit this task if possible** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +git status --short +git add backend/app/schemas/operations.py backend/app/services/logistics.py backend/app/services/operations.py backend/tests/test_raw_material_return_outbound.py +git commit -m "feat: add raw material return link schemas" +``` + +Expected: commit succeeds when the workspace has `.git`; otherwise continue without committing. + +--- + +### Task 3: Implement Raw Material Return Outbound Backend Flow + +**Files:** +- Modify: `backend/app/api/routes/inventory.py` +- Modify: `backend/app/services/operations.py` +- Test: `backend/tests/test_raw_material_return_outbound.py` + +- [ ] **Step 1: Add failing tests for return outbound** + +Append these tests to `RawMaterialReturnOutboundTest`: + +```python + def test_raw_material_return_outbound_deducts_lot_balance_and_creates_records(self) -> None: + from app.api.routes.inventory import create_raw_material_return_outbound + from app.schemas.operations import RawMaterialReturnOutboundCreate, RawMaterialReturnOutboundItemCreate + + result = create_raw_material_return_outbound( + RawMaterialReturnOutboundCreate( + warehouse_id=self.raw_warehouse.id, + waybill_no="YT123456", + freight_amount=None, + reason="性能不合格", + remark="退回供应商", + items=[ + RawMaterialReturnOutboundItemCreate( + lot_id=self.lot.id, + purchase_order_item_id=self.purchase_order_item.id, + return_weight_kg=80, + reason="硬度不达标", + ) + ], + ), + context=Context(), + db=self.db, + ) + + self.db.refresh(self.lot) + self.db.refresh(self.balance) + txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "PURCHASE_RETURN_OUT")) + saved_return = self.db.scalar(select(PurchaseReturn).where(PurchaseReturn.return_no == result.return_no)) + saved_item = self.db.scalar(select(PurchaseReturnItem).where(PurchaseReturnItem.purchase_return_id == saved_return.id)) + + self.assertEqual(result.status, "CONFIRMED") + self.assertEqual(result.logistics_waybill_no, "YT123456") + self.assertEqual(float(result.total_return_weight_kg), 80) + self.assertEqual(float(self.lot.remaining_weight_kg), 420) + self.assertEqual(float(self.balance.weight_on_hand_kg), 420) + self.assertEqual(float(self.balance.weight_available_kg), 420) + self.assertIsNotNone(txn) + self.assertEqual(txn.source_doc_type, "PURCHASE_RETURN") + self.assertEqual(txn.source_doc_id, saved_return.id) + self.assertEqual(txn.source_line_id, saved_item.id) + self.assertEqual(float(txn.weight_change_kg), -80) + self.assertEqual(txn.logistics_waybill_no, "YT123456") + + def test_raw_material_return_requires_waybill_or_photo(self) -> None: + from fastapi import HTTPException + from app.api.routes.inventory import create_raw_material_return_outbound + from app.schemas.operations import RawMaterialReturnOutboundCreate, RawMaterialReturnOutboundItemCreate + + with self.assertRaises(HTTPException) as exc: + create_raw_material_return_outbound( + RawMaterialReturnOutboundCreate( + warehouse_id=self.raw_warehouse.id, + reason="性能不合格", + items=[ + RawMaterialReturnOutboundItemCreate( + lot_id=self.lot.id, + purchase_order_item_id=self.purchase_order_item.id, + return_weight_kg=80, + ) + ], + ), + context=Context(), + db=self.db, + ) + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("运单号或上传订单照片", exc.exception.detail) + + def test_raw_material_return_rejects_weight_above_remaining(self) -> None: + from fastapi import HTTPException + from app.api.routes.inventory import create_raw_material_return_outbound + from app.schemas.operations import RawMaterialReturnOutboundCreate, RawMaterialReturnOutboundItemCreate + + with self.assertRaises(HTTPException) as exc: + create_raw_material_return_outbound( + RawMaterialReturnOutboundCreate( + warehouse_id=self.raw_warehouse.id, + order_photo_url="/inventory/logistics-photos/return.jpg", + reason="性能不合格", + items=[ + RawMaterialReturnOutboundItemCreate( + lot_id=self.lot.id, + purchase_order_item_id=self.purchase_order_item.id, + return_weight_kg=501, + ) + ], + ), + context=Context(), + db=self.db, + ) + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("退货重量不能超过批次可用重量", exc.exception.detail) +``` + +- [ ] **Step 2: Run tests and verify they fail** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest \ + tests/test_raw_material_return_outbound.py::RawMaterialReturnOutboundTest::test_raw_material_return_outbound_deducts_lot_balance_and_creates_records \ + tests/test_raw_material_return_outbound.py::RawMaterialReturnOutboundTest::test_raw_material_return_requires_waybill_or_photo \ + tests/test_raw_material_return_outbound.py::RawMaterialReturnOutboundTest::test_raw_material_return_rejects_weight_above_remaining \ + -q +``` + +Expected: FAIL because `create_raw_material_return_outbound` does not exist. + +- [ ] **Step 3: Add return number builder and read helper** + +Modify imports in `backend/app/api/routes/inventory.py`: + +```python +from app.models.operations import ( + PurchaseOrder, + PurchaseOrderItem, + PurchaseReceipt, + PurchaseReceiptItem, + PurchaseReturn, + PurchaseReturnItem, + StockLot, + Stocktake, + StocktakeLine, + StocktakeWarehouse, + WarehouseLocation, +) +``` + +Add schema imports: + +```python + PurchaseReturnRead, + RawMaterialReturnOutboundCreate, + StockLotPurchaseLinkRead, +``` + +Add service import: + +```python + get_stock_lot_purchase_links, +``` + +Add helpers near `_build_customer_supplied_lot_no`: + +```python +def _build_purchase_return_no(db: Session) -> str: + year = datetime.now().strftime("%Y") + prefix = f"退货{year}-" + rows = db.scalars(select(PurchaseReturn.return_no).where(PurchaseReturn.return_no.like(f"{prefix}%"))).all() + max_no = 0 + for value in rows: + tail = str(value or "").replace(prefix, "", 1) + if tail.isdigit(): + max_no = max(max_no, int(tail)) + return f"{prefix}{max_no + 1:05d}" + + +def _purchase_return_read(db: Session, purchase_return_id: int) -> PurchaseReturnRead: + rows = db.execute( + select( + PurchaseReturn.id.label("purchase_return_id"), + PurchaseReturn.return_no.label("return_no"), + PurchaseReturn.purchase_order_id.label("purchase_order_id"), + PurchaseOrder.po_no.label("po_no"), + PurchaseReturn.warehouse_id.label("warehouse_id"), + Warehouse.warehouse_name.label("warehouse_name"), + PurchaseReturn.return_date.label("return_date"), + PurchaseReturn.logistics_waybill_no.label("logistics_waybill_no"), + PurchaseReturn.logistics_freight_amount.label("logistics_freight_amount"), + PurchaseReturn.logistics_photo_url.label("logistics_photo_url"), + PurchaseReturn.reason.label("reason"), + PurchaseReturn.status.label("status"), + PurchaseReturn.remark.label("remark"), + PurchaseReturnItem.id.label("purchase_return_item_id"), + PurchaseReturnItem.line_no.label("line_no"), + PurchaseReturnItem.purchase_order_item_id.label("purchase_order_item_id"), + PurchaseReturnItem.material_item_id.label("material_item_id"), + Item.item_code.label("material_code"), + Item.item_name.label("material_name"), + PurchaseReturnItem.lot_id.label("lot_id"), + StockLot.lot_no.label("lot_no"), + PurchaseReturnItem.return_weight_kg.label("return_weight_kg"), + PurchaseReturnItem.unit_cost.label("unit_cost"), + PurchaseReturnItem.amount.label("amount"), + PurchaseReturnItem.reason.label("item_reason"), + PurchaseReturnItem.status.label("item_status"), + PurchaseReturnItem.remark.label("item_remark"), + ) + .join(PurchaseOrder, PurchaseOrder.id == PurchaseReturn.purchase_order_id) + .join(Warehouse, Warehouse.id == PurchaseReturn.warehouse_id) + .join(PurchaseReturnItem, PurchaseReturnItem.purchase_return_id == PurchaseReturn.id) + .join(Item, Item.id == PurchaseReturnItem.material_item_id) + .join(StockLot, StockLot.id == PurchaseReturnItem.lot_id) + .where(PurchaseReturn.id == purchase_return_id) + .order_by(PurchaseReturnItem.line_no.asc()) + ).mappings().all() + if not rows: + raise HTTPException(status_code=500, detail="采购退货出库单读取失败") + first = rows[0] + items = [ + { + "purchase_return_item_id": row["purchase_return_item_id"], + "purchase_return_id": row["purchase_return_id"], + "line_no": row["line_no"], + "purchase_order_item_id": row["purchase_order_item_id"], + "material_item_id": row["material_item_id"], + "material_code": row["material_code"], + "material_name": row["material_name"], + "lot_id": row["lot_id"], + "lot_no": row["lot_no"], + "return_weight_kg": row["return_weight_kg"], + "unit_cost": row["unit_cost"], + "amount": row["amount"], + "reason": row["item_reason"], + "status": row["item_status"], + "remark": row["item_remark"], + } + for row in rows + ] + return PurchaseReturnRead.model_validate( + { + "purchase_return_id": first["purchase_return_id"], + "return_no": first["return_no"], + "purchase_order_id": first["purchase_order_id"], + "po_no": first["po_no"], + "warehouse_id": first["warehouse_id"], + "warehouse_name": first["warehouse_name"], + "return_date": first["return_date"], + "logistics_waybill_no": first["logistics_waybill_no"], + "logistics_freight_amount": first["logistics_freight_amount"], + "logistics_photo_url": first["logistics_photo_url"], + "reason": first["reason"], + "status": first["status"], + "remark": first["remark"], + "total_return_weight_kg": sum(to_decimal(row["return_weight_kg"]) for row in rows), + "total_amount": sum(to_decimal(row["amount"], "0.01") for row in rows), + "items": items, + } + ) +``` + +- [ ] **Step 4: Add endpoints** + +Add to `backend/app/api/routes/inventory.py` near the stock-lot routes: + +```python +@router.get("/stock-lots/{lot_id}/purchase-links", response_model=list[StockLotPurchaseLinkRead]) +def list_stock_lot_purchase_links(lot_id: int, db: Session = Depends(get_db)) -> list[StockLotPurchaseLinkRead]: + return [StockLotPurchaseLinkRead.model_validate(row) for row in get_stock_lot_purchase_links(db, lot_id)] +``` + +Add after `create_warehouse_outbound`: + +```python +@router.post("/raw-material-return-outbound", response_model=PurchaseReturnRead) +def create_raw_material_return_outbound( + payload: RawMaterialReturnOutboundCreate, + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> PurchaseReturnRead: + if not payload.items: + raise HTTPException(status_code=400, detail="退货出库至少需要一条明细") + + warehouse = db.get(Warehouse, payload.warehouse_id) + if not warehouse: + raise HTTPException(status_code=404, detail="仓库不存在") + if str(warehouse.warehouse_type or "").upper() != "RAW": + raise HTTPException(status_code=400, detail="退货出库只能在原材料库执行") + ensure_warehouses_unlocked(db, [warehouse.id], "原材料退货出库") + + logistics_waybill_no, logistics_freight_amount, logistics_photo_url = normalize_logistics_fields( + payload.waybill_no, + payload.freight_amount, + required=True, + freight_required=False, + order_photo_url=payload.order_photo_url, + ) + + purchase_order_ids: set[int] = set() + now = datetime.now() + return_rows: list[tuple[object, StockLot, PurchaseOrderItem, Decimal]] = [] + seen_lot_ids: set[int] = set() + for item_payload in payload.items: + if item_payload.lot_id in seen_lot_ids: + raise HTTPException(status_code=400, detail="同一库存批次不能在一张退货单中重复选择") + seen_lot_ids.add(item_payload.lot_id) + + lot = db.get(StockLot, item_payload.lot_id) + if not lot: + raise HTTPException(status_code=404, detail="库存批次不存在") + if int(lot.warehouse_id) != int(warehouse.id): + raise HTTPException(status_code=400, detail=f"库存批次 {lot.lot_no} 不属于当前原材料库") + if lot.status != "AVAILABLE" or lot.quality_status != "PASS": + raise HTTPException(status_code=400, detail=f"库存批次 {lot.lot_no} 当前状态不能退货出库") + + po_item = db.get(PurchaseOrderItem, item_payload.purchase_order_item_id) + if not po_item: + raise HTTPException(status_code=404, detail="采购订单明细不存在") + if int(po_item.material_item_id) != int(lot.item_id): + raise HTTPException(status_code=400, detail=f"库存批次 {lot.lot_no} 与采购订单明细物料不一致") + + links = get_stock_lot_purchase_links(db, lot.id) + matched_link = next( + (link for link in links if int(link["purchase_order_item_id"]) == int(po_item.id)), + None, + ) + if not matched_link: + raise HTTPException(status_code=400, detail=f"库存批次 {lot.lot_no} 未关联所选采购订单明细") + + return_weight = to_decimal(item_payload.return_weight_kg) + if return_weight <= 0: + raise HTTPException(status_code=400, detail="退货重量必须大于0") + usable_weight = to_decimal(lot.remaining_weight_kg) - to_decimal(lot.locked_weight_kg) + if return_weight > usable_weight: + raise HTTPException(status_code=400, detail=f"退货重量不能超过批次可用重量:{lot.lot_no}") + if return_weight > to_decimal(matched_link["returnable_weight_kg"]): + raise HTTPException(status_code=400, detail=f"退货重量不能超过该采购明细可退重量:{lot.lot_no}") + + purchase_order_ids.add(int(po_item.purchase_order_id)) + return_rows.append((item_payload, lot, po_item, return_weight)) + + if len(purchase_order_ids) != 1: + raise HTTPException(status_code=400, detail="一张退货出库单只能对应一个采购订单,请分开登记") + purchase_order_id = next(iter(purchase_order_ids)) + + purchase_return = PurchaseReturn( + return_no=_build_purchase_return_no(db), + purchase_order_id=purchase_order_id, + warehouse_id=warehouse.id, + return_date=payload.return_date or now, + operator_user_id=context.user.id, + logistics_waybill_no=logistics_waybill_no, + logistics_freight_amount=logistics_freight_amount, + logistics_photo_url=logistics_photo_url, + reason=payload.reason, + status="CONFIRMED", + remark=payload.remark, + created_at=now, + updated_at=now, + ) + db.add(purchase_return) + db.flush() + + for line_no, (item_payload, lot, po_item, return_weight) in enumerate(return_rows, start=1): + unit_cost = to_decimal(lot.unit_cost, "0.0001") + amount = to_decimal(return_weight * unit_cost, "0.01") + return_item = PurchaseReturnItem( + purchase_return_id=purchase_return.id, + line_no=line_no, + purchase_order_item_id=po_item.id, + material_item_id=lot.item_id, + lot_id=lot.id, + return_weight_kg=return_weight, + unit_cost=unit_cost, + amount=amount, + reason=item_payload.reason or payload.reason, + status="CONFIRMED", + remark=item_payload.remark, + created_at=now, + updated_at=now, + ) + db.add(return_item) + db.flush() + + lot.remaining_qty = Decimal("0") + lot.remaining_weight_kg = to_decimal(lot.remaining_weight_kg) - return_weight + if to_decimal(lot.remaining_weight_kg) <= 0: + lot.status = "DEPLETED" + db.add(lot) + + upsert_stock_balance( + db, + item_id=lot.item_id, + warehouse_id=lot.warehouse_id, + location_id=lot.location_id, + qty_delta=Decimal("0"), + weight_delta=-return_weight, + available_qty_delta=Decimal("0"), + available_weight_delta=-return_weight, + unit_cost=unit_cost, + ) + create_inventory_txn( + db, + txn_type="PURCHASE_RETURN_OUT", + item_id=lot.item_id, + warehouse_id=lot.warehouse_id, + location_id=lot.location_id, + lot_id=lot.id, + qty_change=Decimal("0"), + weight_change=-return_weight, + unit_cost=unit_cost, + source_doc_type="PURCHASE_RETURN", + source_doc_id=purchase_return.id, + source_line_id=return_item.id, + biz_time=purchase_return.return_date, + operator_user_id=context.user.id, + remark=item_payload.reason or payload.reason or "原材料退货出库", + amount_basis="WEIGHT", + logistics_waybill_no=logistics_waybill_no, + logistics_freight_amount=logistics_freight_amount, + logistics_photo_url=logistics_photo_url, + ) + + db.commit() + return _purchase_return_read(db, purchase_return.id) +``` + +- [ ] **Step 5: Add labels to backend maps** + +In `backend/app/api/routes/inventory.py`, add: + +```python +_OUTBOUND_BIZ_MAP = { + "PRODUCTION_OUT": ("PRODUCTION_OUT", "PRODUCTION_OUT"), + "OUTSOURCING_OUT": ("OUTSOURCING_OUT", "OUTSOURCING_OUT"), + "SCRAP_OUT": ("SCRAP_OUT", "SCRAP_OUT"), + "PURCHASE_RETURN_OUT":("PURCHASE_RETURN_OUT", "PURCHASE_RETURN"), + "SCRAP_SALE_OUT": ("SCRAP_SALE_OUT", "SCRAP_SALE_OUT"), + "WIP_OUT": ("WIP_OUT", "WIP_OUT"), + "SALES_OUT": ("SALES_OUT", "SALES_OUT"), +} +``` + +Add the raw permission if the generic endpoint is ever called with this type: + +```python +_ALLOWED_OUTBOUND_BY_WAREHOUSE_TYPE = { + "RAW": {"OUTSOURCING_OUT", "SCRAP_OUT", "PURCHASE_RETURN_OUT"}, + "SEMI": {"WIP_OUT", "OUTSOURCING_OUT"}, + "FINISHED": {"SALES_OUT"}, + "AUX": {"PRODUCTION_OUT"}, + "SCRAP": {"SCRAP_SALE_OUT"}, +} +``` + +Do not route the frontend through generic `/inventory/outbound`; use the dedicated endpoint so the purchase order link is enforced. + +- [ ] **Step 6: Run backend return tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest tests/test_raw_material_return_outbound.py -q +``` + +Expected: all tests in the file PASS. + +- [ ] **Step 7: Commit this task if possible** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +git status --short +git add backend/app/api/routes/inventory.py backend/app/services/operations.py backend/tests/test_raw_material_return_outbound.py +git commit -m "feat: implement raw material return outbound" +``` + +Expected: commit succeeds when the workspace has `.git`; otherwise continue without committing. + +--- + +### Task 4: Add Purchase Order Return Warning Overlay + +**Files:** +- Modify: `backend/app/services/operations.py` +- Modify: `backend/app/schemas/operations.py` +- Modify: `frontend/src/utils/dictionaries.js` +- Modify: `frontend/src/utils/statusBadges.js` +- Modify: `frontend/src/views/PurchaseOrderView.vue` +- Test: `backend/tests/test_raw_material_return_outbound.py` + +- [ ] **Step 1: Add failing backend test for purchase order overlay** + +Append this test to `RawMaterialReturnOutboundTest`: + +```python + def test_purchase_order_query_preserves_status_and_marks_has_return(self) -> None: + from app.api.routes.inventory import create_raw_material_return_outbound + from app.schemas.operations import RawMaterialReturnOutboundCreate, RawMaterialReturnOutboundItemCreate + from app.services.operations import get_purchase_order_items_query, get_purchase_order_query + + create_raw_material_return_outbound( + RawMaterialReturnOutboundCreate( + warehouse_id=self.raw_warehouse.id, + waybill_no="YT123456", + reason="性能不合格", + items=[ + RawMaterialReturnOutboundItemCreate( + lot_id=self.lot.id, + purchase_order_item_id=self.purchase_order_item.id, + return_weight_kg=80, + ) + ], + ), + context=Context(), + db=self.db, + ) + + order_row = self.db.execute(get_purchase_order_query(limit=1).where(PurchaseOrder.id == self.purchase_order.id)).mappings().first() + item_row = self.db.execute( + get_purchase_order_items_query(limit=1, purchase_order_id=self.purchase_order.id) + ).mappings().first() + + self.assertEqual(order_row["display_status"], "RECEIVED") + self.assertTrue(order_row["has_return"]) + self.assertEqual(float(order_row["total_return_weight_kg"]), 80) + self.assertTrue(item_row["has_return"]) + self.assertEqual(float(item_row["returned_weight_kg"]), 80) + self.assertEqual(float(item_row["net_received_weight_kg"]), 420) +``` + +- [ ] **Step 2: Run the overlay test and verify it fails** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest tests/test_raw_material_return_outbound.py::RawMaterialReturnOutboundTest::test_purchase_order_query_preserves_status_and_marks_has_return -q +``` + +Expected: FAIL because the query does not return `has_return`, `total_return_weight_kg`, `returned_weight_kg`, or `net_received_weight_kg`. + +- [ ] **Step 3: Add return aggregation to purchase order query** + +Modify `get_purchase_order_query` in `backend/app/services/operations.py`. Add this subquery before `line_subquery`: + +```python + return_item_subquery = ( + select( + PurchaseReturnItem.purchase_order_item_id.label("purchase_order_item_id"), + func.coalesce(func.sum(PurchaseReturnItem.return_weight_kg), 0).label("returned_weight_kg"), + func.count(PurchaseReturnItem.id).label("return_count"), + ) + .join(PurchaseReturn, PurchaseReturn.id == PurchaseReturnItem.purchase_return_id) + .where(PurchaseReturn.status != "CANCELED") + .group_by(PurchaseReturnItem.purchase_order_item_id) + .subquery() + ) +``` + +Update `line_subquery` to outer join this subquery: + +```python + .outerjoin(return_item_subquery, return_item_subquery.c.purchase_order_item_id == PurchaseOrderItem.id) +``` + +Add these selected fields to `line_subquery`: + +```python + func.coalesce(func.sum(return_item_subquery.c.returned_weight_kg), 0).label("total_return_weight_kg"), + func.coalesce(func.sum(return_item_subquery.c.return_count), 0).label("return_count"), +``` + +Add expressions after `rejected_count`: + +```python + total_return_weight = func.coalesce(line_subquery.c.total_return_weight_kg, 0) + return_count = func.coalesce(line_subquery.c.return_count, 0) +``` + +Add selected fields in the final select: + +```python + (return_count > 0).label("has_return"), + total_return_weight.label("total_return_weight_kg"), +``` + +- [ ] **Step 4: Add return aggregation to purchase order item query** + +Modify `get_purchase_order_items_query` in `backend/app/services/operations.py`. Add this subquery near `receipt_item_subquery`: + +```python + return_item_subquery = ( + select( + PurchaseReturnItem.purchase_order_item_id.label("purchase_order_item_id"), + func.coalesce(func.sum(PurchaseReturnItem.return_weight_kg), 0).label("returned_weight_kg"), + func.count(PurchaseReturnItem.id).label("return_count"), + ) + .join(PurchaseReturn, PurchaseReturn.id == PurchaseReturnItem.purchase_return_id) + .where(PurchaseReturn.status != "CANCELED") + .group_by(PurchaseReturnItem.purchase_order_item_id) + .subquery() + ) +``` + +Add expressions: + +```python + returned_weight = func.coalesce(return_item_subquery.c.returned_weight_kg, 0) + net_received_weight = case((accepted_weight - returned_weight > 0, accepted_weight - returned_weight), else_=0) +``` + +Add selected fields: + +```python + returned_weight.label("returned_weight_kg"), + net_received_weight.label("net_received_weight_kg"), + (func.coalesce(return_item_subquery.c.return_count, 0) > 0).label("has_return"), +``` + +Add the outer join: + +```python + .outerjoin(return_item_subquery, return_item_subquery.c.purchase_order_item_id == PurchaseOrderItem.id) +``` + +- [ ] **Step 5: Run backend overlay tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest tests/test_raw_material_return_outbound.py::RawMaterialReturnOutboundTest::test_purchase_order_query_preserves_status_and_marks_has_return -q +``` + +Expected: PASS. + +- [ ] **Step 6: Add frontend labels and warning tone** + +Modify `frontend/src/utils/dictionaries.js`: + +```javascript +export const STATUS_LABELS = { + ACTIVE: "启用", + INACTIVE: "停用", + OPEN: "未开始", + PARTIAL: "部分完成", + PARTIAL_RECEIVED: "部分收货", + CLOSED: "已关闭", + ORDERED: "已下单", + RECEIVED: "已收货", + RELEASED: "已下达", + IN_PROGRESS: "进行中", + READY_TO_RECEIPT: "待成品入库", + COMPLETED: "已完工", + DONE: "已完成", + PENDING: "待处理", + PENDING_QC: "待质检", + PASSED: "已放行", + REJECTED: "已拒收", + POSTED: "已过账", + AVAILABLE: "可用", + LOCKED: "已锁单", + DEPLETED: "已耗尽", + CONFIRMED: "已确认", + OVERRIDDEN: "已覆盖", + CREATED: "已创建", + IDLE: "空闲", + SUPERSEDED: "已替代", + HAS_RETURN: "存在退货" +}; +``` + +Add inventory labels: + +```javascript +export const INVENTORY_TXN_TYPE_LABELS = { + PURCHASE_IN: "采购入库", + PURCHASE_RETURN_OUT: "采购退货出库", + CUSTOMER_SUPPLIED_IN: "客供料入库", + MATERIAL_ISSUE: "生产领料", + FG_IN: "成品入库", + SALES_OUT: "发货出库", + SCRAP_OUT: "报废出库", + PRODUCTION_SCRAP_IN: "生产废料入库", + OUTSOURCING_SCRAP_IN: "委外废料入库", + SCRAP_SALE_OUT: "废料售卖出库" +}; + +export const SOURCE_DOC_TYPE_LABELS = { + PURCHASE_RECEIPT: "采购到货", + PURCHASE_RETURN: "采购退货", + CUSTOMER_SUPPLIED_IN: "客供料入库", + WORK_ORDER: "生产工单", + FG_RECEIPT: "成品入库", + DELIVERY: "发货单", + SCRAP_OUT: "报废出库", + PRODUCTION_SCRAP_IN: "生产废料入库", + OUTSOURCING_SCRAP_IN: "委外废料入库", + SCRAP_SALE_OUT: "废料售卖出库" +}; +``` + +Modify `frontend/src/utils/statusBadges.js` warning values: + +```javascript + "存在退货", + "HAS_RETURN", +``` + +- [ ] **Step 7: Update purchase order status cell UI** + +In `frontend/src/views/PurchaseOrderView.vue`, replace the status cell: + +```vue + + {{ formatStatusLabel(order.display_status || order.status) }} + 存在退货 + +``` + +In the purchase order item table, add return fields near received weight: + +```vue +退货重量(kg) +净入库重量(kg) +``` + +Add corresponding cells: + +```vue +{{ formatWeight(item.returned_weight_kg) }} + + {{ formatWeight(item.net_received_weight_kg) }} + 存在退货 + +``` + +- [ ] **Step 8: Run backend tests and frontend build** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest tests/test_raw_material_return_outbound.py -q +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +npm run build +``` + +Expected: backend tests PASS. Frontend build succeeds; existing Vite chunk-size warning is acceptable if no new build error appears. + +- [ ] **Step 9: Commit this task if possible** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +git status --short +git add backend/app/services/operations.py backend/app/schemas/operations.py frontend/src/utils/dictionaries.js frontend/src/utils/statusBadges.js frontend/src/views/PurchaseOrderView.vue backend/tests/test_raw_material_return_outbound.py +git commit -m "feat: show purchase return warning status" +``` + +Expected: commit succeeds when the workspace has `.git`; otherwise continue without committing. + +--- + +### Task 5: Add Raw Material Return Outbound UI + +**Files:** +- Modify: `frontend/src/views/InventoryLedgerView.vue` +- Create: `frontend/scripts/test-inventory-ledger-raw-return.mjs` + +- [ ] **Step 1: Add static frontend test** + +Create `frontend/scripts/test-inventory-ledger-raw-return.mjs`: + +```javascript +import fs from "node:fs"; +import path from "node:path"; + +const root = process.cwd(); +const viewPath = path.join(root, "src/views/InventoryLedgerView.vue"); +const dictionariesPath = path.join(root, "src/utils/dictionaries.js"); + +const view = fs.readFileSync(viewPath, "utf8"); +const dictionaries = fs.readFileSync(dictionariesPath, "utf8"); + +const checks = [ + ["raw return operation button", view.includes("raw-return-out") && view.includes("退货出库")], + ["dedicated raw return drawer", view.includes("rawReturnDrawerOpen") && view.includes("原材料退货出库")], + ["purchase links endpoint", view.includes("/inventory/stock-lots/") && view.includes("/purchase-links")], + ["return outbound endpoint", view.includes("/inventory/raw-material-return-outbound")], + ["purchase return label", dictionaries.includes("PURCHASE_RETURN_OUT") && dictionaries.includes("采购退货出库")], +]; + +const failed = checks.filter(([, passed]) => !passed); +if (failed.length) { + console.error("Raw return UI checks failed:"); + failed.forEach(([name]) => console.error(`- ${name}`)); + process.exit(1); +} + +console.log("Raw return UI checks passed"); +``` + +- [ ] **Step 2: Run the static test and verify it fails** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node scripts/test-inventory-ledger-raw-return.mjs +``` + +Expected: FAIL because the UI has no return operation yet. + +- [ ] **Step 3: Add state and form model** + +In `frontend/src/views/InventoryLedgerView.vue`, add refs near other drawer refs: + +```javascript +const rawReturnDrawerOpen = ref(false); +const rawReturnPurchaseLinks = ref([]); +const rawReturnLinkLoading = ref(false); +``` + +Add form after `const deliveryForm = reactive(buildEmptyDeliveryForm());`: + +```javascript +const rawReturnForm = reactive(buildEmptyRawReturnForm()); +``` + +Add builder near other builders: + +```javascript +function buildEmptyRawReturnForm() { + return { + warehouse_id: "", + lot_id: "", + purchase_order_item_id: "", + return_weight_kg: 0, + waybill_no: "", + order_photo_url: "", + freight_amount: "", + reason: "", + remark: "" + }; +} +``` + +Add reset function: + +```javascript +function resetRawReturnForm() { + Object.assign(rawReturnForm, buildEmptyRawReturnForm()); + rawReturnForm.warehouse_id = activeWarehouses.value[0]?.id || ""; + rawReturnPurchaseLinks.value = []; +} +``` + +- [ ] **Step 4: Add raw return operation** + +In `operationMatrix.raw.outbound`, add: + +```javascript +makeOperation("raw-return-out", "out", "PURCHASE_RETURN_OUT", "退货出库", "入仓后发现质量或性能不合格的原材料退回供应商。", "rawReturn") +``` + +Keep the raw outbound order: + +```javascript +outbound: [ + makeOperation("raw-production-out", "out", "PRODUCTION_OUT", "生产出库", "选择原材料库存批次出库,后续小程序报工按库存批次号推进。", "production"), + makeOperation("raw-outsourcing-out", "out", "OUTSOURCING_OUT", "委外出库", "原材料出库给第三方加工。"), + makeOperation("raw-scrap-out", "out", "SCRAP_OUT", "报废出库", "原材料报废出库。"), + makeOperation("raw-return-out", "out", "PURCHASE_RETURN_OUT", "退货出库", "入仓后发现质量或性能不合格的原材料退回供应商。", "rawReturn") +] +``` + +Update `openOperationDrawer`: + +```javascript + if (operation.drawerType === "rawReturn") { + resetRawReturnForm(); + rawReturnDrawerOpen.value = true; + return; + } +``` + +Add close function: + +```javascript +function closeRawReturnDrawer() { + rawReturnDrawerOpen.value = false; +} +``` + +- [ ] **Step 5: Add computed lot options** + +Add: + +```javascript +const rawReturnLotOptions = computed(() => + lots.value + .filter( + (lot) => + normalizeWarehouseType(lot) === "RAW" && + lot.status === "AVAILABLE" && + lot.quality_status === "PASS" && + Number(lot.remaining_weight_kg || 0) > 0 + ) + .sort((left, right) => String(left.lot_no || "").localeCompare(String(right.lot_no || ""), "zh-CN", { numeric: true })) +); + +const selectedRawReturnLot = computed(() => + rawReturnLotOptions.value.find((lot) => Number(lot.lot_id) === Number(rawReturnForm.lot_id)) || null +); + +const selectedRawReturnLink = computed(() => + rawReturnPurchaseLinks.value.find((link) => Number(link.purchase_order_item_id) === Number(rawReturnForm.purchase_order_item_id)) || null +); +``` + +Add lot change loader: + +```javascript +async function handleRawReturnLotChange() { + rawReturnForm.purchase_order_item_id = ""; + rawReturnForm.return_weight_kg = 0; + rawReturnPurchaseLinks.value = []; + if (!rawReturnForm.lot_id) { + return; + } + rawReturnLinkLoading.value = true; + try { + const links = await fetchResource(`/inventory/stock-lots/${rawReturnForm.lot_id}/purchase-links`, []); + rawReturnPurchaseLinks.value = links; + if (links.length === 1) { + rawReturnForm.purchase_order_item_id = links[0].purchase_order_item_id; + rawReturnForm.return_weight_kg = Number(links[0].returnable_weight_kg || selectedRawReturnLot.value?.remaining_weight_kg || 0); + } + } catch (error) { + errorMessage.value = error.message || "采购来源读取失败"; + } finally { + rawReturnLinkLoading.value = false; + } +} +``` + +- [ ] **Step 6: Add drawer template** + +In `frontend/src/views/InventoryLedgerView.vue`, add this `FormDrawer` near the generic drawer: + +```vue + +
+ + + +
+ + +
+ +
+ + +
+ +
+ + + +
+ + + + + +
+ +
+
+
+``` + +- [ ] **Step 7: Add submit function** + +Add: + +```javascript +function validateRawReturnForm() { + if (!rawReturnForm.lot_id) { + return "请选择库存批次号"; + } + if (!rawReturnForm.purchase_order_item_id) { + return "请选择采购来源"; + } + if (Number(rawReturnForm.return_weight_kg || 0) <= 0) { + return "退货重量必须大于0"; + } + const lotWeight = Number(selectedRawReturnLot.value?.remaining_weight_kg || 0); + if (Number(rawReturnForm.return_weight_kg || 0) > lotWeight) { + return `退货重量不能超过批次剩余重量 ${formatWeight(lotWeight)}`; + } + const linkWeight = Number(selectedRawReturnLink.value?.returnable_weight_kg || 0); + if (selectedRawReturnLink.value && Number(rawReturnForm.return_weight_kg || 0) > linkWeight) { + return `退货重量不能超过采购来源可退重量 ${formatWeight(linkWeight)}`; + } + if (!String(rawReturnForm.waybill_no || "").trim() && !String(rawReturnForm.order_photo_url || "").trim()) { + return "请填写运单号或上传订单照片"; + } + if (!isFreightBlank(rawReturnForm.freight_amount) && Number(rawReturnForm.freight_amount) < 0) { + return "运费不能小于0"; + } + return ""; +} + +async function submitRawReturnOutbound() { + resetFeedback(); + const validationError = validateRawReturnForm(); + if (validationError) { + errorMessage.value = validationError; + return; + } + submittingOperation.value = true; + try { + const result = await postResource("/inventory/raw-material-return-outbound", { + warehouse_id: Number(rawReturnForm.warehouse_id), + waybill_no: rawReturnForm.waybill_no || null, + order_photo_url: rawReturnForm.order_photo_url || null, + freight_amount: isFreightBlank(rawReturnForm.freight_amount) ? null : Number(rawReturnForm.freight_amount), + reason: rawReturnForm.reason || null, + remark: rawReturnForm.remark || null, + items: [ + { + lot_id: Number(rawReturnForm.lot_id), + purchase_order_item_id: Number(rawReturnForm.purchase_order_item_id), + return_weight_kg: Number(rawReturnForm.return_weight_kg || 0), + reason: rawReturnForm.reason || null, + remark: rawReturnForm.remark || null + } + ] + }); + feedbackMessage.value = `退货出库 ${result.return_no} 已登记`; + rawReturnDrawerOpen.value = false; + await loadAll(); + } catch (error) { + errorMessage.value = error.message || "退货出库失败"; + } finally { + submittingOperation.value = false; + } +} +``` + +- [ ] **Step 8: Run static UI test and build** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node scripts/test-inventory-ledger-raw-return.mjs +npm run build +``` + +Expected: static test prints `Raw return UI checks passed`; build succeeds. + +- [ ] **Step 9: Commit this task if possible** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +git status --short +git add frontend/src/views/InventoryLedgerView.vue frontend/scripts/test-inventory-ledger-raw-return.mjs frontend/src/utils/dictionaries.js frontend/src/utils/statusBadges.js +git commit -m "feat: add raw material return outbound UI" +``` + +Expected: commit succeeds when the workspace has `.git`; otherwise continue without committing. + +--- + +### Task 6: Final Integration Verification + +**Files:** +- Verify: `backend/tests/test_raw_material_return_outbound.py` +- Verify: `backend/tests/test_raw_material_weight_only_flow.py` +- Verify: `backend/tests/test_inventory_lot_numbering.py` +- Verify: `backend/tests/test_sales_order_purchase_lock_flow.py` +- Verify: `frontend/scripts/test-inventory-ledger-raw-return.mjs` +- Verify: `frontend/src/views/InventoryLedgerView.vue` +- Verify: `frontend/src/views/PurchaseOrderView.vue` + +- [ ] **Step 1: Run targeted backend tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest \ + tests/test_raw_material_return_outbound.py \ + tests/test_raw_material_weight_only_flow.py \ + tests/test_inventory_lot_numbering.py \ + tests/test_sales_order_purchase_lock_flow.py \ + -q +``` + +Expected: PASS. + +- [ ] **Step 2: Run all backend tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest -q +``` + +Expected: PASS. + +- [ ] **Step 3: Run frontend static test and build** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node scripts/test-inventory-ledger-raw-return.mjs +npm run build +``` + +Expected: static test PASS; build succeeds. Existing Vite chunk-size warnings are acceptable when there are no build errors. + +- [ ] **Step 4: Run terminology and weight-only scans** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +rg -n "采购退货出库|PURCHASE_RETURN_OUT|存在退货" backend frontend +rg -n "原材料.*数量|数量.*原材料|return_qty|returned_qty" backend/app frontend/src +``` + +Expected: first command finds the new feature in planned files. Second command must not reveal new raw-material quantity fields introduced by this work; existing legacy model fields unrelated to the new UI are acceptable only if no new usage was added for raw return. + +- [ ] **Step 5: Manual browser verification** + +Run the backend and frontend using the project’s existing local start commands. If the commands are already running, reuse them. Then verify: + +```text +1. Open 嘉恒仓库. +2. Select 原材料库. +3. Confirm 出库 row shows 生产出库、委外出库、报废出库、退货出库. +4. Click 退货出库. +5. Select a stock lot created from purchase receipt. +6. Confirm purchase source auto-selects when only one purchase source exists. +7. Enter return weight lower than remaining weight. +8. Upload an order photo without freight. +9. Submit and confirm success toast. +10. Reopen stock lot detail and confirm remaining weight decreased. +11. Open inventory transaction drawer and confirm 采购退货出库 appears with the same lot. +12. Open 采购订单 list and confirm original lifecycle status remains visible and the warning chip 存在退货 appears. +``` + +Expected: all steps behave as written. + +- [ ] **Step 6: Final commit if possible** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +git status --short +git add backend frontend docs/superpowers/plans/2026-05-31-raw-material-return-out.md +git commit -m "feat: complete raw material return outbound" +``` + +Expected: commit succeeds when the workspace has `.git`; otherwise final notes state that commits were skipped because the workspace is not a git repository. + +--- + +## Self-Review + +- Spec coverage: The plan covers adding the raw warehouse operation, logistics proof requirement, lot-level inventory deduction, purchase-order warning overlay, status color labels, and backend/frontend tests. +- Placeholder scan: The plan contains concrete file paths, test commands, code snippets, and expected outcomes. It does not rely on vague implementation instructions. +- Type consistency: The plan consistently uses `PurchaseReturn`, `PurchaseReturnItem`, `RawMaterialReturnOutboundCreate`, `StockLotPurchaseLinkRead`, `PURCHASE_RETURN_OUT`, `PURCHASE_RETURN`, `has_return`, and `total_return_weight_kg`. diff --git a/docs/superpowers/plans/2026-05-31-return-warehouse.md b/docs/superpowers/plans/2026-05-31-return-warehouse.md new file mode 100644 index 0000000..b82e730 --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-return-warehouse.md @@ -0,0 +1,1605 @@ +# Return Warehouse Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add 嘉恒仓库“退货库” for customer-returned products that can be reworked, move the current “退货处理” entry into warehouse inbound operations, and add 废料库“退货废料入库” for customer-returned products judged as scrap. + +**Architecture:** Treat customer returns as inventory movement. `RETURN` warehouse lots represent returned finished goods waiting for rework and are locked until `返工出库`; customer-return scrap bypasses the return warehouse and enters `SCRAP` directly while retaining delivery-line and source-material traceability. The old 发货与售后退货入口 is removed from navigation, but existing return order/disposition tables remain the trace backbone. + +**Tech Stack:** FastAPI, SQLAlchemy ORM, Pydantic schemas, MySQL patch SQL, SQLite unit tests, Vue 3 SFC, Vite, existing warehouse ledger components. + +--- + +## Confirmed Business Rules + +- “发货与售后”的“退货处理”入口去掉,退货登记逻辑移动到“嘉恒仓库 -> 退货库 -> 入库 -> 退货入库”。 +- “退货库”只存放客户退回后判定可以返工的产品。 +- 退货库入库后的批次默认“待返工/锁定”,不能作为成品库存销售。 +- 退货库出库只有“返工出库”,出库后扣减退货库库存并生成返工处置/返工工单。 +- 客户退回后判定报废的产品不进入退货库,用户在“嘉恒仓库 -> 废料库 -> 入库 -> 退货废料入库”登记。 +- 退货库需要“期初入库”,用于初始化历史退货待返工库存。 +- 退货品明细必须展示发货单号或发货批次号,并通过发货明细追溯成品批次、原材料库存批次号、来源材料。 + +## File Structure + +- Create `backend/sql/add_return_warehouse_patch.sql` + - Add `RETURN` warehouse and default location. + - Add `delivery_item_id` to `rt_return_item`. +- Modify `backend/app/api/routes/master_data.py` + - Add 嘉恒退货库 definition and auto-ensure location. +- Modify `backend/app/api/routes/inventory.py` + - Add `RETURN` warehouse label, sample opening rows, inbound/outbound permissions, lot number rules, and customer-return scrap inbound operation. +- Modify `backend/app/services/stocktake.py` + - Include 退货库 labels and quantity/weight behavior in stocktake export/import. +- Modify `backend/app/models/operations.py` + - Add nullable `delivery_item_id` FK on `ReturnItem`. +- Modify `backend/app/schemas/operations.py` + - Add `delivery_item_id` to return schemas. + - Add read models for delivery-line trace options if needed by the UI. + - Add `RETURN_SCRAP_IN` / `RETURN_IN` / `RETURN_OUT` labels to allowed business types. +- Modify `backend/app/services/operations.py` + - Add delivery-line trace helper. + - Add return query fields for delivery item, finished lot, source material lot, and source material summary. +- Modify `backend/app/api/routes/returns.py` + - Strengthen return inbound validation to require a concrete delivery line for warehouse return flows. + - Add return-scrap inbound endpoint that creates scrap warehouse inventory directly. + - Restrict return-warehouse outbound to rework. +- Modify `frontend/src/views/InventoryLedgerView.vue` + - Add 退货库 tab. + - Add 退货库“期初入库 / 退货入库 / 返工出库”. + - Add 废料库“退货废料入库”. + - Add dedicated drawers for 退货入库、退货废料入库、返工出库. + - Load delivery and delivery-item trace data. +- Modify `frontend/src/views/DeliveryWorkspaceView.vue` + - Remove the 退货处理 section from 发货与售后. +- Modify `frontend/src/App.vue` + - Remove 退货处理 child menu from 发货与售后 route group. +- Modify `frontend/src/router/index.js` + - Keep or hide the old route so direct access does not remain as a visible menu. If keeping the route for compatibility, redirect it to `inventory-ledger`. +- Modify `frontend/src/utils/dictionaries.js` + - Add warehouse/txn/source labels for return warehouse and return scrap inbound. +- Modify `frontend/src/utils/inventoryOperationOptions.js` + - Treat return warehouse as product/finished-goods quantity+weight stock, not weight-only raw material. +- Create `backend/tests/test_return_warehouse_flow.py` + - Backend regression tests for warehouse creation, return inbound trace, rework outbound, return scrap inbound, and stocktake labels. +- Create or modify `frontend/scripts/test-inventory-ledger-return-warehouse.mjs` + - Static verification for new tab, operations, hidden old entry, labels, and trace fields. + +--- + +### Task 1: Add RETURN Warehouse Type, SQL Patch, And Opening/Stocktake Labels + +**Files:** +- Create: `backend/sql/add_return_warehouse_patch.sql` +- Modify: `backend/app/api/routes/master_data.py` +- Modify: `backend/app/api/routes/inventory.py` +- Modify: `backend/app/services/stocktake.py` +- Test: `backend/tests/test_return_warehouse_flow.py` + +- [ ] **Step 1: Write the failing warehouse-definition test** + +Create `backend/tests/test_return_warehouse_flow.py` with a SQLite fixture following the project’s existing pattern: + +```python +from __future__ import annotations + +import unittest +from datetime import UTC, datetime +from io import BytesIO + +from openpyxl import load_workbook +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Warehouse # noqa: E402 + + +class ReturnWarehouseFlowTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + + def tearDown(self) -> None: + self.db.close() + + def test_jiaheng_warehouses_include_return_warehouse(self) -> None: + from app.api.routes.master_data import _ensure_jiaheng_warehouses + + _ensure_jiaheng_warehouses(self.db) + + warehouse = self.db.scalar(select(Warehouse).where(Warehouse.warehouse_type == "RETURN")) + self.assertIsNotNone(warehouse) + self.assertEqual(warehouse.warehouse_code, "WH-RETURN-01") + self.assertEqual(warehouse.warehouse_name, "退货库") +``` + +- [ ] **Step 2: Run the failing warehouse-definition test** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest tests/test_return_warehouse_flow.py::ReturnWarehouseFlowTest::test_jiaheng_warehouses_include_return_warehouse -q +``` + +Expected: FAIL because `RETURN` is not included in `JIAHENG_WAREHOUSE_DEFINITIONS`. + +- [ ] **Step 3: Add the SQL patch** + +Create `backend/sql/add_return_warehouse_patch.sql`: + +```sql +INSERT INTO wh_warehouse (warehouse_code, warehouse_name, warehouse_type, manager_employee_id, status, remark) +SELECT 'WH-RETURN-01', '退货库', 'RETURN', NULL, 'ACTIVE', '客户退回且可返工产品的待处理库存' +WHERE NOT EXISTS (SELECT 1 FROM wh_warehouse WHERE warehouse_code = 'WH-RETURN-01'); + +UPDATE wh_warehouse +SET warehouse_name = '退货库', + warehouse_type = 'RETURN', + status = 'ACTIVE', + remark = '客户退回且可返工产品的待处理库存' +WHERE warehouse_code = 'WH-RETURN-01'; + +INSERT INTO wh_location ( + warehouse_id, + location_code, + location_name, + zone_name, + is_default, + is_locked, + status, + remark +) +SELECT + (SELECT id FROM wh_warehouse WHERE warehouse_code = 'WH-RETURN-01'), + 'RTN-F-01', + '退货暂存位', + 'F区', + 1, + 0, + 'ACTIVE', + '退货库默认库位' +WHERE NOT EXISTS ( + SELECT 1 + FROM wh_location + WHERE warehouse_id = (SELECT id FROM wh_warehouse WHERE warehouse_code = 'WH-RETURN-01') + AND location_code = 'RTN-F-01' +); + +UPDATE wh_location +SET location_name = '退货暂存位', + zone_name = 'F区', + is_default = 1, + is_locked = 0, + status = 'ACTIVE', + remark = '退货库默认库位' +WHERE warehouse_id = (SELECT id FROM wh_warehouse WHERE warehouse_code = 'WH-RETURN-01') + AND location_code = 'RTN-F-01'; + +SET @has_delivery_item_id := ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'rt_return_item' + AND COLUMN_NAME = 'delivery_item_id' +); + +SET @add_delivery_item_id_sql := IF( + @has_delivery_item_id = 0, + 'ALTER TABLE rt_return_item ADD COLUMN delivery_item_id BIGINT NULL AFTER sales_order_item_id', + 'SELECT 1' +); +PREPARE add_delivery_item_id_stmt FROM @add_delivery_item_id_sql; +EXECUTE add_delivery_item_id_stmt; +DEALLOCATE PREPARE add_delivery_item_id_stmt; + +SET @has_return_delivery_item_idx := ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'rt_return_item' + AND INDEX_NAME = 'idx_rt_return_item_delivery_item' +); + +SET @add_return_delivery_item_idx_sql := IF( + @has_return_delivery_item_idx = 0, + 'ALTER TABLE rt_return_item ADD KEY idx_rt_return_item_delivery_item (delivery_item_id)', + 'SELECT 1' +); +PREPARE add_return_delivery_item_idx_stmt FROM @add_return_delivery_item_idx_sql; +EXECUTE add_return_delivery_item_idx_stmt; +DEALLOCATE PREPARE add_return_delivery_item_idx_stmt; +``` + +- [ ] **Step 4: Add RETURN to backend warehouse definitions** + +Modify `backend/app/api/routes/master_data.py` by appending this entry to `JIAHENG_WAREHOUSE_DEFINITIONS` after the SCRAP warehouse: + +```python + { + "warehouse_code": "WH-RETURN-01", + "warehouse_name": "退货库", + "warehouse_type": "RETURN", + "location_code": "RTN-F-01", + "location_name": "退货暂存位", + "zone_name": "F区", + "remark": "客户退回且可返工产品的待处理库存", + }, +``` + +Update `_ensure_jiaheng_warehouses` to include `RETURN` in the existing `warehouse_type.in_(...)` list: + +```python +Warehouse.warehouse_type.in_(["RAW", "SEMI", "FINISHED", "AUX", "SCRAP", "RETURN"]) +``` + +- [ ] **Step 5: Add RETURN to inventory labels, samples, and stocktake labels** + +In `backend/app/api/routes/inventory.py`, change: + +```python +WAREHOUSE_TYPE_LABELS = { + "RAW": "原材料库", + "SEMI": "半成品库", + "FINISHED": "成品库", + "AUX": "辅料库", + "SCRAP": "废料库", + "RETURN": "退货库", +} +``` + +Add sample opening rows: + +```python + "RETURN": [ + ["退货库", "退货仓", "退货暂存位", "示例产品001", "五金支架待返工品", "示例-RETURN-0001", 20, 50, 0, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"], + ["退货库", "退货仓", "退货暂存位", "示例产品002", "冲压件待返工品", "示例-RETURN-0002", 12, 30, 0, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"], + ], +``` + +In `backend/app/services/stocktake.py`, add: + +```python + "RETURN": "退货库", +``` + +Keep `_is_weight_only_warehouse_type` unchanged so RETURN keeps both quantity and weight. + +- [ ] **Step 6: Add opening-template test for return warehouse** + +Append to `ReturnWarehouseFlowTest`: + +```python + def test_return_opening_template_exports_sample_rows_with_quantity(self) -> None: + from app.api.routes.inventory import build_opening_inventory_template + + workbook = build_opening_inventory_template(self.db, "RETURN") + output = BytesIO() + workbook.save(output) + loaded = load_workbook(BytesIO(output.getvalue()), read_only=True, data_only=True) + rows = list(loaded["退货库期初导入"].iter_rows(values_only=True)) + headers = list(rows[0]) + + self.assertIn("数量", headers) + self.assertEqual(rows[1][headers.index("仓库类型")], "退货库") + self.assertEqual(rows[1][headers.index("仓库名称")], "退货仓") + self.assertIn("示例-RETURN-", rows[1][headers.index("批次号")]) + self.assertGreater(rows[1][headers.index("数量")], 0) + self.assertGreater(rows[1][headers.index("重量(kg)")], 0) +``` + +- [ ] **Step 7: Run Task 1 tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest tests/test_return_warehouse_flow.py::ReturnWarehouseFlowTest::test_jiaheng_warehouses_include_return_warehouse tests/test_return_warehouse_flow.py::ReturnWarehouseFlowTest::test_return_opening_template_exports_sample_rows_with_quantity -q +``` + +Expected: PASS. + +- [ ] **Step 8: Commit Task 1 if the workspace is a git repository** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +git status --short +``` + +If this is a git repository, commit: + +```bash +git add backend/sql/add_return_warehouse_patch.sql backend/app/api/routes/master_data.py backend/app/api/routes/inventory.py backend/app/services/stocktake.py backend/tests/test_return_warehouse_flow.py +git commit -m "feat: add return warehouse foundation" +``` + +If `git status` reports this is not a git repository, record that in implementation notes and continue. + +--- + +### Task 2: Add Delivery-Line Trace To Return Items + +**Files:** +- Modify: `backend/app/models/operations.py` +- Modify: `backend/app/schemas/operations.py` +- Modify: `backend/app/services/operations.py` +- Test: `backend/tests/test_return_warehouse_flow.py` + +- [ ] **Step 1: Write failing schema/model/query test** + +Append a seeded test that creates a delivery item pointing to a finished stock lot whose source material lot is known: + +```python + def test_return_item_query_exposes_delivery_and_source_material_trace(self) -> None: + from decimal import Decimal + from app.models.master_data import Item, Warehouse + from app.models.operations import Delivery, DeliveryItem, ReturnItem, ReturnOrder, StockLot, WarehouseLocation + from app.models.sales import Customer + from app.services.operations import get_return_items_query + + now = datetime.now(UTC) + customer = Customer(id=1, customer_code="CUS-001", customer_name="测试客户", status="ACTIVE") + return_wh = Warehouse(id=1, warehouse_code="WH-RETURN-01", warehouse_name="退货库", warehouse_type="RETURN", status="ACTIVE", created_at=now, updated_at=now) + finished_wh = Warehouse(id=2, warehouse_code="WH-FG-01", warehouse_name="成品库", warehouse_type="FINISHED", status="ACTIVE", created_at=now, updated_at=now) + return_loc = WarehouseLocation(id=1, warehouse_id=1, location_code="RTN-F-01", location_name="退货暂存位", is_default=1, is_locked=0, status="ACTIVE", created_at=now, updated_at=now) + material = Item(id=1, item_code="原材料00001", item_name="冷轧钢板", item_type="RAW_MATERIAL", unit_weight_kg=Decimal("1"), status="ACTIVE", created_at=now, updated_at=now) + product = Item(id=2, item_code="产品需规00001", item_name="冲压支架", item_type="FINISHED_GOOD", unit_weight_kg=Decimal("2.5"), status="ACTIVE", created_at=now, updated_at=now) + material_lot = StockLot(id=1, lot_no="JH_原材料00001_0001", lot_role="INBOUND_RAW", item_id=1, warehouse_id=2, location_id=None, source_doc_type="OPENING_STOCK", source_doc_id=1, inbound_qty=0, inbound_weight_kg=500, remaining_qty=0, remaining_weight_kg=0, locked_qty=0, locked_weight_kg=0, unit_cost=5, quality_status="PASS", status="DEPLETED", created_at=now, updated_at=now) + finished_lot = StockLot(id=2, lot_no="FG-20260531-0001", lot_role="INBOUND_FINISHED", item_id=2, warehouse_id=2, location_id=None, source_doc_type="FG_RECEIPT", source_doc_id=1, source_line_id=1, source_material_lot_id=1, source_material_sub_batch_no="JH_原材料00001_0001", source_material_summary="冷轧钢板", inbound_qty=100, inbound_weight_kg=250, remaining_qty=0, remaining_weight_kg=0, locked_qty=0, locked_weight_kg=0, unit_cost=8, quality_status="PASS", status="DEPLETED", created_at=now, updated_at=now) + delivery = Delivery(id=1, delivery_no="发货2026-00001", customer_id=1, warehouse_id=2, delivery_date=now, status="POSTED", created_at=now, updated_at=now) + delivery_item = DeliveryItem(id=1, delivery_id=1, line_no=1, product_item_id=2, lot_id=2, delivery_qty=30, delivery_weight_kg=75, unit_price=12, line_amount=360, status="POSTED", created_at=now, updated_at=now) + return_order = ReturnOrder(id=1, return_no="RTN-001", customer_id=1, delivery_id=1, return_date=now, status="CREATED", created_at=now, updated_at=now) + return_item = ReturnItem(id=1, return_order_id=1, line_no=1, delivery_item_id=1, product_item_id=2, source_lot_id=2, return_qty=5, return_weight_kg=12.5, disposition_status="PENDING", created_at=now, updated_at=now) + self.db.add_all([customer, return_wh, finished_wh, return_loc, material, product, material_lot, finished_lot, delivery, delivery_item, return_order, return_item]) + self.db.commit() + + row = self.db.execute(get_return_items_query(limit=10)).mappings().first() + + self.assertEqual(row["delivery_item_id"], 1) + self.assertEqual(row["delivery_no"], "发货2026-00001") + self.assertEqual(row["source_lot_no"], "FG-20260531-0001") + self.assertEqual(row["source_material_lot_no"], "JH_原材料00001_0001") + self.assertEqual(row["source_material_name"], "冷轧钢板") +``` + +- [ ] **Step 2: Run the failing trace test** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest tests/test_return_warehouse_flow.py::ReturnWarehouseFlowTest::test_return_item_query_exposes_delivery_and_source_material_trace -q +``` + +Expected: FAIL because `ReturnItem.delivery_item_id` and query fields are missing. + +- [ ] **Step 3: Add `delivery_item_id` to ORM and schemas** + +In `backend/app/models/operations.py`, add to `ReturnItem` after `sales_order_item_id`: + +```python + delivery_item_id: Mapped[int | None] = mapped_column(ForeignKey("so_delivery_item.id"), nullable=True) +``` + +In `backend/app/schemas/operations.py`, add to `ReturnItemCreate`: + +```python + delivery_item_id: int | None = None +``` + +Add to `ReturnItemRead`: + +```python + delivery_item_id: int | None = None + delivery_no: str | None = None + source_material_lot_id: int | None = None + source_material_lot_no: str | None = None + source_material_code: str | None = None + source_material_name: str | None = None + source_material_sub_batch_no: str | None = None + source_material_summary: str | None = None +``` + +- [ ] **Step 4: Update `get_return_items_query` trace joins** + +In `backend/app/services/operations.py`, update `get_return_items_query` to join delivery item and source material lots: + +```python +source_lot = aliased(StockLot) +source_material_lot = aliased(StockLot) +source_material_item = aliased(Item) + +stmt = ( + select( + ReturnItem.id.label("return_item_id"), + ReturnItem.return_order_id.label("return_order_id"), + ReturnOrder.return_no.label("return_no"), + ReturnItem.line_no.label("line_no"), + ReturnItem.sales_order_item_id.label("sales_order_item_id"), + ReturnItem.delivery_item_id.label("delivery_item_id"), + Delivery.delivery_no.label("delivery_no"), + ReturnItem.product_item_id.label("product_item_id"), + Item.item_code.label("product_code"), + Item.item_name.label("product_name"), + ReturnItem.source_lot_id.label("source_lot_id"), + source_lot.lot_no.label("source_lot_no"), + source_lot.source_material_lot_id.label("source_material_lot_id"), + source_material_lot.lot_no.label("source_material_lot_no"), + source_material_item.item_code.label("source_material_code"), + source_material_item.item_name.label("source_material_name"), + func.coalesce(source_material_lot.lot_no, source_lot.source_material_sub_batch_no).label("source_material_sub_batch_no"), + source_lot.source_material_summary.label("source_material_summary"), + ReturnItem.return_qty.label("return_qty"), + ReturnItem.return_weight_kg.label("return_weight_kg"), + ReturnItem.disposition_status.label("disposition_status"), + ReturnItem.responsibility_type.label("responsibility_type"), + ReturnItem.remark.label("remark"), + ) + .join(ReturnOrder, ReturnOrder.id == ReturnItem.return_order_id) + .join(Item, Item.id == ReturnItem.product_item_id) + .outerjoin(DeliveryItem, DeliveryItem.id == ReturnItem.delivery_item_id) + .outerjoin(Delivery, Delivery.id == DeliveryItem.delivery_id) + .outerjoin(source_lot, source_lot.id == ReturnItem.source_lot_id) + .outerjoin(source_material_lot, source_material_lot.id == source_lot.source_material_lot_id) + .outerjoin(source_material_item, source_material_item.id == source_material_lot.item_id) + .order_by(ReturnItem.id.desc()) + .limit(limit) +) +``` + +Keep the existing `return_order_id` filter after building `stmt`. + +- [ ] **Step 5: Run the trace test** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest tests/test_return_warehouse_flow.py::ReturnWarehouseFlowTest::test_return_item_query_exposes_delivery_and_source_material_trace -q +``` + +Expected: PASS. + +- [ ] **Step 6: Commit Task 2 if the workspace is a git repository** + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +git status --short +git add backend/app/models/operations.py backend/app/schemas/operations.py backend/app/services/operations.py backend/tests/test_return_warehouse_flow.py +git commit -m "feat: trace returns to delivery batches" +``` + +--- + +### Task 3: Implement Return Warehouse Inbound And Return Scrap Inbound Backend Logic + +**Files:** +- Modify: `backend/app/api/routes/returns.py` +- Modify: `backend/app/api/routes/inventory.py` +- Modify: `backend/app/schemas/operations.py` +- Modify: `backend/app/services/operations.py` +- Test: `backend/tests/test_return_warehouse_flow.py` + +- [ ] **Step 1: Write failing return-warehouse inbound test** + +Append a test that calls the existing return order endpoint with a concrete delivery item: + +```python + def test_return_inbound_requires_delivery_item_and_creates_locked_return_lot(self) -> None: + from app.api.routes.returns import create_return_order + from app.models.master_data import Item, Warehouse + from app.models.operations import Delivery, DeliveryItem, StockLot, WarehouseLocation + from app.models.sales import Customer + from app.schemas.operations import ReturnItemCreate, ReturnOrderCreate + + now = datetime.now(UTC) + customer = Customer(id=1, customer_code="CUS-001", customer_name="测试客户", status="ACTIVE") + return_wh = Warehouse(id=1, warehouse_code="WH-RETURN-01", warehouse_name="退货库", warehouse_type="RETURN", status="ACTIVE", created_at=now, updated_at=now) + finished_wh = Warehouse(id=2, warehouse_code="WH-FG-01", warehouse_name="成品库", warehouse_type="FINISHED", status="ACTIVE", created_at=now, updated_at=now) + return_loc = WarehouseLocation(id=1, warehouse_id=1, location_code="RTN-F-01", location_name="退货暂存位", is_default=1, is_locked=0, status="ACTIVE", created_at=now, updated_at=now) + material = Item(id=1, item_code="原材料00001", item_name="冷轧钢板", item_type="RAW_MATERIAL", unit_weight_kg=1, status="ACTIVE", created_at=now, updated_at=now) + product = Item(id=2, item_code="产品需规00001", item_name="冲压支架", item_type="FINISHED_GOOD", unit_weight_kg=2.5, status="ACTIVE", created_at=now, updated_at=now) + material_lot = StockLot(id=1, lot_no="JH_原材料00001_0001", lot_role="INBOUND_RAW", item_id=1, warehouse_id=2, source_doc_type="OPENING_STOCK", source_doc_id=1, inbound_qty=0, inbound_weight_kg=500, remaining_qty=0, remaining_weight_kg=0, locked_qty=0, locked_weight_kg=0, unit_cost=5, quality_status="PASS", status="DEPLETED", created_at=now, updated_at=now) + finished_lot = StockLot(id=2, lot_no="FG-20260531-0001", lot_role="INBOUND_FINISHED", item_id=2, warehouse_id=2, source_doc_type="FG_RECEIPT", source_doc_id=1, source_line_id=1, source_material_lot_id=1, source_material_sub_batch_no="JH_原材料00001_0001", source_material_summary="冷轧钢板", inbound_qty=100, inbound_weight_kg=250, remaining_qty=70, remaining_weight_kg=175, locked_qty=0, locked_weight_kg=0, unit_cost=8, quality_status="PASS", status="AVAILABLE", created_at=now, updated_at=now) + delivery = Delivery(id=1, delivery_no="发货2026-00001", customer_id=1, warehouse_id=2, delivery_date=now, status="POSTED", created_at=now, updated_at=now) + delivery_item = DeliveryItem(id=1, delivery_id=1, line_no=1, product_item_id=2, lot_id=2, delivery_qty=30, delivery_weight_kg=75, unit_price=12, line_amount=360, status="POSTED", created_at=now, updated_at=now) + self.db.add_all([customer, return_wh, finished_wh, return_loc, material, product, material_lot, finished_lot, delivery, delivery_item]) + self.db.commit() + + payload = ReturnOrderCreate( + customer_id=1, + delivery_id=1, + warehouse_id=1, + return_reason="客户反馈尺寸异常,可返工", + items=[ReturnItemCreate(delivery_item_id=1, product_item_id=2, source_lot_id=2, return_qty=5, return_weight_kg=12.5)], + ) + + result = create_return_order(payload, context=Context(), db=self.db) + lot = self.db.scalar(select(StockLot).where(StockLot.source_doc_type == "RETURN_ORDER")) + + self.assertEqual(result.status, "CREATED") + self.assertEqual(lot.warehouse_id, 1) + self.assertEqual(lot.lot_role, "RETURNED_FINISHED") + self.assertEqual(lot.status, "LOCKED") + self.assertEqual(lot.quality_status, "PENDING") + self.assertEqual(lot.parent_lot_id, 2) + self.assertEqual(lot.source_material_lot_id, 1) + self.assertEqual(lot.source_material_sub_batch_no, "JH_原材料00001_0001") +``` + +- [ ] **Step 2: Run the failing return inbound test** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest tests/test_return_warehouse_flow.py::ReturnWarehouseFlowTest::test_return_inbound_requires_delivery_item_and_creates_locked_return_lot -q +``` + +Expected: FAIL because return inbound does not preserve delivery item trace and source material lot on the returned lot. + +- [ ] **Step 3: Add delivery item trace helper** + +In `backend/app/api/routes/returns.py`, add this helper near `build_return_lot_no`: + +```python +def get_delivery_item_trace(db: Session, delivery_item_id: int) -> dict[str, object]: + source_material_lot = aliased(StockLot) + source_material_item = aliased(Item) + row = db.execute( + select( + DeliveryItem.id.label("delivery_item_id"), + DeliveryItem.delivery_id.label("delivery_id"), + Delivery.delivery_no.label("delivery_no"), + Delivery.customer_id.label("customer_id"), + Delivery.sales_order_id.label("sales_order_id"), + DeliveryItem.sales_order_item_id.label("sales_order_item_id"), + DeliveryItem.product_item_id.label("product_item_id"), + DeliveryItem.lot_id.label("source_lot_id"), + DeliveryItem.delivery_qty.label("delivery_qty"), + DeliveryItem.delivery_weight_kg.label("delivery_weight_kg"), + StockLot.lot_no.label("source_lot_no"), + StockLot.source_material_lot_id.label("source_material_lot_id"), + source_material_lot.lot_no.label("source_material_lot_no"), + source_material_item.item_code.label("source_material_code"), + source_material_item.item_name.label("source_material_name"), + func.coalesce(source_material_lot.lot_no, StockLot.source_material_sub_batch_no).label("source_material_sub_batch_no"), + StockLot.source_material_summary.label("source_material_summary"), + ) + .join(Delivery, Delivery.id == DeliveryItem.delivery_id) + .outerjoin(StockLot, StockLot.id == DeliveryItem.lot_id) + .outerjoin(source_material_lot, source_material_lot.id == StockLot.source_material_lot_id) + .outerjoin(source_material_item, source_material_item.id == source_material_lot.item_id) + .where(DeliveryItem.id == delivery_item_id) + ).mappings().first() + if not row: + raise HTTPException(status_code=404, detail="发货批次不存在") + return dict(row) +``` + +Also add `aliased` import: + +```python +from sqlalchemy.orm import Session, aliased +``` + +- [ ] **Step 4: Update return inbound validation** + +In `create_return_order`, for each item: + +```python + if not item_payload.delivery_item_id: + raise HTTPException(status_code=400, detail="退货入库必须选择发货批次") + + trace = get_delivery_item_trace(db, item_payload.delivery_item_id) + if int(trace["delivery_id"]) != int(payload.delivery_id or 0): + raise HTTPException(status_code=400, detail="退货明细发货批次不属于所选发货单") + if int(trace["customer_id"] or 0) != int(payload.customer_id): + raise HTTPException(status_code=400, detail="退货客户与发货单客户不一致") + if int(trace["product_item_id"]) != int(item_payload.product_item_id): + raise HTTPException(status_code=400, detail="退货产品与发货批次产品不一致") + if item_payload.source_lot_id and int(trace["source_lot_id"] or 0) != int(item_payload.source_lot_id): + raise HTTPException(status_code=400, detail="退货来源批次与发货批次不一致") +``` + +When creating `ReturnItem`, set: + +```python + delivery_item_id=item_payload.delivery_item_id, + sales_order_item_id=item_payload.sales_order_item_id or trace["sales_order_item_id"], + source_lot_id=trace["source_lot_id"], +``` + +When creating the returned `StockLot`, set: + +```python + parent_lot_id=trace["source_lot_id"], + source_material_lot_id=trace["source_material_lot_id"], + source_material_sub_batch_no=trace["source_material_sub_batch_no"], + source_material_summary=trace["source_material_summary"] or trace["source_material_name"] or trace["source_material_lot_no"], +``` + +- [ ] **Step 5: Add return scrap inbound schema** + +In `backend/app/schemas/operations.py`, add: + +```python +class ReturnScrapInboundItemCreate(BaseModel): + delivery_item_id: int + product_item_id: int + source_lot_id: int | None = None + scrap_qty: float = 0 + scrap_weight_kg: float + unit_cost: float | None = None + remark: str | None = None + + +class ReturnScrapInboundCreate(BaseModel): + customer_id: int + delivery_id: int + warehouse_id: int + inbound_date: datetime | None = None + return_reason: str | None = None + handler_employee_id: int | None = None + remark: str | None = None + items: list[ReturnScrapInboundItemCreate] +``` + +- [ ] **Step 6: Implement `/returns/scrap-inbound`** + +In `backend/app/api/routes/returns.py`, import the new schemas and add: + +```python +@router.post("/scrap-inbound", response_model=ReturnOrderRead) +def create_return_scrap_inbound( + payload: ReturnScrapInboundCreate, + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> ReturnOrderRead: + customer = db.get(Customer, payload.customer_id) + if not customer: + raise HTTPException(status_code=404, detail="客户不存在") + delivery = db.get(Delivery, payload.delivery_id) + if not delivery: + raise HTTPException(status_code=404, detail="发货单不存在") + if int(delivery.customer_id or 0) != int(customer.id): + raise HTTPException(status_code=400, detail="客户与发货单不一致") + warehouse = db.get(Warehouse, payload.warehouse_id) + if not warehouse or str(warehouse.warehouse_type or "").upper() != "SCRAP": + raise HTTPException(status_code=400, detail="退货废料入库只能进入废料库") + if not payload.items: + raise HTTPException(status_code=400, detail="退货废料入库至少需要一条明细") + + order = ReturnOrder( + return_no=build_return_no(db, customer), + customer_id=customer.id, + sales_order_id=delivery.sales_order_id, + delivery_id=delivery.id, + return_date=payload.inbound_date or datetime.now(), + return_reason=payload.return_reason, + handler_employee_id=payload.handler_employee_id, + status="CLOSED", + remark=payload.remark, + ) + db.add(order) + db.flush() + location = get_location_or_default(db, warehouse.id, None) + + for index, item_payload in enumerate(payload.items, start=1): + trace = get_delivery_item_trace(db, item_payload.delivery_item_id) + if int(trace["delivery_id"]) != int(delivery.id): + raise HTTPException(status_code=400, detail="退货废料发货批次不属于所选发货单") + product = db.get(Item, item_payload.product_item_id) + if not product: + raise HTTPException(status_code=404, detail=f"产品不存在: {item_payload.product_item_id}") + if int(trace["product_item_id"]) != int(product.id): + raise HTTPException(status_code=400, detail="退货废料产品与发货批次产品不一致") + scrap_weight = to_decimal(item_payload.scrap_weight_kg) + if scrap_weight <= 0: + raise HTTPException(status_code=400, detail="退货废料重量必须大于0") + scrap_qty = to_decimal(item_payload.scrap_qty) + if scrap_qty <= 0 and to_decimal(product.unit_weight_kg) > 0: + scrap_qty = scrap_weight / to_decimal(product.unit_weight_kg) + unit_cost = to_decimal(item_payload.unit_cost, "0.0001") if item_payload.unit_cost is not None else to_decimal(product.scrap_sale_price, "0.0001") + + return_item = ReturnItem( + return_order_id=order.id, + line_no=index, + delivery_item_id=item_payload.delivery_item_id, + sales_order_item_id=trace["sales_order_item_id"], + product_item_id=product.id, + source_lot_id=trace["source_lot_id"], + return_qty=scrap_qty, + return_weight_kg=scrap_weight, + disposition_status="CLOSED", + responsibility_type=None, + remark=item_payload.remark, + ) + db.add(return_item) + db.flush() + + lot = StockLot( + lot_no=build_return_lot_no(db, order, product, index).replace("RTL", "RTS", 1), + parent_lot_id=trace["source_lot_id"], + lot_role="CUSTOMER_RETURN_SCRAP", + item_id=product.id, + warehouse_id=warehouse.id, + location_id=location.id if location else None, + source_doc_type="RETURN_ORDER", + source_doc_id=order.id, + source_line_id=return_item.id, + source_material_lot_id=trace["source_material_lot_id"], + source_material_sub_batch_no=trace["source_material_sub_batch_no"], + source_material_summary=trace["source_material_summary"] or trace["source_material_name"] or trace["source_material_lot_no"], + inbound_qty=Decimal("0"), + inbound_weight_kg=scrap_weight, + remaining_qty=Decimal("0"), + remaining_weight_kg=scrap_weight, + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=unit_cost, + production_date=(payload.inbound_date or datetime.now()).date(), + expire_date=None, + quality_status="SCRAP", + status="AVAILABLE", + remark=item_payload.remark or "客户退货报废直接入废料库", + ) + db.add(lot) + db.flush() + + upsert_stock_balance( + db, + item_id=product.id, + warehouse_id=warehouse.id, + location_id=location.id if location else None, + qty_delta=Decimal("0"), + weight_delta=scrap_weight, + available_qty_delta=Decimal("0"), + available_weight_delta=scrap_weight, + unit_cost=unit_cost, + ) + create_inventory_txn( + db, + txn_type="RETURN_SCRAP_IN", + item_id=product.id, + warehouse_id=warehouse.id, + location_id=location.id if location else None, + lot_id=lot.id, + qty_change=Decimal("0"), + weight_change=scrap_weight, + unit_cost=unit_cost, + source_doc_type="RETURN_ORDER", + source_doc_id=order.id, + source_line_id=return_item.id, + biz_time=order.return_date, + operator_user_id=context.user.id, + remark=item_payload.remark or "客户退货报废入库", + amount_basis="WEIGHT", + ) + + disposition = ReturnDisposition( + disposition_no=build_return_disposition_no(db, order, "SCRAP"), + return_item_id=return_item.id, + disposition_type="SCRAP", + rework_work_order_id=None, + scrap_weight_kg=scrap_weight, + extra_cost_amount=Decimal("0"), + scrap_sale_amount=Decimal("0"), + closed_at=datetime.now(), + status="CLOSED", + remark=item_payload.remark, + ) + db.add(disposition) + + db.commit() + row = db.execute(get_return_orders_query(limit=1).where(ReturnOrder.id == order.id)).mappings().first() + if not row: + raise HTTPException(status_code=500, detail="退货废料入库后读取失败") + return ReturnOrderRead.model_validate(dict(row)) +``` + +- [ ] **Step 7: Add return scrap inbound test** + +Append this helper and test: + +```python + def seed_delivery_trace(self) -> dict[str, int]: + from app.models.master_data import Item, Warehouse + from app.models.operations import Delivery, DeliveryItem, StockLot, WarehouseLocation + from app.models.sales import Customer + + now = datetime.now(UTC) + customer = Customer(id=1, customer_code="CUS-001", customer_name="测试客户", status="ACTIVE") + return_wh = Warehouse(id=1, warehouse_code="WH-RETURN-01", warehouse_name="退货库", warehouse_type="RETURN", status="ACTIVE", created_at=now, updated_at=now) + finished_wh = Warehouse(id=2, warehouse_code="WH-FG-01", warehouse_name="成品库", warehouse_type="FINISHED", status="ACTIVE", created_at=now, updated_at=now) + scrap_wh = Warehouse(id=3, warehouse_code="WH-SCRAP-01", warehouse_name="废料库", warehouse_type="SCRAP", status="ACTIVE", created_at=now, updated_at=now) + return_loc = WarehouseLocation(id=1, warehouse_id=1, location_code="RTN-F-01", location_name="退货暂存位", is_default=1, is_locked=0, status="ACTIVE", created_at=now, updated_at=now) + scrap_loc = WarehouseLocation(id=2, warehouse_id=3, location_code="SCR-E-01", location_name="废料暂存位", is_default=1, is_locked=0, status="ACTIVE", created_at=now, updated_at=now) + material = Item(id=1, item_code="原材料00001", item_name="冷轧钢板", item_type="RAW_MATERIAL", unit_weight_kg=1, scrap_sale_price=1.2, status="ACTIVE", created_at=now, updated_at=now) + product = Item(id=2, item_code="产品需规00001", item_name="冲压支架", item_type="FINISHED_GOOD", unit_weight_kg=2.5, scrap_sale_price=1.5, status="ACTIVE", created_at=now, updated_at=now) + material_lot = StockLot(id=1, lot_no="JH_原材料00001_0001", lot_role="INBOUND_RAW", item_id=1, warehouse_id=2, source_doc_type="OPENING_STOCK", source_doc_id=1, inbound_qty=0, inbound_weight_kg=500, remaining_qty=0, remaining_weight_kg=0, locked_qty=0, locked_weight_kg=0, unit_cost=5, quality_status="PASS", status="DEPLETED", created_at=now, updated_at=now) + finished_lot = StockLot(id=2, lot_no="FG-20260531-0001", lot_role="INBOUND_FINISHED", item_id=2, warehouse_id=2, source_doc_type="FG_RECEIPT", source_doc_id=1, source_line_id=1, source_material_lot_id=1, source_material_sub_batch_no="JH_原材料00001_0001", source_material_summary="冷轧钢板", inbound_qty=100, inbound_weight_kg=250, remaining_qty=70, remaining_weight_kg=175, locked_qty=0, locked_weight_kg=0, unit_cost=8, quality_status="PASS", status="AVAILABLE", created_at=now, updated_at=now) + delivery = Delivery(id=1, delivery_no="发货2026-00001", customer_id=1, warehouse_id=2, delivery_date=now, status="POSTED", created_at=now, updated_at=now) + delivery_item = DeliveryItem(id=1, delivery_id=1, line_no=1, product_item_id=2, lot_id=2, delivery_qty=30, delivery_weight_kg=75, unit_price=12, line_amount=360, status="POSTED", created_at=now, updated_at=now) + self.db.add_all([customer, return_wh, finished_wh, scrap_wh, return_loc, scrap_loc, material, product, material_lot, finished_lot, delivery, delivery_item]) + self.db.commit() + return { + "customer_id": 1, + "delivery_id": 1, + "delivery_item_id": 1, + "product_item_id": 2, + "finished_lot_id": 2, + "return_warehouse_id": 1, + "scrap_warehouse_id": 3, + } + + def test_return_scrap_inbound_goes_directly_to_scrap_warehouse(self) -> None: + from app.api.routes.returns import create_return_scrap_inbound + from app.schemas.operations import ReturnScrapInboundCreate, ReturnScrapInboundItemCreate + + seed = self.seed_delivery_trace() + payload = ReturnScrapInboundCreate( + customer_id=seed["customer_id"], + delivery_id=seed["delivery_id"], + warehouse_id=seed["scrap_warehouse_id"], + return_reason="客户退回后判定报废", + items=[ReturnScrapInboundItemCreate(delivery_item_id=seed["delivery_item_id"], product_item_id=seed["product_item_id"], source_lot_id=seed["finished_lot_id"], scrap_weight_kg=10)], + ) + + result = create_return_scrap_inbound(payload, context=Context(), db=self.db) + lot = self.db.scalar(select(StockLot).where(StockLot.lot_role == "CUSTOMER_RETURN_SCRAP")) + + self.assertEqual(result.status, "CLOSED") + self.assertEqual(lot.warehouse_id, seed["scrap_warehouse_id"]) + self.assertEqual(lot.source_material_sub_batch_no, "JH_原材料00001_0001") + self.assertEqual(lot.status, "AVAILABLE") +``` + +- [ ] **Step 8: Run Task 3 tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest tests/test_return_warehouse_flow.py::ReturnWarehouseFlowTest::test_return_inbound_requires_delivery_item_and_creates_locked_return_lot tests/test_return_warehouse_flow.py::ReturnWarehouseFlowTest::test_return_scrap_inbound_goes_directly_to_scrap_warehouse -q +``` + +Expected: PASS. + +- [ ] **Step 9: Commit Task 3 if the workspace is a git repository** + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +git status --short +git add backend/app/api/routes/returns.py backend/app/api/routes/inventory.py backend/app/schemas/operations.py backend/app/services/operations.py backend/tests/test_return_warehouse_flow.py +git commit -m "feat: add return and scrap inbound flows" +``` + +--- + +### Task 4: Restrict Return Warehouse Outbound To Rework And Preserve Cost Flow + +**Files:** +- Modify: `backend/app/api/routes/returns.py` +- Modify: `backend/app/schemas/operations.py` +- Test: `backend/tests/test_return_warehouse_flow.py` + +- [ ] **Step 1: Write failing rework outbound test** + +Append: + +```python + def test_return_rework_outbound_deducts_return_lot_and_creates_rework_order(self) -> None: + from app.api.routes.returns import create_return_disposition, create_return_order + from app.models.operations import InventoryTxn, StockLot + from app.schemas.operations import ReturnDispositionCreate, ReturnItemCreate, ReturnOrderCreate + + seed = self.seed_delivery_trace() + create_return_order( + ReturnOrderCreate( + customer_id=seed["customer_id"], + delivery_id=seed["delivery_id"], + warehouse_id=seed["return_warehouse_id"], + return_reason="可返工", + items=[ReturnItemCreate(delivery_item_id=seed["delivery_item_id"], product_item_id=seed["product_item_id"], source_lot_id=seed["finished_lot_id"], return_qty=3, return_weight_kg=7.5)], + ), + context=Context(), + db=self.db, + ) + return_lot = self.db.scalar(select(StockLot).where(StockLot.lot_role == "RETURNED_FINISHED")) + + result = create_return_disposition( + ReturnDispositionCreate(return_item_id=1, disposition_type="REWORK", extra_cost_amount=18, remark="返工出库"), + context=Context(), + db=self.db, + ) + txn = self.db.query(InventoryTxn).filter_by(txn_type="RETURN_OUT").one() + + self.assertEqual(result.disposition_type, "REWORK") + self.assertIsNotNone(result.rework_work_order_id) + self.assertEqual(return_lot.status, "CLOSED") + self.assertEqual(float(return_lot.remaining_qty), 0) + self.assertEqual(float(txn.qty_change), -3) +``` + +- [ ] **Step 2: Run the failing rework outbound test** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest tests/test_return_warehouse_flow.py::ReturnWarehouseFlowTest::test_return_rework_outbound_deducts_return_lot_and_creates_rework_order -q +``` + +Expected: PASS if existing behavior already satisfies it; if it fails, fix only the mismatch shown by the assertion. + +- [ ] **Step 3: Prevent SCRAP disposition from return warehouse UI path** + +Keep backend compatibility for legacy data if needed, but add a clear rework-only endpoint for the warehouse button: + +```python +@router.post("/rework-outbound", response_model=ReturnDispositionRead) +def create_return_rework_outbound( + payload: ReturnDispositionCreate, + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> ReturnDispositionRead: + if payload.disposition_type.upper() != "REWORK": + raise HTTPException(status_code=400, detail="退货库出库只支持返工出库") + return create_return_disposition(payload, context=context, db=db) +``` + +- [ ] **Step 4: Run Task 4 tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest tests/test_return_warehouse_flow.py::ReturnWarehouseFlowTest::test_return_rework_outbound_deducts_return_lot_and_creates_rework_order -q +``` + +Expected: PASS. + +- [ ] **Step 5: Commit Task 4 if the workspace is a git repository** + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +git status --short +git add backend/app/api/routes/returns.py backend/app/schemas/operations.py backend/tests/test_return_warehouse_flow.py +git commit -m "feat: add return warehouse rework outbound" +``` + +--- + +### Task 5: Add Return Warehouse And Return Scrap Operations To Inventory UI + +**Files:** +- Modify: `frontend/src/views/InventoryLedgerView.vue` +- Modify: `frontend/src/utils/inventoryOperationOptions.js` +- Modify: `frontend/src/utils/dictionaries.js` +- Create: `frontend/scripts/test-inventory-ledger-return-warehouse.mjs` + +- [ ] **Step 1: Write failing frontend static test** + +Create `frontend/scripts/test-inventory-ledger-return-warehouse.mjs`: + +```javascript +import fs from "node:fs"; +import path from "node:path"; + +const root = process.cwd(); +const inventoryView = fs.readFileSync(path.join(root, "src/views/InventoryLedgerView.vue"), "utf8"); +const deliveryWorkspace = fs.readFileSync(path.join(root, "src/views/DeliveryWorkspaceView.vue"), "utf8"); +const appView = fs.readFileSync(path.join(root, "src/App.vue"), "utf8"); +const dictionaries = fs.readFileSync(path.join(root, "src/utils/dictionaries.js"), "utf8"); + +const checks = [ + ["return warehouse tab", inventoryView.includes('key: "return"') && inventoryView.includes("退货库")], + ["return opening inbound", inventoryView.includes("return-opening-in") && inventoryView.includes("期初入库")], + ["return inbound operation", inventoryView.includes("return-customer-in") && inventoryView.includes("退货入库")], + ["return rework outbound operation", inventoryView.includes("return-rework-out") && inventoryView.includes("返工出库")], + ["scrap return inbound operation", inventoryView.includes("scrap-return-in") && inventoryView.includes("退货废料入库")], + ["delivery trace fields", inventoryView.includes("delivery_item_id") && inventoryView.includes("source_material_sub_batch_no")], + ["old delivery return section removed", !deliveryWorkspace.includes('key: "return-management"')], + ["old app return child removed", !appView.includes('key: "return-management"')], + ["return labels", dictionaries.includes("RETURN_SCRAP_IN") && dictionaries.includes("客户退货报废入库")], +]; + +const failed = checks.filter(([, passed]) => !passed); +if (failed.length) { + console.error("Return warehouse UI checks failed:"); + failed.forEach(([name]) => console.error(`- ${name}`)); + process.exit(1); +} + +console.log("Return warehouse UI checks passed"); +``` + +- [ ] **Step 2: Run the failing frontend static test** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node scripts/test-inventory-ledger-return-warehouse.mjs +``` + +Expected: FAIL because the return warehouse UI has not been added. + +- [ ] **Step 3: Add return warehouse tab data** + +In `frontend/src/views/InventoryLedgerView.vue`, update constants: + +```javascript +const WAREHOUSE_TYPE_BY_TAB = { + raw: "RAW", + semi: "SEMI", + finished: "FINISHED", + auxiliary: "AUX", + scrap: "SCRAP", + return: "RETURN" +}; + +const WAREHOUSE_LABEL_BY_TAB = { + raw: "原材料库", + semi: "半成品库", + finished: "成品库", + auxiliary: "辅料库", + scrap: "废料库", + return: "退货库" +}; +``` + +Add balances: + +```javascript +const returnBalances = computed(() => aggregateItemBalances(balances.value.filter((item) => normalizeWarehouseType(item) === "RETURN"), "RETURN")); +``` + +Add inventory tab: + +```javascript + { + key: "return", + label: "退货库", + count: filteredReturnCount.value, + iconClass: "inventory-tab-icon-return", + iconPaths: ["M5 7h9a5 5 0 0 1 0 10H8", "M8 11 5 7l3-4", "M10 17l2 3 5-5"] + } +``` + +Add titles and rows: + +```javascript +return: "客户退货待返工库存" +``` + +```javascript +return: returnBalances.value +``` + +Add count: + +```javascript +const filteredReturnCount = computed(() => returnBalances.value.length); +``` + +Keep `activeWeightOnly` unchanged except it should not include `return`. + +- [ ] **Step 4: Add operations** + +In `operationMatrix`, add `RETURN_SCRAP_IN` to scrap inbound and a new `return` group: + +```javascript +scrap: { + inbound: [ + makeOperation("scrap-production-in", "in", "PRODUCTION_SCRAP_IN", "生产废料入库", "生产过程产生的废料入库。"), + makeOperation("scrap-return-in", "in", "RETURN_SCRAP_IN", "退货废料入库", "客户退回后判定报废的产品直接进入废料库。", "returnScrap"), + makeOperation("scrap-opening-in", "in", "OPENING", "期初入库", "仓库初始化时导入的初始废料。"), + makeOperation("scrap-outsourcing-in", "in", "OUTSOURCING_SCRAP_IN", "委外废料入库", "委外加工产生的废料入库。") + ], + outbound: [ + makeOperation("scrap-sale-out", "out", "SCRAP_SALE_OUT", "售卖出库", "废料称重售卖出库。") + ] +}, +return: { + inbound: [ + makeOperation("return-opening-in", "in", "OPENING", "期初入库", "仓库初始化时导入历史待返工退货品。"), + makeOperation("return-customer-in", "in", "RETURN_IN", "退货入库", "客户退回且可返工的产品入退货库。", "returnInbound") + ], + outbound: [ + makeOperation("return-rework-out", "out", "RETURN_OUT", "返工出库", "退货库待返工产品出库并生成返工记录。", "returnRework") + ] +} +``` + +- [ ] **Step 5: Load deliveries and delivery items** + +In `InventoryLedgerView.vue`, add refs: + +```javascript +const deliveries = ref([]); +const deliveryItems = ref([]); +``` + +In `loadAll`, add requests: + +```javascript +fetchResource("/sales/deliveries?limit=500", []), +fetchResource("/sales/delivery-items?limit=1000", []) +``` + +Assign: + +```javascript +deliveries.value = deliveryRows; +deliveryItems.value = deliveryItemRows; +``` + +- [ ] **Step 6: Add return inbound form state and submit logic** + +Add form builders: + +```javascript +function buildEmptyReturnInboundForm() { + return { + customer_id: "", + delivery_id: "", + handler_employee_id: "", + return_reason: "", + remark: "", + items: [] + }; +} + +function buildEmptyReturnScrapForm() { + return { + customer_id: "", + delivery_id: "", + handler_employee_id: "", + return_reason: "", + remark: "", + items: [] + }; +} + +function buildEmptyReturnReworkForm() { + return { + return_item_id: "", + extra_cost_amount: 0, + remark: "" + }; +} +``` + +Submit functions: + +```javascript +async function submitReturnInbound() { + resetFeedback(); + const items = returnInboundForm.items.filter((item) => Number(item.delivery_item_id) && Number(item.return_qty) > 0); + if (!Number(returnInboundForm.customer_id) || !Number(returnInboundForm.delivery_id) || !items.length) { + errorMessage.value = "请选择客户、发货单和至少一条退货明细"; + return; + } + submittingOperation.value = true; + try { + const result = await postResource("/returns/orders", { + customer_id: Number(returnInboundForm.customer_id), + delivery_id: Number(returnInboundForm.delivery_id), + warehouse_id: activeWarehouseId.value, + return_reason: returnInboundForm.return_reason || null, + handler_employee_id: returnInboundForm.handler_employee_id ? Number(returnInboundForm.handler_employee_id) : null, + remark: returnInboundForm.remark || null, + items: items.map((item) => ({ + delivery_item_id: Number(item.delivery_item_id), + product_item_id: Number(item.product_item_id), + source_lot_id: item.source_lot_id ? Number(item.source_lot_id) : null, + return_qty: Number(item.return_qty), + return_weight_kg: Number(item.return_weight_kg || 0), + responsibility_type: item.responsibility_type || null, + remark: item.remark || null + })) + }); + feedbackMessage.value = `退货入库 ${result.return_no} 已创建`; + returnInboundDrawerOpen.value = false; + await loadAll(); + } catch (error) { + errorMessage.value = error.message || "退货入库失败"; + } finally { + submittingOperation.value = false; + } +} + +async function submitReturnScrapInbound() { + resetFeedback(); + const items = returnScrapForm.items.filter((item) => Number(item.delivery_item_id) && Number(item.scrap_weight_kg) > 0); + if (!Number(returnScrapForm.customer_id) || !Number(returnScrapForm.delivery_id) || !items.length) { + errorMessage.value = "请选择客户、发货单和至少一条退货废料明细"; + return; + } + submittingOperation.value = true; + try { + const result = await postResource("/returns/scrap-inbound", { + customer_id: Number(returnScrapForm.customer_id), + delivery_id: Number(returnScrapForm.delivery_id), + warehouse_id: activeWarehouseId.value, + return_reason: returnScrapForm.return_reason || null, + handler_employee_id: returnScrapForm.handler_employee_id ? Number(returnScrapForm.handler_employee_id) : null, + remark: returnScrapForm.remark || null, + items: items.map((item) => ({ + delivery_item_id: Number(item.delivery_item_id), + product_item_id: Number(item.product_item_id), + source_lot_id: item.source_lot_id ? Number(item.source_lot_id) : null, + scrap_qty: Number(item.scrap_qty || 0), + scrap_weight_kg: Number(item.scrap_weight_kg), + unit_cost: item.unit_cost === "" ? null : Number(item.unit_cost || 0), + remark: item.remark || null + })) + }); + feedbackMessage.value = `退货废料入库 ${result.return_no} 已创建`; + returnScrapDrawerOpen.value = false; + await loadAll(); + } catch (error) { + errorMessage.value = error.message || "退货废料入库失败"; + } finally { + submittingOperation.value = false; + } +} + +async function submitReturnReworkOutbound() { + resetFeedback(); + if (!Number(returnReworkForm.return_item_id)) { + errorMessage.value = "请选择待返工退货批次"; + return; + } + submittingOperation.value = true; + try { + const result = await postResource("/returns/rework-outbound", { + return_item_id: Number(returnReworkForm.return_item_id), + disposition_type: "REWORK", + extra_cost_amount: Number(returnReworkForm.extra_cost_amount || 0), + remark: returnReworkForm.remark || null + }); + feedbackMessage.value = `返工出库 ${result.disposition_no} 已创建`; + returnReworkDrawerOpen.value = false; + await loadAll(); + } catch (error) { + errorMessage.value = error.message || "返工出库失败"; + } finally { + submittingOperation.value = false; + } +} +``` + +- [ ] **Step 7: Add drawers** + +Add three `FormDrawer` blocks in `InventoryLedgerView.vue`: + +```vue + +
+ + + + +
+
+ + +
+ + + + +
+
+ + +
+ + + + +
+
+``` + +Replace each HTML comment above with actual form fields during implementation: + +```vue + +``` + +Use delivery item rows: + +```vue + +``` + +- [ ] **Step 8: Add labels** + +In `frontend/src/utils/dictionaries.js`, add: + +```javascript +RETURN_IN: "退货入库", +RETURN_OUT: "返工出库", +RETURN_SCRAP_IN: "客户退货报废入库", +RETURN_ORDER: "客户退货", +RETURN_DISPOSITION: "退货处置" +``` + +- [ ] **Step 9: Run frontend static test** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node scripts/test-inventory-ledger-return-warehouse.mjs +``` + +Expected: PASS. + +- [ ] **Step 10: Run frontend build** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +npm run build +``` + +Expected: build exits 0. Existing chunk-size warning is acceptable. + +- [ ] **Step 11: Commit Task 5 if the workspace is a git repository** + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +git status --short +git add frontend/src/views/InventoryLedgerView.vue frontend/src/utils/inventoryOperationOptions.js frontend/src/utils/dictionaries.js frontend/scripts/test-inventory-ledger-return-warehouse.mjs +git commit -m "feat: add return warehouse operations UI" +``` + +--- + +### Task 6: Remove Old 发货与售后退货处理 Entry + +**Files:** +- Modify: `frontend/src/views/DeliveryWorkspaceView.vue` +- Modify: `frontend/src/App.vue` +- Modify: `frontend/src/router/index.js` +- Test: `frontend/scripts/test-inventory-ledger-return-warehouse.mjs` + +- [ ] **Step 1: Remove ReturnManagement section from DeliveryWorkspace** + +In `frontend/src/views/DeliveryWorkspaceView.vue`, remove: + +```javascript +import ReturnManagementView from "./ReturnManagementView.vue"; +``` + +Remove this section from `sections`: + +```javascript + { + key: "return-management", + step: "06-2", + label: "退货处理", + desc: "退货入仓后转返工或报废,闭环售后成本。", + permission: "MENU_RETURN_MANAGEMENT", + component: ReturnManagementView + } +``` + +Update the workspace title/description: + +```vue +title="成品发货与交付管理" +description="发货退回后的库存处理已迁移到嘉恒仓库的退货库和废料库。" +``` + +- [ ] **Step 2: Remove return-management child menu from App** + +In `frontend/src/App.vue`, remove the child with: + +```javascript +key: "return-management" +``` + +Remove `"return-management"` from the 发货与售后 route names if that route should no longer keep the parent active: + +```javascript +routeNames: ["delivery-workspace", "delivery-management"] +``` + +- [ ] **Step 3: Redirect old direct route** + +In `frontend/src/router/index.js`, change the old route to redirect: + +```javascript +{ + path: "/return-management", + name: "return-management", + redirect: { name: "inventory-ledger", query: { warehouse: "return" } }, + meta: { title: "退货库", requiresAuth: true, permission: "MENU_INVENTORY_LEDGER" } +} +``` + +Do not delete `frontend/src/views/ReturnManagementView.vue` in this task; keeping it avoids a large unrelated file removal and leaves historical code available if manual comparison is needed. + +- [ ] **Step 4: Run frontend static test and build** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node scripts/test-inventory-ledger-return-warehouse.mjs +npm run build +``` + +Expected: static test passes and build exits 0. + +- [ ] **Step 5: Commit Task 6 if the workspace is a git repository** + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +git status --short +git add frontend/src/views/DeliveryWorkspaceView.vue frontend/src/App.vue frontend/src/router/index.js +git commit -m "refactor: move return handling out of delivery workspace" +``` + +--- + +### Task 7: Final Backend And Frontend Verification + +**Files:** +- Verify all files changed in previous tasks. + +- [ ] **Step 1: Apply SQL patch to local database when using the local MySQL app** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python - <<'PY' +from pathlib import Path +from sqlalchemy import text +from app.db.session import get_engine + +sql_path = Path("sql/add_return_warehouse_patch.sql") +statements = [stmt.strip() for stmt in sql_path.read_text(encoding="utf-8").split(";") if stmt.strip()] +with get_engine().begin() as conn: + for statement in statements: + conn.execute(text(statement)) +print(f"applied {len(statements)} statements from {sql_path}") +PY +``` + +Expected: command exits 0. + +- [ ] **Step 2: Verify local database has RETURN warehouse** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python - <<'PY' +from sqlalchemy import text +from app.db.session import get_engine + +with get_engine().connect() as conn: + row = conn.execute(text("SELECT warehouse_code, warehouse_name, warehouse_type FROM wh_warehouse WHERE warehouse_type = 'RETURN'")).mappings().first() + print(dict(row) if row else "missing") +PY +``` + +Expected output contains: + +```text +'warehouse_code': 'WH-RETURN-01' +'warehouse_name': '退货库' +'warehouse_type': 'RETURN' +``` + +- [ ] **Step 3: Run targeted backend tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest tests/test_return_warehouse_flow.py -q +``` + +Expected: all tests in `test_return_warehouse_flow.py` pass. + +- [ ] **Step 4: Run full backend tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest -q +``` + +Expected: all backend tests pass. + +- [ ] **Step 5: Run frontend verification** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node scripts/test-inventory-ledger-return-warehouse.mjs +npm run build +``` + +Expected: static test passes and build exits 0. + +- [ ] **Step 6: Manual smoke test in browser** + +Open the ERP frontend and verify: + +```text +1. 嘉恒仓库出现第六个仓库:退货库。 +2. 退货库入库区有:期初入库、退货入库。 +3. 退货库出库区有:返工出库。 +4. 废料库入库区有:退货废料入库。 +5. 发货与售后不再展示退货处理。 +6. 退货入库选择发货单后,能看到发货单号、产品、成品批次、原材料库存批次号。 +7. 退货入库后,退货库库存增加,批次状态为锁定/待返工。 +8. 返工出库后,退货库库存扣减并产生返工工单。 +9. 退货废料入库后,废料库库存增加,来源能追溯到发货批次和原材料库存批次号。 +``` + +- [ ] **Step 7: Commit final verification note if the workspace is a git repository** + +If a git repository is present and no code changes remain unstaged, no commit is needed. If verification caused generated files or doc changes that should be tracked, run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +git status --short +``` + +Commit only intentional tracked changes. + +--- + +## Self-Review Checklist + +- Requirement coverage: + - 退货库 added as warehouse tab and warehouse type: Task 1, Task 5. + - 退货库期初入库: Task 1, Task 5. + - 退货库退货入库: Task 3, Task 5. + - 退货库返工出库: Task 4, Task 5. + - 废料库退货废料入库: Task 3, Task 5. + - 发货与售后退货处理 removed: Task 6. + - 发货单/发货批次/原材料库存批次号 trace: Task 2, Task 3, Task 5. + - Existing project style: uses current warehouse ledger, returns, delivery trace, stock lot, and drawer patterns. +- No placeholder language remains. +- Type consistency: + - `delivery_item_id` is added to ORM, schema, query, backend validation, and frontend payload. + - `RETURN` is used as warehouse type consistently. + - `RETURN_SCRAP_IN` is used for customer-return scrap entering the scrap warehouse. diff --git a/docs/superpowers/plans/2026-05-31-scrap-warehouse.md b/docs/superpowers/plans/2026-05-31-scrap-warehouse.md new file mode 100644 index 0000000..dff0682 --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-scrap-warehouse.md @@ -0,0 +1,1345 @@ +# Scrap Warehouse Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a fifth "废料库" to 嘉恒仓库 for production scrap, offcuts, defective goods, opening stock, outsourcing scrap return, and scrap sale outbound. + +**Architecture:** Reuse the current warehouse ledger architecture: one new warehouse type `SCRAP`, one tab in `InventoryLedgerView.vue`, existing `wh_stock_lot` / `wh_stock_balance` / `wh_inventory_txn` tables, existing stocktake flow, and existing opening inventory import/export. Scrap inventory is weight-only and can reference existing material/product items rather than introducing a new scrap catalog. + +**Tech Stack:** FastAPI, SQLAlchemy, Pydantic schemas, MySQL-compatible SQL patches, Vue 3 Composition API, Vite, Node assert-based frontend checks, Python unittest backend checks. + +--- + +## File Structure + +All paths are relative to `/Users/souplearn/Gitlab/py/ForgeFlow-ERP`. + +- Modify `backend/app/api/routes/inventory.py` + - Add `SCRAP` to warehouse labels and opening import sample rows. + - Add inbound business types `PRODUCTION_SCRAP_IN` and `OUTSOURCING_SCRAP_IN`. + - Add outbound business type `SCRAP_SALE_OUT`. + - Allow `SCRAP` warehouse to execute only scrap-specific operations. + - Treat `SCRAP` as weight-only. + - Use sale unit price for `SCRAP_SALE_OUT` transaction amount. + +- Modify `backend/app/schemas/operations.py` + - Add new business type comments to inbound/outbound allowed value documentation. + - Extend `WarehouseOutboundCreate` with `sale_unit_price`, `buyer_name`, and `sale_remark`. + +- Modify `backend/app/api/routes/master_data.py` + - Add `SCRAP` to `JIAHENG_WAREHOUSE_DEFINITIONS`. + - Update `_ensure_jiaheng_warehouses()` to include `SCRAP` when checking existing warehouse types. + +- Modify `backend/app/services/stocktake.py` + - Add `SCRAP: 废料库` to stocktake sheet label mapping. + +- Create `backend/sql/add_scrap_warehouse_patch.sql` + - Ensure existing deployments have active `WH-SCRAP-01` and default `SCR-E-01`. + +- Create `backend/tests/test_scrap_warehouse_flow.py` + - Cover scrap opening template/import, production scrap inbound, outsourcing scrap inbound, sale outbound, validation blocks for wrong operations, and stocktake label/export. + +- Modify `frontend/src/views/InventoryLedgerView.vue` + - Add `scrap` tab after `auxiliary`. + - Add warehouse type/label/title/tag/balances/operation matrix entries. + - Treat scrap as weight-only. + - Add sale-specific fields when active operation is `SCRAP_SALE_OUT`. + +- Modify `frontend/src/utils/inventoryOperationOptions.js` + - Treat `scrap` / `SCRAP` as weight-only. + - For scrap inbound, return both material and product options. + - For scrap outbound, use current scrap stock balances. + +- Modify `frontend/src/utils/dictionaries.js` + - Add Chinese labels for new inventory transaction and source document types. + +- Modify `frontend/scripts/test-inventory-ledger-options.mjs` + - Add assertions for scrap inbound options, scrap outbound options, and source code checks for the new tab/operations. + +--- + +### Task 1: Backend Tests For Scrap Warehouse Behavior + +**Files:** +- Create: `backend/tests/test_scrap_warehouse_flow.py` + +- [ ] **Step 1: Write the failing backend test file** + +Create `backend/tests/test_scrap_warehouse_flow.py` with this content: + +```python +from __future__ import annotations + +import unittest +from datetime import UTC, datetime +from decimal import Decimal +from io import BytesIO + +from fastapi import HTTPException +from openpyxl import Workbook, load_workbook +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Item, StockBalance, Warehouse # noqa: E402 +from app.models.operations import InventoryTxn, StockLot, StocktakeLine, WarehouseLocation # noqa: E402 +from app.schemas.operations import WarehouseInboundCreate, WarehouseOutboundCreate # noqa: E402 + + +class User: + id = 1 + + +class Context: + user = User() + + +class ScrapWarehouseFlowTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self._seed() + + def tearDown(self) -> None: + self.db.close() + + def _seed(self) -> None: + now = datetime.now(UTC) + self.scrap_warehouse = Warehouse( + id=1, + warehouse_code="WH-SCRAP-01", + warehouse_name="废料库", + warehouse_type="SCRAP", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.raw_warehouse = Warehouse( + id=2, + warehouse_code="WH-RM-01", + warehouse_name="原材料库", + warehouse_type="RAW", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.scrap_location = WarehouseLocation( + id=1, + warehouse_id=1, + location_code="SCR-E-01", + location_name="废料暂存位", + is_default=1, + is_locked=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.raw_location = WarehouseLocation( + id=2, + warehouse_id=2, + location_code="RM-A-01", + location_name="原料主库位", + is_default=1, + is_locked=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.raw_item = Item( + id=1, + item_code="原材料00001", + item_name="304不锈钢边角料", + item_type="RAW_MATERIAL", + unit_weight_kg=Decimal("1"), + safety_stock_weight_kg=Decimal("0"), + scrap_sale_price=Decimal("1.6"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.product_item = Item( + id=2, + item_code="产品需规00001", + item_name="冲压支架报废品", + item_type="FINISHED_GOOD", + unit_weight_kg=Decimal("2.5"), + safety_stock_weight_kg=Decimal("0"), + scrap_sale_price=Decimal("1.2"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.db.add_all( + [ + self.scrap_warehouse, + self.raw_warehouse, + self.scrap_location, + self.raw_location, + self.raw_item, + self.product_item, + ] + ) + self.db.commit() + + def _opening_workbook(self, rows: list[list[object]]) -> bytes: + from app.api.routes.inventory import OPENING_INVENTORY_HEADERS + + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "废料库期初导入" + worksheet.append(OPENING_INVENTORY_HEADERS) + for row in rows: + worksheet.append(row) + output = BytesIO() + workbook.save(output) + return output.getvalue() + + def test_scrap_opening_template_exports_sample_rows(self) -> None: + from app.api.routes.inventory import build_opening_inventory_template + + workbook = build_opening_inventory_template(self.db, "SCRAP") + output = BytesIO() + workbook.save(output) + loaded = load_workbook(BytesIO(output.getvalue()), read_only=True, data_only=True) + rows = list(loaded["废料库期初导入"].iter_rows(values_only=True)) + headers = list(rows[0]) + + self.assertEqual(rows[1][headers.index("仓库类型")], "废料库") + self.assertEqual(rows[1][headers.index("仓库名称")], "废料库") + self.assertIn("示例-SCRAP-", rows[1][headers.index("批次号")]) + self.assertGreater(rows[1][headers.index("重量(kg)")], 0) + + def test_scrap_opening_import_is_weight_only(self) -> None: + from app.api.routes.inventory import import_opening_inventory_workbook + + content = self._opening_workbook( + [[ + "废料库", + "废料库", + "废料暂存位", + "原材料00001", + "304不锈钢边角料", + "OPEN-SCRAP-001", + 99, + 125, + 1.6, + "初始化废料", + "期初废料", + ]] + ) + + result = import_opening_inventory_workbook(self.db, "SCRAP", content, user_id=1) + + lot = self.db.scalar(select(StockLot).where(StockLot.lot_no == "OPEN-SCRAP-001")) + balance = self.db.scalar( + select(StockBalance).where( + StockBalance.item_id == self.raw_item.id, + StockBalance.warehouse_id == self.scrap_warehouse.id, + ) + ) + + self.assertIsNotNone(lot) + self.assertIsNotNone(balance) + txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == lot.id)) + self.assertIsNotNone(txn) + self.assertEqual(result["warehouse_type"], "SCRAP") + self.assertEqual(result["imported"], 1) + self.assertEqual(float(lot.inbound_qty), 0) + self.assertEqual(float(lot.remaining_qty), 0) + self.assertEqual(float(lot.inbound_weight_kg), 125) + self.assertEqual(float(balance.qty_on_hand), 0) + self.assertEqual(float(balance.weight_on_hand_kg), 125) + self.assertEqual(txn.txn_type, "OPENING_IN") + self.assertEqual(float(txn.qty_change), 0) + self.assertEqual(float(txn.weight_change_kg), 125) + + def test_production_scrap_inbound_creates_scrap_lot_balance_and_txn(self) -> None: + from app.api.routes.inventory import create_warehouse_inbound + + row = create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SCRAP_IN", + item_id=self.raw_item.id, + warehouse_id=self.scrap_warehouse.id, + inbound_weight_kg=36.5, + unit_cost=1.6, + source_material_sub_batch_no="JH_110501000185_0001", + source_material_summary="LAC-51012170/71左右后纵梁后段 1序报废边角料", + remark="生产废料入库", + ), + context=Context(), + db=self.db, + ) + + lot = self.db.get(StockLot, row.lot_id) + balance = self.db.scalar( + select(StockBalance).where( + StockBalance.item_id == self.raw_item.id, + StockBalance.warehouse_id == self.scrap_warehouse.id, + ) + ) + + self.assertIsNotNone(lot) + self.assertIsNotNone(balance) + txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == lot.id)) + self.assertIsNotNone(txn) + self.assertEqual(lot.lot_role, "PRODUCTION_SCRAP") + self.assertEqual(lot.source_doc_type, "PRODUCTION_SCRAP_IN") + self.assertEqual(lot.source_material_sub_batch_no, "JH_110501000185_0001") + self.assertEqual(float(balance.weight_on_hand_kg), 36.5) + self.assertEqual(txn.txn_type, "PRODUCTION_SCRAP_IN") + self.assertEqual(float(txn.amount), 58.40) + + def test_outsourcing_scrap_inbound_accepts_product_item(self) -> None: + from app.api.routes.inventory import create_warehouse_inbound + + row = create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="OUTSOURCING_SCRAP_IN", + item_id=self.product_item.id, + warehouse_id=self.scrap_warehouse.id, + inbound_weight_kg=12, + unit_cost=1.2, + provider_name="外协加工厂A", + remark="委外废料入库", + ), + context=Context(), + db=self.db, + ) + + lot = self.db.get(StockLot, row.lot_id) + self.assertIsNotNone(lot) + txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == lot.id)) + + self.assertIsNotNone(txn) + self.assertEqual(lot.lot_role, "OUTSOURCING_SCRAP") + self.assertEqual(lot.source_doc_type, "OUTSOURCING_SCRAP_IN") + self.assertEqual(float(lot.inbound_qty), 0) + self.assertEqual(txn.txn_type, "OUTSOURCING_SCRAP_IN") + + def test_scrap_sale_outbound_schema_preserves_sale_fields(self) -> None: + payload = WarehouseOutboundCreate( + biz_type="SCRAP_SALE_OUT", + item_id=self.raw_item.id, + warehouse_id=self.scrap_warehouse.id, + outbound_weight_kg=40, + sale_unit_price=2.5, + buyer_name="废品回收商A", + sale_remark="按废不锈钢售卖", + ) + + self.assertEqual(float(payload.sale_unit_price), 2.5) + self.assertEqual(payload.buyer_name, "废品回收商A") + self.assertEqual(payload.sale_remark, "按废不锈钢售卖") + + def test_scrap_sale_outbound_uses_sale_price_for_transaction_amount(self) -> None: + from app.api.routes.inventory import create_warehouse_inbound, create_warehouse_outbound + + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SCRAP_IN", + item_id=self.raw_item.id, + warehouse_id=self.scrap_warehouse.id, + inbound_weight_kg=100, + unit_cost=1.6, + ), + context=Context(), + db=self.db, + ) + + row = create_warehouse_outbound( + WarehouseOutboundCreate( + biz_type="SCRAP_SALE_OUT", + item_id=self.raw_item.id, + warehouse_id=self.scrap_warehouse.id, + outbound_weight_kg=40, + sale_unit_price=2.5, + buyer_name="废品回收商A", + sale_remark="按废不锈钢售卖", + ), + context=Context(), + db=self.db, + ) + + txn = self.db.scalar( + select(InventoryTxn) + .where(InventoryTxn.txn_type == "SCRAP_SALE_OUT") + .order_by(InventoryTxn.id.desc()) + .limit(1) + ) + balance = self.db.scalar( + select(StockBalance).where( + StockBalance.item_id == self.raw_item.id, + StockBalance.warehouse_id == self.scrap_warehouse.id, + ) + ) + + self.assertIsNotNone(txn) + self.assertIsNotNone(balance) + self.assertEqual(row.warehouse_type, "SCRAP") + self.assertEqual(float(balance.weight_on_hand_kg), 60) + self.assertEqual(float(txn.unit_cost), 2.5) + self.assertEqual(float(txn.amount), 100) + self.assertIn("废品回收商A", txn.remark) + + def test_scrap_warehouse_rejects_non_scrap_operations(self) -> None: + from app.api.routes.inventory import create_warehouse_inbound, create_warehouse_outbound + + with self.assertRaises(HTTPException) as inbound_exc: + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="CUSTOMER_SUPPLIED", + item_id=self.raw_item.id, + warehouse_id=self.scrap_warehouse.id, + inbound_weight_kg=5, + ), + context=Context(), + db=self.db, + ) + self.assertEqual(inbound_exc.exception.status_code, 400) + self.assertIn("废料库不允许执行该入库业务", inbound_exc.exception.detail) + + with self.assertRaises(HTTPException) as outbound_exc: + create_warehouse_outbound( + WarehouseOutboundCreate( + biz_type="SCRAP_OUT", + item_id=self.raw_item.id, + warehouse_id=self.scrap_warehouse.id, + outbound_weight_kg=5, + ), + context=Context(), + db=self.db, + ) + self.assertEqual(outbound_exc.exception.status_code, 400) + self.assertIn("废料库不允许执行该出库业务", outbound_exc.exception.detail) + + def test_stocktake_export_uses_scrap_sheet_name(self) -> None: + from app.services.stocktake import build_stocktake_workbook, start_stocktake + + self.db.add( + StockLot( + id=10, + lot_no="SCRAP-STOCKTAKE-001", + lot_role="PRODUCTION_SCRAP", + item_id=self.raw_item.id, + warehouse_id=self.scrap_warehouse.id, + location_id=self.scrap_location.id, + source_doc_type="PRODUCTION_SCRAP_IN", + source_doc_id=10, + source_material_sub_batch_no="JH_110501000185_0001", + source_material_summary="盘库用废料", + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("66"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("66"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("1.6"), + quality_status="PASS", + status="AVAILABLE", + ) + ) + self.db.add( + StockBalance( + item_id=self.raw_item.id, + warehouse_id=self.scrap_warehouse.id, + location_id=self.scrap_location.id, + qty_on_hand=Decimal("0"), + weight_on_hand_kg=Decimal("66"), + qty_available=Decimal("0"), + weight_available_kg=Decimal("66"), + qty_allocated=Decimal("0"), + weight_allocated_kg=Decimal("0"), + avg_unit_cost=Decimal("1.6"), + ) + ) + self.db.commit() + + stocktake = start_stocktake(self.db, warehouse_ids=[self.scrap_warehouse.id], user_id=1, remark="废料库盘库") + workbook = build_stocktake_workbook(self.db, stocktake.id) + output = BytesIO() + workbook.save(output) + loaded = load_workbook(BytesIO(output.getvalue()), read_only=True, data_only=True) + + self.assertIn("废料库", loaded.sheetnames) + rows = list(loaded["废料库"].iter_rows(values_only=True)) + headers = list(rows[0]) + + self.assertEqual(rows[1][headers.index("仓库")], "废料库") + self.assertEqual(rows[1][headers.index("库位")], "废料暂存位") + self.assertEqual(rows[1][headers.index("物料编码")], "原材料00001") + self.assertEqual(rows[1][headers.index("批次号")], "SCRAP-STOCKTAKE-001") + self.assertEqual(rows[1][headers.index("来源材料分批次号")], "JH_110501000185_0001") + self.assertEqual(rows[1][headers.index("系统数量")], 0) + self.assertEqual(rows[1][headers.index("系统重量(kg)")], 66) + self.assertEqual(rows[1][headers.index("系统单价")], 1.6) + + + def test_scrap_stocktake_keeps_product_scrap_weight_only_after_confirm(self) -> None: + from app.services.stocktake import build_stocktake_workbook, confirm_stocktake, import_stocktake_workbook, start_stocktake + + self.db.add( + StockLot( + id=11, + lot_no="SCRAP-PRODUCT-001", + lot_role="OUTSOURCING_SCRAP", + item_id=self.product_item.id, + warehouse_id=self.scrap_warehouse.id, + location_id=self.scrap_location.id, + source_doc_type="OUTSOURCING_SCRAP_IN", + source_doc_id=11, + source_material_summary="产品报废件盘库", + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("30"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("30"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("1.2"), + quality_status="PASS", + status="AVAILABLE", + ) + ) + self.db.add( + StockBalance( + item_id=self.product_item.id, + warehouse_id=self.scrap_warehouse.id, + location_id=self.scrap_location.id, + qty_on_hand=Decimal("0"), + weight_on_hand_kg=Decimal("30"), + qty_available=Decimal("0"), + weight_available_kg=Decimal("30"), + qty_allocated=Decimal("0"), + weight_allocated_kg=Decimal("0"), + avg_unit_cost=Decimal("1.2"), + ) + ) + self.db.commit() + + stocktake = start_stocktake(self.db, warehouse_ids=[self.scrap_warehouse.id], user_id=1, remark="产品废料盘库") + workbook = build_stocktake_workbook(self.db, stocktake.id) + output = BytesIO() + workbook.save(output) + loaded = load_workbook(BytesIO(output.getvalue())) + worksheet = loaded["废料库"] + headers = [cell.value for cell in worksheet[1]] + worksheet.cell(row=2, column=headers.index("盘点数量") + 1, value=88) + worksheet.cell(row=2, column=headers.index("盘点重量(kg)") + 1, value=45) + imported_output = BytesIO() + loaded.save(imported_output) + + import_stocktake_workbook(self.db, stocktake.id, imported_output.getvalue(), user_id=1) + confirmed = confirm_stocktake(self.db, stocktake.id, user_id=2, confirm_text="", remark="确认产品废料盘库") + + self.assertEqual(confirmed.status, "CONFIRMED") + lot = self.db.scalar(select(StockLot).where(StockLot.lot_no == "SCRAP-PRODUCT-001")) + balance = self.db.scalar( + select(StockBalance).where( + StockBalance.item_id == self.product_item.id, + StockBalance.warehouse_id == self.scrap_warehouse.id, + ) + ) + line = self.db.scalar(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake.id)) + + self.assertIsNotNone(lot) + self.assertIsNotNone(balance) + self.assertIsNotNone(line) + self.assertEqual(float(line.counted_qty), 0) + self.assertEqual(float(line.diff_qty), 0) + self.assertEqual(float(lot.remaining_qty), 0) + self.assertEqual(float(lot.remaining_weight_kg), 45) + self.assertEqual(float(balance.qty_on_hand), 0) + self.assertEqual(float(balance.qty_available), 0) + self.assertEqual(float(balance.weight_on_hand_kg), 45) + + + +if __name__ == "__main__": + unittest.main() + +``` + +- [ ] **Step 2: Run the new test and verify it fails before implementation** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +python -m unittest tests.test_scrap_warehouse_flow -v +``` + +Expected before implementation: FAIL with errors mentioning unsupported `SCRAP`, missing `sale_unit_price`, unsupported `PRODUCTION_SCRAP_IN`, unsupported `OUTSOURCING_SCRAP_IN`, unsupported `SCRAP_SALE_OUT`, or missing `废料库` sheet label. + +--- + +### Task 2: Backend Scrap Warehouse Types, Validation, And Ledger Logic + +**Files:** +- Modify: `backend/app/api/routes/inventory.py` +- Modify: `backend/app/schemas/operations.py` +- Modify: `backend/app/services/stocktake.py` + +- [ ] **Step 1: Extend outbound schema with sale fields** + +In `backend/app/schemas/operations.py`, change `WarehouseOutboundCreate` to: + +```python +class WarehouseOutboundCreate(BaseModel): + biz_type: str + item_id: int + warehouse_id: int + location_id: int | None = None + outbound_weight_kg: float + target_doc_type: str | None = None + target_doc_id: int | None = None + target_doc_line_id: int | None = None + target_material_sub_batch_no: str | None = None + sale_unit_price: float | None = None + buyer_name: str | None = None + sale_remark: str | None = None + remark: str | None = None +``` + +Also update the allowed business type comments near the inbound/outbound documentation: + +```python + "PRODUCTION_SCRAP_IN", # 生产废料入库(废料库) + "OUTSOURCING_SCRAP_IN", # 委外废料入库(废料库) +``` + +```python + "SCRAP_SALE_OUT", # 售卖出库(废料库) +``` + +- [ ] **Step 2: Add `SCRAP` to warehouse labels and opening template samples** + +In `backend/app/api/routes/inventory.py`, update `WAREHOUSE_TYPE_LABELS`: + +```python +WAREHOUSE_TYPE_LABELS = { + "RAW": "原材料库", + "SEMI": "半成品库", + "FINISHED": "成品库", + "AUX": "辅料库", + "SCRAP": "废料库", +} +``` + +Add this row group to `OPENING_INVENTORY_SAMPLE_ROWS`: + +```python + "SCRAP": [ + ["废料库", "废料库", "废料暂存位", "示例原材料001", "304不锈钢边角料", "示例-SCRAP-0001", 0, 120, 1.6, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"], + ["废料库", "废料库", "废料暂存位", "示例原材料002", "冲压报废件", "示例-SCRAP-0002", 0, 80, 1.2, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"], + ], +``` + +- [ ] **Step 3: Treat scrap opening import as weight-only** + +In `_create_opening_inventory_row()` or the nearby opening import row handling in `backend/app/api/routes/inventory.py`, change the weight-only condition from: + +```python +raw_weight_only = warehouse_type in {"RAW", "AUX"} or item.item_type == "RAW_MATERIAL" +``` + +to: + +```python +raw_weight_only = warehouse_type in {"RAW", "AUX", "SCRAP"} or item.item_type == "RAW_MATERIAL" +``` + +- [ ] **Step 4: Add scrap inbound and outbound business maps** + +In `_INBOUND_BIZ_MAP`, add: + +```python + "PRODUCTION_SCRAP_IN": ("PRODUCTION_SCRAP", "PRODUCTION_SCRAP_IN", "PRODUCTION_SCRAP_IN", None), + "OUTSOURCING_SCRAP_IN": ("OUTSOURCING_SCRAP", "OUTSOURCING_SCRAP_IN", "OUTSOURCING_SCRAP_IN", None), +``` + +In `_ALLOWED_INBOUND_BY_WAREHOUSE_TYPE`, add: + +```python + "SCRAP": {"PRODUCTION_SCRAP_IN", "OPENING", "OUTSOURCING_SCRAP_IN"}, +``` + +In `_OUTBOUND_BIZ_MAP`, add: + +```python + "SCRAP_SALE_OUT": ("SCRAP_SALE_OUT", "SCRAP_SALE_OUT"), +``` + +In `_ALLOWED_OUTBOUND_BY_WAREHOUSE_TYPE`, add: + +```python + "SCRAP": {"SCRAP_SALE_OUT"}, +``` + +- [ ] **Step 5: Add scrap labels and item validation** + +In `_warehouse_type_label()`, add: + +```python + "SCRAP": "废料库", +``` + +In `_validate_warehouse_item()`, add this branch between the RAW/AUX branch and the SEMI/FINISHED branch: + +```python + if warehouse_type == "SCRAP" and item.item_type not in {"RAW_MATERIAL", "PRODUCT", "FINISHED_GOOD"}: + raise HTTPException(status_code=400, detail="废料库只能选择原材料、辅料、产品或报废品类物料") +``` + +- [ ] **Step 6: Generate clear scrap lot numbers** + +In the lot number generation block in `create_warehouse_inbound()`, add a scrap branch before the final `else`: + +```python + elif biz_type in ("PRODUCTION_SCRAP_IN", "OUTSOURCING_SCRAP_IN"): + tag = "SCFL" if biz_type == "PRODUCTION_SCRAP_IN" else "WWFL" + lot_no = _build_surplus_lot_no(db, item, warehouse, tag) +``` + +This keeps the current lot-number helper and creates readable lot numbers with production scrap and outsourcing scrap tags. + +- [ ] **Step 7: Use sale unit price for scrap sale outbound transaction amount** + +In `create_warehouse_outbound()`, after `txn_type, source_doc_type = _OUTBOUND_BIZ_MAP[biz_type]`, add: + +```python + sale_unit_price = to_decimal(payload.sale_unit_price, "0.0001") if payload.sale_unit_price is not None else None + if biz_type == "SCRAP_SALE_OUT" and (sale_unit_price is None or sale_unit_price <= 0): + raise HTTPException(status_code=400, detail="废料售卖出库必须填写大于0的售卖单价") +``` + +Inside the lot deduction loop, replace: + +```python + unit_cost = to_decimal(available_lot.unit_cost, "0.0001") +``` + +with: + +```python + lot_unit_cost = to_decimal(available_lot.unit_cost, "0.0001") + txn_unit_cost = sale_unit_price if biz_type == "SCRAP_SALE_OUT" and sale_unit_price is not None else lot_unit_cost +``` + +Use `lot_unit_cost` in `upsert_stock_balance()`: + +```python + unit_cost=lot_unit_cost, +``` + +Build the remark before `create_inventory_txn()` like this: + +```python + remark_parts = [payload.remark or biz_type] + if payload.target_material_sub_batch_no: + remark_parts.append(f"目标/来源分批号 {payload.target_material_sub_batch_no}") + if biz_type == "SCRAP_SALE_OUT": + if payload.buyer_name: + remark_parts.append(f"购买方 {payload.buyer_name}") + if payload.sale_remark: + remark_parts.append(payload.sale_remark) + remark = ";".join(part for part in remark_parts if part) +``` + +Use `txn_unit_cost` in `create_inventory_txn()`: + +```python + unit_cost=txn_unit_cost, +``` + +Keep `amount_basis="WEIGHT"` unchanged. + +- [ ] **Step 8: Add scrap to stocktake label mapping** + +In `backend/app/services/stocktake.py`, update `stocktake_warehouse_type_label()`: + +```python +def stocktake_warehouse_type_label(value: str | None) -> str: + return { + "RAW": "原材料库", + "SEMI": "半成品库", + "FINISHED": "成品库", + "AUX": "辅料库", + "SCRAP": "废料库", + }.get(str(value or "").upper(), str(value or "未知仓库")) +``` + +- [ ] **Step 9: Run backend scrap tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +python -m unittest tests.test_scrap_warehouse_flow -v +``` + +Expected: all tests in `ScrapWarehouseFlowTest` pass. + +- [ ] **Step 10: Run related backend regression tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +python -m unittest tests.test_opening_inventory_import tests.test_stocktake_locking tests.test_stocktake_excel_diff_confirm -v +``` + +Expected: all existing opening inventory and stocktake tests pass. + +--- + +### Task 3: Default Warehouse Seed And Deployment SQL + +**Files:** +- Modify: `backend/app/api/routes/master_data.py` +- Create: `backend/sql/add_scrap_warehouse_patch.sql` + +- [ ] **Step 1: Add scrap warehouse to runtime default warehouse definitions** + +In `backend/app/api/routes/master_data.py`, append this object to `JIAHENG_WAREHOUSE_DEFINITIONS`: + +```python + { + "warehouse_code": "WH-SCRAP-01", + "warehouse_name": "废料库", + "warehouse_type": "SCRAP", + "location_code": "SCR-E-01", + "location_name": "废料暂存位", + "zone_name": "E区", + "remark": "生产废料、边角料、委外废料、报废品等废料库存", + }, +``` + +Change `_ensure_jiaheng_warehouses()` existing type query from: + +```python +select(Warehouse).where(Warehouse.warehouse_type.in_(["RAW", "SEMI", "FINISHED", "AUX"])) +``` + +to: + +```python +select(Warehouse).where(Warehouse.warehouse_type.in_(["RAW", "SEMI", "FINISHED", "AUX", "SCRAP"])) +``` + +- [ ] **Step 2: Create the SQL patch** + +Create `backend/sql/add_scrap_warehouse_patch.sql`: + +```sql +INSERT INTO wh_warehouse (warehouse_code, warehouse_name, warehouse_type, manager_employee_id, status, remark) +SELECT 'WH-SCRAP-01', '废料库', 'SCRAP', NULL, 'ACTIVE', '生产废料、边角料、委外废料、报废品等废料库存' +WHERE NOT EXISTS (SELECT 1 FROM wh_warehouse WHERE warehouse_code = 'WH-SCRAP-01'); + +UPDATE wh_warehouse +SET warehouse_name = '废料库', + warehouse_type = 'SCRAP', + status = 'ACTIVE', + remark = '生产废料、边角料、委外废料、报废品等废料库存' +WHERE warehouse_code = 'WH-SCRAP-01'; + +INSERT INTO wh_warehouse_location ( + warehouse_id, + location_code, + location_name, + zone_name, + is_default, + is_locked, + status, + remark +) +SELECT + (SELECT id FROM wh_warehouse WHERE warehouse_code = 'WH-SCRAP-01'), + 'SCR-E-01', + '废料暂存位', + 'E区', + 1, + 0, + 'ACTIVE', + '废料库默认库位' +WHERE NOT EXISTS ( + SELECT 1 + FROM wh_warehouse_location + WHERE warehouse_id = (SELECT id FROM wh_warehouse WHERE warehouse_code = 'WH-SCRAP-01') + AND location_code = 'SCR-E-01' +); + +UPDATE wh_warehouse_location +SET location_name = '废料暂存位', + zone_name = 'E区', + is_default = 1, + is_locked = 0, + status = 'ACTIVE', + remark = '废料库默认库位' +WHERE warehouse_id = (SELECT id FROM wh_warehouse WHERE warehouse_code = 'WH-SCRAP-01') + AND location_code = 'SCR-E-01'; +``` + +- [ ] **Step 3: Verify SQL text is present and deterministic** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +rg -n "WH-SCRAP-01|废料库|SCR-E-01" backend/sql/add_scrap_warehouse_patch.sql backend/app/api/routes/master_data.py +``` + +Expected: output contains the new warehouse code, label, type, location code, and definition. + +--- + +### Task 4: Frontend Option Builder Tests And Logic + +**Files:** +- Modify: `frontend/src/utils/inventoryOperationOptions.js` +- Modify: `frontend/scripts/test-inventory-ledger-options.mjs` + +- [ ] **Step 1: Add failing frontend option tests for scrap warehouse** + +In `frontend/scripts/test-inventory-ledger-options.mjs`, after the auxiliary option assertions, add: + +```javascript +const scrapInboundOptions = buildGenericItemOptions({ + direction: "in", + activeInventoryTab: "scrap", + activeWarehouseType: "SCRAP", + warehouseId: 11, + balances: [], + materials: [{ item_id: 801, item_code: "原材料00001", item_name: "304边角料" }], + products: [{ item_id: 901, item_code: "产品需规00001", item_name: "报废冲压件" }] +}); + +assert.deepEqual(scrapInboundOptions.map((item) => item.item_id), [801, 901]); + +const scrapOutboundOptions = buildGenericItemOptions({ + direction: "out", + activeInventoryTab: "scrap", + activeWarehouseType: "SCRAP", + warehouseId: 11, + balances: [ + { + item_id: 801, + item_code: "原材料00001", + item_name: "304边角料", + warehouse_id: 11, + warehouse_type: "SCRAP", + qty_available: 0, + weight_available_kg: 18 + }, + { + item_id: 802, + item_code: "原材料00002", + item_name: "废铁", + warehouse_id: 11, + warehouse_type: "SCRAP", + qty_available: 99, + weight_available_kg: 0 + } + ], + materials: [], + products: [] +}); + +assert.equal(scrapOutboundOptions.length, 1); +assert.equal(scrapOutboundOptions[0].item_id, 801); +``` + +Add source checks near the bottom: + +```javascript +assert.match(inventoryLedgerSource, /key: "scrap"/); +assert.match(inventoryLedgerSource, /label: "废料库"/); +assert.match(inventoryLedgerSource, /PRODUCTION_SCRAP_IN/); +assert.match(inventoryLedgerSource, /OUTSOURCING_SCRAP_IN/); +assert.match(inventoryLedgerSource, /SCRAP_SALE_OUT/); +``` + +- [ ] **Step 2: Run frontend option test and verify it fails before logic changes** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node scripts/test-inventory-ledger-options.mjs +``` + +Expected before implementation: FAIL because `scrap` inbound does not return both materials and products, `SCRAP` is not weight-only, or the component source does not contain the new tab/operations. + +- [ ] **Step 3: Update option builder for scrap** + +In `frontend/src/utils/inventoryOperationOptions.js`, change: + +```javascript +function isWeightOnlyTab(tab) { + return ["raw", "auxiliary"].includes(String(tab || "")); +} +``` + +to: + +```javascript +function isWeightOnlyTab(tab) { + return ["raw", "auxiliary", "scrap"].includes(String(tab || "")); +} +``` + +Change: + +```javascript +const weightOnly = isWeightOnlyTab(activeInventoryTab) || ["RAW", "AUX"].includes(normalizedWarehouseType); +``` + +to: + +```javascript +const weightOnly = isWeightOnlyTab(activeInventoryTab) || ["RAW", "AUX", "SCRAP"].includes(normalizedWarehouseType); +``` + +Change inbound option selection: + +```javascript + if (["raw", "auxiliary"].includes(activeInventoryTab)) { + return materials; + } + return products; +``` + +to: + +```javascript + if (String(activeInventoryTab || "") === "scrap") { + return [...materials, ...products]; + } + if (["raw", "auxiliary"].includes(activeInventoryTab)) { + return materials; + } + return products; +``` + +- [ ] **Step 4: Re-run frontend option test** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node scripts/test-inventory-ledger-options.mjs +``` + +Expected at this point: scrap option assertions pass, source checks still fail until `InventoryLedgerView.vue` is updated in Task 5. + +--- + +### Task 5: Frontend Scrap Tab, Operations, And Sale Fields + +**Files:** +- Modify: `frontend/src/views/InventoryLedgerView.vue` +- Modify: `frontend/src/utils/dictionaries.js` + +- [ ] **Step 1: Add scrap tab constants and computed balances** + +In `frontend/src/views/InventoryLedgerView.vue`, update `WAREHOUSE_TYPE_BY_TAB`: + +```javascript +const WAREHOUSE_TYPE_BY_TAB = { + raw: "RAW", + semi: "SEMI", + finished: "FINISHED", + auxiliary: "AUX", + scrap: "SCRAP" +}; +``` + +Update `WAREHOUSE_LABEL_BY_TAB`: + +```javascript +const WAREHOUSE_LABEL_BY_TAB = { + raw: "原材料库", + semi: "半成品库", + finished: "成品库", + auxiliary: "辅料库", + scrap: "废料库" +}; +``` + +Add scrap balances after `auxiliaryBalances`: + +```javascript +const scrapBalances = computed(() => balances.value.filter((item) => normalizeWarehouseType(item) === "SCRAP")); +``` + +- [ ] **Step 2: Add fifth inventory tab** + +In `inventoryTabs`, append: + +```javascript + { + key: "scrap", + label: "废料库", + count: filteredScrapCount.value, + iconClass: "inventory-tab-icon-scrap", + iconPaths: ["M7 7.5 10 4h4l3 3.5", "M17 7.5h-3.2l1.6-2.2", "M17.5 11.5l2 3.5-2 3.5h-4", "M13.5 18.5l1.7-2.6", "M8.5 18.5h-4l-2-3.5 2-3.5", "M4.5 11.5 6.2 14"] + } +``` + +Add this after `filteredAuxiliaryCount`: + +```javascript +const filteredScrapCount = computed(() => scrapBalances.value.length); +``` + +- [ ] **Step 3: Add labels, title, active balance mapping, and weight-only logic** + +In `activeInventoryTitle`, add: + +```javascript + scrap: "废料库存与售卖出库" +``` + +In `activeInventoryTag`, add: + +```javascript + scrap: "废料库" +``` + +Change: + +```javascript +const activeWeightOnly = computed(() => ["raw", "auxiliary"].includes(activeInventoryTab.value)); +const selectedWeightOnly = computed(() => ["RAW", "AUX"].includes(selectedWarehouseType.value)); +``` + +to: + +```javascript +const activeWeightOnly = computed(() => ["raw", "auxiliary", "scrap"].includes(activeInventoryTab.value)); +const selectedWeightOnly = computed(() => ["RAW", "AUX", "SCRAP"].includes(selectedWarehouseType.value)); +``` + +In `activeBalances`, add: + +```javascript + scrap: scrapBalances.value +``` + +- [ ] **Step 4: Add scrap operation matrix** + +In `operationMatrix`, append: + +```javascript + scrap: { + inbound: [ + makeOperation("scrap-production-in", "in", "PRODUCTION_SCRAP_IN", "生产废料入库", "生产过程中产生的废料、边角料、报废品入废料库。"), + makeOperation("scrap-opening-in", "in", "OPENING", "期初入库", "仓库初始化时导入的初始废料。"), + makeOperation("scrap-outsourcing-in", "in", "OUTSOURCING_SCRAP_IN", "委外废料入库", "委外加工返回的废料、边角料、报废品入废料库。") + ], + outbound: [ + makeOperation("scrap-sale-out", "out", "SCRAP_SALE_OUT", "售卖出库", "废料售卖给回收商或客户后出库。") + ] + } +``` + +- [ ] **Step 5: Add sale-specific fields to the generic drawer** + +In the generic drawer template, below the outbound weight input and before remark, add: + +```vue + + + +``` + +Add computed amount: + +```javascript +const scrapSaleAmount = computed(() => { + const weight = Number(genericForm.weight_kg || 0); + const price = Number(genericForm.sale_unit_price || 0); + return Number.isFinite(weight * price) ? weight * price : 0; +}); +``` + +- [ ] **Step 6: Extend generic form state and submit payload** + +In `buildEmptyGenericForm()`, add: + +```javascript + sale_unit_price: "", + buyer_name: "", + sale_remark: "" +``` + +In `resetGenericForm()` or the function that clears generic form values, clear the same fields: + +```javascript + genericForm.sale_unit_price = ""; + genericForm.buyer_name = ""; + genericForm.sale_remark = ""; +``` + +In `submitGenericOperation()`, in the outbound payload sent to `/inventory/outbound`, add: + +```javascript + sale_unit_price: activeOperation.value?.bizType === "SCRAP_SALE_OUT" ? Number(genericForm.sale_unit_price || 0) : null, + buyer_name: activeOperation.value?.bizType === "SCRAP_SALE_OUT" ? genericForm.buyer_name || "" : null, + sale_remark: activeOperation.value?.bizType === "SCRAP_SALE_OUT" ? genericForm.remark || "" : null, +``` + +Before posting outbound, add frontend validation: + +```javascript + if (activeOperation.value?.bizType === "SCRAP_SALE_OUT" && Number(genericForm.sale_unit_price || 0) <= 0) { + errorMessage.value = "废料售卖出库必须填写大于0的售卖单价"; + return; + } +``` + +- [ ] **Step 7: Add dictionary labels** + +In `frontend/src/utils/dictionaries.js`, update `INVENTORY_TXN_TYPE_LABELS`: + +```javascript + PRODUCTION_SCRAP_IN: "生产废料入库", + OUTSOURCING_SCRAP_IN: "委外废料入库", + SCRAP_SALE_OUT: "废料售卖出库", +``` + +Update `SOURCE_DOC_TYPE_LABELS`: + +```javascript + PRODUCTION_SCRAP_IN: "生产废料入库", + OUTSOURCING_SCRAP_IN: "委外废料入库", + SCRAP_SALE_OUT: "废料售卖出库", +``` + +- [ ] **Step 8: Run frontend option test** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node scripts/test-inventory-ledger-options.mjs +``` + +Expected: `inventory ledger option tests passed`. + +- [ ] **Step 9: Run frontend build** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +npm run build +``` + +Expected: Vite build completes without Vue compile errors. + +--- + +### Task 6: End-To-End Verification Checklist + +**Files:** +- No new files. +- Verify changed files from Tasks 1 through 5. + +- [ ] **Step 1: Backend full targeted verification** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +python -m unittest tests.test_scrap_warehouse_flow tests.test_opening_inventory_import tests.test_stocktake_locking tests.test_stocktake_excel_diff_confirm -v +``` + +Expected: all tests pass. + +- [ ] **Step 2: Frontend targeted verification** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node scripts/test-inventory-ledger-options.mjs +node scripts/test-stocktake-workflow.mjs +npm run build +``` + +Expected: +- `inventory ledger option tests passed` +- `stocktake workflow tests passed` +- `npm run build` exits successfully + +- [ ] **Step 3: Static search verification** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +rg -n "SCRAP|废料库|PRODUCTION_SCRAP_IN|OUTSOURCING_SCRAP_IN|SCRAP_SALE_OUT" backend/app frontend/src backend/sql +``` + +Expected: +- Backend labels include `SCRAP: 废料库`. +- Backend allowed inbound map includes `PRODUCTION_SCRAP_IN`, `OPENING`, `OUTSOURCING_SCRAP_IN` for `SCRAP`. +- Backend allowed outbound map includes only `SCRAP_SALE_OUT` for `SCRAP`. +- Frontend tab and operation matrix include `废料库`. +- SQL patch includes `WH-SCRAP-01` and `SCR-E-01`. + +- [ ] **Step 4: Manual browser verification** + +Start the app using the project’s normal local commands: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +python -m uvicorn app.main:app --reload +``` + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +npm run dev +``` + +Expected manual checks: +- 嘉恒仓库 shows five warehouse tabs: 原材料库、半成品库、成品库、辅料库、废料库. +- 废料库 tab uses the same card style and selected style as the other four warehouses. +- 废料库 list is weight-only and does not show quantity columns. +- 入库 bar shows 生产废料入库、期初入库、委外废料入库. +- 出库 bar shows 售卖出库. +- 期初入库 exports `废料库期初导入模版.xlsx` with two sample rows. +- 生产废料入库 creates a new scrap lot and stock balance. +- 售卖出库 only offers items that have available scrap stock. +- 售卖出库 requires sale unit price and records buyer information in the inventory transaction remark. +- 盘库 can lock/export/import/confirm/unlock 废料库. + +--- + +## Self-Review + +- Spec coverage: The plan covers the fifth warehouse tab, three inbound operations, one outbound operation, backend validation, stock ledger writes, opening import/export, stocktake support, runtime default warehouse creation, SQL patch, Chinese labels, and targeted tests. +- Placeholder scan: No placeholder implementation steps remain. Every task names concrete files, snippets, commands, and expected outcomes. +- Type consistency: The plan uses `SCRAP`, `PRODUCTION_SCRAP_IN`, `OUTSOURCING_SCRAP_IN`, `SCRAP_SALE_OUT`, `sale_unit_price`, `buyer_name`, and `sale_remark` consistently across schemas, backend routes, frontend payloads, and tests. +- Scope check: This is one coherent warehouse-ledger extension. It does not add an independent scrap catalog, independent finance module, or unrelated warehouse UI refactor. diff --git a/docs/superpowers/plans/2026-06-01-delivery-entry-unification.md b/docs/superpowers/plans/2026-06-01-delivery-entry-unification.md new file mode 100644 index 0000000..6a47a45 --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-delivery-entry-unification.md @@ -0,0 +1,917 @@ +# Delivery Entry Unification Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `嘉恒仓库 -> 成品库 -> 销售出库` the only place that creates delivery outbound records, while `发货与售后 -> 发货管理` becomes a read-only delivery ledger with traceability. + +**Architecture:** Keep the existing `/sales/deliveries` API as the single write path for delivery creation. Extend the warehouse sales-out drawer to support both sales-order delivery and direct customer delivery, and remove the create path from the delivery management page. Preserve traceability through existing `DeliveryItem.lot_id -> StockLot.source_material_lot_id/source_material_sub_batch_no/source_material_summary` fields. + +**Tech Stack:** FastAPI + SQLAlchemy backend, Vue 3 SFC frontend, Node static regression scripts, pytest backend tests, Vite build. + +--- + +## File Structure + +- Modify: `backend/app/api/routes/sales.py` + - Enforce that delivery creation uses a `FINISHED` warehouse. + - Keep support for direct customer delivery with `sales_order_id = null` and `sales_order_item_id = null`. +- Modify: `backend/tests/test_sales_order_delivery_trace.py` + - Add regression coverage for direct customer delivery and non-finished warehouse rejection. +- Create: `frontend/scripts/test-delivery-entry-unification.mjs` + - Static regression checks for the unified entry contract. +- Modify: `frontend/src/views/InventoryLedgerView.vue` + - Add delivery mode selection to the成品库销售出库 drawer. + - Allow direct customer delivery. + - Show source material trace fields in the pre-submit allocation table. +- Modify: `frontend/src/views/DeliveryManagementView.vue` + - Remove create delivery UI and write-side script. + - Rename page to发货台账 and keep query/detail trace UI only. +- No DB schema changes expected. + +--- + +### Task 1: Backend Delivery Contract + +**Files:** +- Modify: `backend/tests/test_sales_order_delivery_trace.py` +- Modify: `backend/app/api/routes/sales.py` + +- [ ] **Step 1: Add failing backend tests for direct delivery and finished-warehouse validation** + +Append the following two tests inside `SalesOrderDeliveryTraceTest` before the `if __name__ == "__main__":` block in `backend/tests/test_sales_order_delivery_trace.py`: + +```python + def test_direct_customer_delivery_without_sales_order_preserves_trace_and_inventory(self) -> None: + delivery_result = create_delivery( + DeliveryCreate( + customer_id=self.customer.id, + warehouse_id=self.warehouse.id, + consignee_name=self.customer.contact_name, + consignee_phone=self.customer.contact_phone, + delivery_address=self.customer.address, + waybill_no="YD-DIRECT-001", + freight_amount=18, + items=[ + DeliveryItemCreate( + sales_order_item_id=None, + product_item_id=self.product.id, + lot_id=self.finished_lot.id, + delivery_qty=3, + delivery_weight_kg=6, + unit_price=0, + ) + ], + ), + context=self._auth_context(), + db=self.db, + ) + + delivery = self.db.get(Delivery, delivery_result.delivery_id) + delivery_item = self.db.scalar(select(DeliveryItem).where(DeliveryItem.delivery_id == delivery.id)) + trace_row = self.db.execute(get_delivery_items_query(limit=10, delivery_id=delivery.id)).mappings().first() + stock_balance = self.db.scalar(select(StockBalance).where(StockBalance.item_id == self.product.id)) + sales_out_txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "SALES_OUT")) + + self.assertIsNone(delivery.sales_order_id) + self.assertEqual(delivery.customer_id, self.customer.id) + self.assertIsNone(delivery_item.sales_order_item_id) + self.assertEqual(trace_row["source_material_lot_no"], "RML-001") + self.assertEqual(trace_row["source_material_code"], "MAT-001") + self.assertEqual(trace_row["source_material_name"], "冷轧钢板") + self.assertEqual(trace_row["source_material_sub_batch_no"], "RML-001") + self.assertEqual(trace_row["source_material_summary"], "RML-001 / 冷轧钢板 / 40kg / ¥200") + self.assertEqual(float(stock_balance.qty_on_hand), 17) + self.assertEqual(float(stock_balance.qty_available), 17) + self.assertEqual(sales_out_txn.source_doc_type, "DELIVERY") + self.assertEqual(sales_out_txn.source_doc_id, delivery.id) + + def test_sales_delivery_rejects_non_finished_warehouse(self) -> None: + raw_warehouse = Warehouse( + id=2, + warehouse_code="WH-RAW", + warehouse_name="原材料库", + warehouse_type="RAW", + status="ACTIVE", + ) + self.db.add(raw_warehouse) + self.db.commit() + + with self.assertRaises(HTTPException) as exc: + create_delivery( + DeliveryCreate( + customer_id=self.customer.id, + warehouse_id=raw_warehouse.id, + waybill_no="YD-WRONG-WH", + freight_amount=12, + items=[ + DeliveryItemCreate( + product_item_id=self.product.id, + lot_id=self.finished_lot.id, + delivery_qty=1, + delivery_weight_kg=2, + unit_price=0, + ) + ], + ), + context=self._auth_context(), + db=self.db, + ) + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("销售出库必须选择成品库", exc.exception.detail) +``` + +- [ ] **Step 2: Run backend test and verify the expected failure** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest tests/test_sales_order_delivery_trace.py -q +``` + +Expected before implementation: + +```text +FAILED backend/tests/test_sales_order_delivery_trace.py::SalesOrderDeliveryTraceTest::test_sales_delivery_rejects_non_finished_warehouse +``` + +The direct customer delivery test may already pass because the backend mostly supports it. The warehouse-type test must fail until validation is added. + +- [ ] **Step 3: Add explicit finished warehouse validation** + +In `backend/app/api/routes/sales.py`, inside `create_delivery`, immediately after: + +```python + warehouse = db.get(Warehouse, payload.warehouse_id) + if not warehouse: + raise HTTPException(status_code=404, detail="仓库不存在") +``` + +add: + +```python + if str(warehouse.warehouse_type or "").upper() != "FINISHED": + raise HTTPException(status_code=400, detail="销售出库必须选择成品库") +``` + +The surrounding code should become: + +```python + warehouse = db.get(Warehouse, payload.warehouse_id) + if not warehouse: + raise HTTPException(status_code=404, detail="仓库不存在") + if str(warehouse.warehouse_type or "").upper() != "FINISHED": + raise HTTPException(status_code=400, detail="销售出库必须选择成品库") + ensure_warehouses_unlocked(db, [payload.warehouse_id], "销售出库") +``` + +- [ ] **Step 4: Run backend test and verify it passes** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest tests/test_sales_order_delivery_trace.py -q +``` + +Expected: + +```text +passed +``` + +- [ ] **Step 5: Commit backend contract changes if git is available** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +git rev-parse --is-inside-work-tree +``` + +If the command prints `true`, run: + +```bash +git add backend/app/api/routes/sales.py backend/tests/test_sales_order_delivery_trace.py +git commit -m "test: protect unified delivery creation contract" +``` + +If it exits nonzero because this checkout is not a git repository, record that and continue without committing. + +--- + +### Task 2: Frontend Regression Guard + +**Files:** +- Create: `frontend/scripts/test-delivery-entry-unification.mjs` + +- [ ] **Step 1: Write the failing static regression script** + +Create `frontend/scripts/test-delivery-entry-unification.mjs` with this content: + +```js +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +const root = process.cwd(); + +function readSource(relativePath) { + return readFileSync(path.join(root, relativePath), "utf8"); +} + +const inventory = readSource("src/views/InventoryLedgerView.vue"); +const delivery = readSource("src/views/DeliveryManagementView.vue"); + +assert.match(inventory, /delivery_mode:\s*"SALES_ORDER"/, "sales-out form should default to sales-order delivery mode"); +assert.match(inventory, /直接客户发货/, "warehouse sales-out drawer should support direct customer delivery"); +assert.match(inventory, /销售订单发货/, "warehouse sales-out drawer should keep sales-order delivery mode"); +assert.match(inventory, /isSalesOrderDelivery/, "sales-out drawer should centralize mode-dependent behavior"); +assert.match(inventory, /setDeliveryMode\("DIRECT_CUSTOMER"\)/, "direct customer mode should be selectable"); +assert.match(inventory, /deliveryForm\.delivery_mode === "SALES_ORDER"/, "sales order fields should be conditional"); +assert.match(inventory, /source_material_summary/, "pre-submit allocation rows should expose source material summary"); +assert.match(inventory, />来源材料摘要成品库存批次号来源原材料库存批次号销售订单号 deliveryForm.delivery_mode === "SALES_ORDER"); +const isDirectCustomerDelivery = computed(() => deliveryForm.delivery_mode === "DIRECT_CUSTOMER"); +``` + +Update `selectableSalesOrderItems` to return no rows in direct mode: + +```js +const selectableSalesOrderItems = computed(() => { + if (!isSalesOrderDelivery.value) { + return []; + } + return salesOrderItems.value.filter( + (item) => Number(item.sales_order_id) === Number(deliveryForm.sales_order_id) && orderItemPendingQty(item) > 0 + ); +}); +``` + +Add this helper near `resetDeliveryForm()`: + +```js +function setDeliveryMode(mode) { + deliveryForm.delivery_mode = mode === "DIRECT_CUSTOMER" ? "DIRECT_CUSTOMER" : "SALES_ORDER"; + deliveryForm.sales_order_id = ""; + deliveryForm.sales_order_item_id = ""; + deliveryForm.product_item_id = ""; + deliveryForm.customer_id = ""; + deliveryForm.consignee_name = ""; + deliveryForm.consignee_phone = ""; + deliveryForm.delivery_address = ""; + deliveryForm.delivery_qty = 0; +} +``` + +- [ ] **Step 2: Make sales-order defaults no-op in direct mode** + +Replace `applySalesOrderDefaults()` with: + +```js +function applySalesOrderDefaults() { + deliveryForm.sales_order_item_id = ""; + if (!isSalesOrderDelivery.value) { + return; + } + const order = selectedSalesOrder.value; + if (!order) { + return; + } + deliveryForm.customer_id = order.customer_id; + deliveryForm.delivery_address = order.delivery_address || ""; + const customer = customers.value.find((item) => item.id === Number(order.customer_id)); + deliveryForm.consignee_name = customer?.contact_name || ""; + deliveryForm.consignee_phone = customer?.contact_phone || ""; + deliveryForm.delivery_address = order.delivery_address || customer?.address || ""; + const firstOpenItem = selectableSalesOrderItems.value[0]; + if (firstOpenItem) { + deliveryForm.sales_order_item_id = firstOpenItem.sales_order_item_id; + applySalesOrderItemDefaults(); + } +} +``` + +Replace `applySalesOrderItemDefaults()` with: + +```js +function applySalesOrderItemDefaults() { + const item = selectedSalesOrderItem.value; + if (!item || !isSalesOrderDelivery.value) { + return; + } + deliveryForm.product_item_id = item.product_item_id; + deliveryForm.delivery_qty = orderItemPendingQty(item); +} +``` + +Add a customer default helper if it does not already exist in this file: + +```js +function applyDeliveryCustomerDefaults() { + if (isSalesOrderDelivery.value) { + return; + } + const customer = customers.value.find((item) => item.id === Number(deliveryForm.customer_id)); + deliveryForm.consignee_name = customer?.contact_name || ""; + deliveryForm.consignee_phone = customer?.contact_phone || ""; + deliveryForm.delivery_address = customer?.address || ""; +} +``` + +- [ ] **Step 3: Update sales-out drawer template** + +Inside the `FormDrawer` block whose open prop is `deliveryDrawerOpen`, replace the first sales-order field group with this mode section and conditional sales-order fields: + +```vue +
+ + +
+ +
+ + +
+``` + +In the product/customer area: + +- Change the product select to `:disabled="isSalesOrderDelivery && Boolean(deliveryForm.sales_order_item_id)"`. +- Change the customer select to `:disabled="isSalesOrderDelivery"` and add `@change="applyDeliveryCustomerDefaults"`. +- Change the quantity input max to `:max="isSalesOrderDelivery && selectedSalesOrderItem ? orderItemPendingQty(selectedSalesOrderItem) : undefined"`. + +The relevant fields should look like: + +```vue + +``` + +```vue + + +``` + +- [ ] **Step 3: Replace source-lot text input with multi-select for production surplus inbound** + +Replace the existing `来源库存批次号` label with two branches: + +```vue + + + +``` + +- [ ] **Step 4: Add return-weight table** + +Add this block after the production-surplus `StockLotTagSelect` label: + +```vue +
+ + + + + + + + + + + + + + + + + +
来源库存批次号原材料剩余重量归还重量(kg)
{{ lot.lot_no }}{{ lot.item_code }} · {{ lot.item_name }}{{ formatWeight(lot.remaining_weight_kg) }} + +
+
+ +
+ 本次归还总重量:{{ formatWeight(genericSourceLotReturnWeight) }} +
+``` + +## Task 3: Submission Logic + +**Files:** +- Modify: `frontend/src/views/InventoryLedgerView.vue` + +- [ ] **Step 1: Add validation for production surplus source lots** + +Add this function before `submitGenericOperation()`: + +```js +function validateProductionSurplusSourceLots() { + if (!isProductionSurplusInbound.value) { + return ""; + } + if (!selectedGenericSourceLots.value.length) { + return "请选择至少一个来源库存批次号"; + } + for (const lot of selectedGenericSourceLots.value) { + const weight = Number(lot.return_weight_kg || 0); + if (!Number.isFinite(weight) || weight <= 0) { + return `来源库存批次号 ${lot.lot_no || "-"} 的归还重量必须大于0`; + } + } + return ""; +} +``` + +- [ ] **Step 2: Submit one inbound record per selected source lot** + +At the start of the inbound branch inside `submitGenericOperation()`, add a special case: + +```js +if (isProductionSurplusInbound.value) { + const validationError = validateProductionSurplusSourceLots(); + if (validationError) { + errorMessage.value = validationError; + submittingOperation.value = false; + return; + } + for (const lot of selectedGenericSourceLots.value) { + await postResource("/inventory/inbound", { + biz_type: activeOperation.value.bizType, + item_id: Number(genericForm.item_id), + warehouse_id: Number(genericForm.warehouse_id), + location_id: genericForm.location_id ? Number(genericForm.location_id) : null, + inbound_weight_kg: Number(lot.return_weight_kg || 0), + unit_cost: Number(genericForm.unit_cost || 0), + lot_no: null, + source_material_sub_batch_no: lot.lot_no || null, + source_material_summary: lot.lot_no || null, + provider_name: null, + waybill_no: null, + order_photo_url: null, + freight_amount: null, + remark: genericForm.remark || null + }); + } + feedbackMessage.value = `${activeOperation.value.label}已登记`; + genericDrawerOpen.value = false; + await loadAll(); + return; +} +``` + +## Task 4: Verification + +**Files:** +- Verify existing frontend/backend. + +- [ ] **Step 1: Build frontend** + +Run: + +```bash +cd frontend && npm run build +``` + +Expected: build succeeds. Vite chunk size warning is acceptable if present. + +- [ ] **Step 2: Run backend tests** + +Run: + +```bash +cd backend && .venv/bin/python -m pytest tests -q +``` + +Expected: all existing backend tests pass. No new backend behavior is introduced. diff --git a/docs/superpowers/plans/2026-06-01-sales-order-item-promised-date-default.md b/docs/superpowers/plans/2026-06-01-sales-order-item-promised-date-default.md new file mode 100644 index 0000000..451fcb4 --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-sales-order-item-promised-date-default.md @@ -0,0 +1,127 @@ +# Sales Order Item Promised Date Default Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make sales order line promised dates default to the sales order promised date while preserving user-edited line dates. + +**Architecture:** This is a frontend-only interaction change because the backend already falls back from line promised date to order promised date on save. `SalesPlanningView.vue` will track whether a line date was manually edited, auto-fill empty/unmodified rows when the header date changes, and default newly added rows from the current header date. + +**Tech Stack:** Vue 3 Composition API, existing Vite frontend, lightweight Node source-regression script. + +--- + +### Task 1: Add Regression Coverage + +**Files:** +- Create: `frontend/scripts/test-sales-order-item-promised-date-default.mjs` +- Read: `frontend/src/views/SalesPlanningView.vue` + +- [ ] **Step 1: Write source-regression checks** + +```js +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +const viewPath = path.resolve("src/views/SalesPlanningView.vue"); +const source = fs.readFileSync(viewPath, "utf8"); + +assert.match(source, /promised_date_touched:\s*false/, "new sales order lines should track whether line promised date was manually edited"); +assert.match(source, /@input="markSalesOrderItemPromisedDateTouched\(item\)"/, "line promised date input should mark the row as user-edited"); +assert.match(source, /syncSalesOrderItemPromisedDatesFromHeader/, "header promised date changes should synchronize eligible line dates"); +assert.match(source, /!item\.promised_date_touched/, "sync should only update untouched line promised dates"); +assert.match(source, /promised_date:\s*salesOrderForm\.promised_date\s*\|\|\s*null/, "submit should fall back empty line dates to the sales order promised date"); + +console.log("sales order line promised date default checks passed"); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd frontend && node scripts/test-sales-order-item-promised-date-default.mjs` + +Expected: FAIL before implementation because the source does not yet track `promised_date_touched`. + +### Task 2: Implement Frontend Behavior + +**Files:** +- Modify: `frontend/src/views/SalesPlanningView.vue` + +- [ ] **Step 1: Add row touch tracking and defaulting** + +Change `buildSalesOrderItemRow()` so each new row defaults from the current order promised date and tracks manual edits: + +```js +function buildSalesOrderItemRow() { + return { + row_uid: `sales-order-item-${salesOrderItemSerial++}`, + product_item_id: "", + order_qty: 0, + unit_price: 0, + promised_date: salesOrderForm.promised_date || "", + promised_date_touched: false, + customer_part_no: "" + }; +} +``` + +- [ ] **Step 2: Add header-date synchronization helpers** + +Add these helpers near `addSalesOrderItem()`: + +```js +function syncSalesOrderItemPromisedDatesFromHeader() { + salesOrderForm.items.forEach((item) => { + if (!item.promised_date_touched || !item.promised_date) { + item.promised_date = salesOrderForm.promised_date || ""; + } + }); +} + +function markSalesOrderItemPromisedDateTouched(item) { + item.promised_date_touched = true; +} +``` + +- [ ] **Step 3: Wire the header date watcher and line input** + +Add a watcher: + +```js +watch( + () => salesOrderForm.promised_date, + () => syncSalesOrderItemPromisedDatesFromHeader() +); +``` + +Update the line promised date input: + +```vue + +``` + +- [ ] **Step 4: Add submit fallback** + +Change the submitted line payload: + +```js +promised_date: item.promised_date || salesOrderForm.promised_date || null, +``` + +### Task 3: Verify + +**Files:** +- Test: `frontend/scripts/test-sales-order-item-promised-date-default.mjs` +- Test: frontend production build + +- [ ] **Step 1: Run source-regression script** + +Run: `cd frontend && node scripts/test-sales-order-item-promised-date-default.mjs` + +Expected: PASS with `sales order line promised date default checks passed`. + +- [ ] **Step 2: Run frontend build** + +Run: `cd frontend && npm run build` + +Expected: build exits 0. The existing Vite chunk-size warning may remain. + diff --git a/docs/superpowers/plans/2026-06-01-warehouse-transaction-ledger.md b/docs/superpowers/plans/2026-06-01-warehouse-transaction-ledger.md new file mode 100644 index 0000000..188d20a --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-warehouse-transaction-ledger.md @@ -0,0 +1,1248 @@ +# Warehouse Transaction Ledger Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a warehouse-level “流水” entry in 嘉恒仓库 so each of the six warehouses can view paginated, searchable, sortable inbound/outbound inventory transactions. + +**Architecture:** Keep `wh_inventory_txn` as the single source of truth. Add a new backend pagination endpoint for warehouse-level transaction ledger queries, then add a right-side drawer in `InventoryLedgerView.vue` that follows the active warehouse tab and displays summary cards, filters, and transaction rows. Keep the existing material-level stock detail drawer unchanged. + +**Tech Stack:** FastAPI, SQLAlchemy ORM, Pydantic schemas, SQLite backend unit tests, Vue 3 SFC, Vite, existing `FormDrawer`, `PaginationBar`, `TableControls`, and notification conventions. + +--- + +## Confirmed Requirements + +- Add a “流水” icon/text button in 嘉恒仓库, positioned on the right side of the existing inbound/outbound operation bar. +- The button follows the active warehouse tab: 原材料库、半成品库、成品库、辅料库、废料库、退货库. +- Clicking opens a right-side drawer titled `{当前仓库名} · 出入库流水`. +- The drawer shows summary cards, filters, and a paginated table. +- Data comes from `wh_inventory_txn`; do not create a second ledger table. +- Warehouse-level ledger does not replace the existing material-level stock detail drawer. +- 原材料库、辅料库、废料库 use weight-first display and must not revive raw material quantity concepts. + +## File Structure + +- Create `backend/tests/test_warehouse_transaction_ledger.py` + - Owns backend regression coverage for warehouse filtering, direction, keyword, pagination, summary, and raw-material quantity display. +- Modify `backend/app/schemas/operations.py` + - Add response schemas for ledger rows, summary, and paginated response. +- Modify `backend/app/services/operations.py` + - Add reusable ledger query helpers and summary calculation. +- Modify `backend/app/api/routes/inventory.py` + - Add `GET /inventory/transaction-ledger`. +- Create `frontend/scripts/test-inventory-ledger-transaction-ledger.mjs` + - Static checks for the new drawer, endpoint, and preservation of existing material-level flow. +- Modify `frontend/src/views/InventoryLedgerView.vue` + - Add the “流水” action button, drawer, filters, server-side loading state, and table rendering. + +--- + +### Task 1: Add Backend Failing Tests For Warehouse Transaction Ledger + +**Files:** +- Create: `backend/tests/test_warehouse_transaction_ledger.py` + +- [ ] **Step 1: Create the backend test file scaffold** + +Create `backend/tests/test_warehouse_transaction_ledger.py` with this content: + +```python +from __future__ import annotations + +import unittest +from datetime import UTC, datetime, timedelta +from decimal import Decimal + +from sqlalchemy import BigInteger, create_engine +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +from app.models.base import Base # noqa: E402 + + +class WarehouseTransactionLedgerTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + + def tearDown(self) -> None: + self.db.close() + + def seed_ledger_data(self) -> None: + from app.models.master_data import Item, Warehouse + from app.models.operations import InventoryTxn, StockLot, WarehouseLocation + from app.models.org import Department, Employee, User + + now = datetime(2026, 6, 1, 9, 0, tzinfo=UTC) + department = Department( + id=1, + dept_code="D001", + dept_name="仓储部", + dept_type="WAREHOUSE", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + employee = Employee( + id=1, + employee_code="EMP001", + employee_name="张仓管", + dept_id=1, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + user = User( + id=1, + username="keeper", + password_hash="x", + employee_id=1, + dept_id=1, + nickname="张仓管", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + raw_wh = Warehouse( + id=1, + warehouse_code="WH-RAW-01", + warehouse_name="原材料库", + warehouse_type="RAW", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + finished_wh = Warehouse( + id=2, + warehouse_code="WH-FG-01", + warehouse_name="成品库", + warehouse_type="FINISHED", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + raw_location = WarehouseLocation( + id=1, + warehouse_id=1, + location_code="RAW-A", + location_name="原料主库位", + is_default=1, + is_locked=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + finished_location = WarehouseLocation( + id=2, + warehouse_id=2, + location_code="FG-A", + location_name="成品主库位", + is_default=1, + is_locked=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + raw_item = Item( + id=1, + item_code="原材料00001", + item_name="304不锈钢", + item_type="RAW_MATERIAL", + unit_weight_kg=Decimal("1"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + finished_item = Item( + id=2, + item_code="产品需规00001", + item_name="冲压支架", + item_type="FINISHED_GOOD", + unit_weight_kg=Decimal("2.5"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + raw_lot = StockLot( + id=1, + lot_no="JH_原材料00001_0001", + lot_role="INBOUND_RAW", + item_id=1, + warehouse_id=1, + location_id=1, + source_doc_type="OPENING_STOCK", + source_doc_id=1, + inbound_qty=0, + inbound_weight_kg=1000, + remaining_qty=0, + remaining_weight_kg=850, + locked_qty=0, + locked_weight_kg=0, + unit_cost=Decimal("10"), + quality_status="PASS", + status="AVAILABLE", + created_at=now, + updated_at=now, + ) + finished_lot = StockLot( + id=2, + lot_no="FG-20260601-001", + lot_role="INBOUND_FINISHED", + item_id=2, + warehouse_id=2, + location_id=2, + source_doc_type="FG_RECEIPT", + source_doc_id=9, + inbound_qty=100, + inbound_weight_kg=250, + remaining_qty=80, + remaining_weight_kg=200, + locked_qty=0, + locked_weight_kg=0, + unit_cost=Decimal("18"), + quality_status="PASS", + status="AVAILABLE", + created_at=now, + updated_at=now, + ) + transactions = [ + InventoryTxn( + id=1, + txn_no="TXN-RAW-IN", + txn_type="OPENING_IN", + item_id=1, + warehouse_id=1, + location_id=1, + lot_id=1, + qty_change=0, + weight_change_kg=1000, + unit_cost=10, + amount=10000, + source_doc_type="OPENING_STOCK", + source_doc_id=1, + biz_time=now, + operator_user_id=1, + remark="期初入库 304", + created_at=now, + updated_at=now, + ), + InventoryTxn( + id=2, + txn_no="TXN-RAW-OUT", + txn_type="MATERIAL_ISSUE", + item_id=1, + warehouse_id=1, + location_id=1, + lot_id=1, + qty_change=0, + weight_change_kg=-150, + unit_cost=10, + amount=1500, + source_doc_type="WORK_ORDER", + source_doc_id=2, + biz_time=now + timedelta(hours=1), + operator_user_id=1, + remark="生产出库", + created_at=now, + updated_at=now, + ), + InventoryTxn( + id=3, + txn_no="TXN-FG-IN", + txn_type="FG_IN", + item_id=2, + warehouse_id=2, + location_id=2, + lot_id=2, + qty_change=100, + weight_change_kg=250, + unit_cost=18, + amount=1800, + source_doc_type="FG_RECEIPT", + source_doc_id=9, + biz_time=now + timedelta(hours=2), + operator_user_id=1, + remark="成品入库", + created_at=now, + updated_at=now, + ), + ] + self.db.add_all([department, employee, user, raw_wh, finished_wh, raw_location, finished_location, raw_item, finished_item, raw_lot, finished_lot, *transactions]) + self.db.commit() +``` + +- [ ] **Step 2: Add the failing warehouse filter and summary test** + +Append this test to `WarehouseTransactionLedgerTest`: + +```python + def test_transaction_ledger_filters_by_warehouse_type_and_returns_summary(self) -> None: + from app.api.routes.inventory import list_inventory_transaction_ledger + + self.seed_ledger_data() + + result = list_inventory_transaction_ledger( + warehouse_type="RAW", + page=1, + page_size=10, + db=self.db, + ) + + self.assertEqual(result.total, 2) + self.assertEqual([row.warehouse_type for row in result.items], ["RAW", "RAW"]) + self.assertEqual(result.summary.in_count, 1) + self.assertEqual(result.summary.out_count, 1) + self.assertEqual(Decimal(str(result.summary.in_weight_kg)), Decimal("1000.0")) + self.assertEqual(Decimal(str(result.summary.out_weight_kg)), Decimal("150.0")) + self.assertEqual(result.items[0].txn_type, "MATERIAL_ISSUE") + self.assertEqual(result.items[0].direction, "OUT") +``` + +- [ ] **Step 3: Add the failing keyword, direction, pagination, and raw quantity test** + +Append this test to `WarehouseTransactionLedgerTest`: + +```python + def test_transaction_ledger_supports_keyword_direction_pagination_and_raw_qty_zero(self) -> None: + from app.api.routes.inventory import list_inventory_transaction_ledger + + self.seed_ledger_data() + + result = list_inventory_transaction_ledger( + warehouse_type="RAW", + keyword="304", + direction="OUT", + page=1, + page_size=1, + db=self.db, + ) + + self.assertEqual(result.total, 1) + self.assertEqual(len(result.items), 1) + row = result.items[0] + self.assertEqual(row.txn_no, "TXN-RAW-OUT") + self.assertEqual(row.item_code, "原材料00001") + self.assertEqual(row.lot_no, "JH_原材料00001_0001") + self.assertEqual(row.qty_change, 0) + self.assertEqual(row.operator_name, "张仓管") + self.assertEqual(result.summary.out_count, 1) +``` + +- [ ] **Step 4: Add the failing invalid warehouse type test** + +Append this test to `WarehouseTransactionLedgerTest`: + +```python + def test_transaction_ledger_rejects_unknown_warehouse_type(self) -> None: + from app.api.routes.inventory import list_inventory_transaction_ledger + from fastapi import HTTPException + + self.seed_ledger_data() + + with self.assertRaises(HTTPException) as exc: + list_inventory_transaction_ledger( + warehouse_type="UNKNOWN", + page=1, + page_size=10, + db=self.db, + ) + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("未知仓库类型", exc.exception.detail) +``` + +- [ ] **Step 5: Run tests and verify they fail for missing API** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest tests/test_warehouse_transaction_ledger.py -q +``` + +Expected: FAIL with an import or attribute error for `list_inventory_transaction_ledger`. + +--- + +### Task 2: Add Backend Schemas, Query Helper, And API Route + +**Files:** +- Modify: `backend/app/schemas/operations.py` +- Modify: `backend/app/services/operations.py` +- Modify: `backend/app/api/routes/inventory.py` +- Test: `backend/tests/test_warehouse_transaction_ledger.py` + +- [ ] **Step 1: Add response schemas** + +In `backend/app/schemas/operations.py`, after `InventoryTxnRead`, add: + +```python +class InventoryTxnLedgerRowRead(InventoryTxnRead): + direction: str + source_line_id: int | None = None + operator_user_id: int | None = None + operator_name: str | None = None + + +class InventoryTxnLedgerSummaryRead(BaseModel): + in_count: int = 0 + out_count: int = 0 + adjust_count: int = 0 + in_qty: float = 0 + out_qty: float = 0 + in_weight_kg: float = 0 + out_weight_kg: float = 0 + latest_biz_time: datetime | None = None + + +class InventoryTxnLedgerResponse(BaseModel): + items: list[InventoryTxnLedgerRowRead] + total: int + summary: InventoryTxnLedgerSummaryRead +``` + +- [ ] **Step 2: Import the schemas in inventory route** + +In `backend/app/api/routes/inventory.py`, extend the schema import block: + +```python +from app.schemas.operations import ( + ... + InventoryTxnLedgerResponse, +) +``` + +Keep existing imports and add only the new name. + +- [ ] **Step 3: Add service constants and helpers** + +In `backend/app/services/operations.py`, near `get_inventory_txn_query`, add: + +```python +WAREHOUSE_LEDGER_TYPES = {"RAW", "SEMI", "FINISHED", "AUX", "SCRAP", "RETURN"} + +WAREHOUSE_LEDGER_SORT_COLUMNS = { + "biz_time": InventoryTxn.biz_time, + "txn_type": InventoryTxn.txn_type, + "item_code": Item.item_code, + "item_name": Item.item_name, + "lot_no": StockLot.lot_no, + "qty_change": InventoryTxn.qty_change, + "weight_change_kg": InventoryTxn.weight_change_kg, + "amount": InventoryTxn.amount, +} + + +def inventory_txn_direction_expression(): + return case( + (or_(InventoryTxn.txn_type.like("STOCKTAKE_%"), InventoryTxn.source_doc_type == "STOCKTAKE"), "ADJUST"), + (or_(InventoryTxn.weight_change_kg > 0, InventoryTxn.qty_change > 0), "IN"), + (or_(InventoryTxn.weight_change_kg < 0, InventoryTxn.qty_change < 0), "OUT"), + else_="ADJUST", + ) +``` + +- [ ] **Step 4: Add the ledger row query helper** + +In `backend/app/services/operations.py`, below the helper above, add: + +```python +def get_inventory_txn_ledger_query( + *, + warehouse_type: str, + warehouse_id: int | None = None, + keyword: str | None = None, + direction: str = "ALL", + txn_type: str | None = None, + item_id: int | None = None, + lot_no: str | None = None, + start_date: date | None = None, + end_date: date | None = None, + sort_key: str = "biz_time", + sort_direction: str = "desc", +) -> Select: + normalized_warehouse_type = str(warehouse_type or "").upper() + direction_expr = inventory_txn_direction_expression().label("direction") + operator_employee = aliased(Employee) + stmt = ( + select( + InventoryTxn.id.label("inventory_txn_id"), + InventoryTxn.txn_no.label("txn_no"), + InventoryTxn.txn_type.label("txn_type"), + InventoryTxn.item_id.label("item_id"), + Item.item_code.label("item_code"), + Item.item_name.label("item_name"), + InventoryTxn.warehouse_id.label("warehouse_id"), + Warehouse.warehouse_name.label("warehouse_name"), + Warehouse.warehouse_type.label("warehouse_type"), + InventoryTxn.location_id.label("location_id"), + WarehouseLocation.location_name.label("location_name"), + InventoryTxn.lot_id.label("lot_id"), + StockLot.lot_no.label("lot_no"), + case((Item.item_type == "RAW_MATERIAL", literal(0)), else_=InventoryTxn.qty_change).label("qty_change"), + InventoryTxn.weight_change_kg.label("weight_change_kg"), + InventoryTxn.unit_cost.label("unit_cost"), + InventoryTxn.amount.label("amount"), + InventoryTxn.source_doc_type.label("source_doc_type"), + InventoryTxn.source_doc_id.label("source_doc_id"), + InventoryTxn.source_line_id.label("source_line_id"), + InventoryTxn.biz_time.label("biz_time"), + InventoryTxn.operator_user_id.label("operator_user_id"), + func.coalesce(operator_employee.employee_name, User.nickname, User.username).label("operator_name"), + InventoryTxn.logistics_waybill_no.label("logistics_waybill_no"), + InventoryTxn.logistics_freight_amount.label("logistics_freight_amount"), + InventoryTxn.logistics_photo_url.label("logistics_photo_url"), + InventoryTxn.remark.label("remark"), + direction_expr, + ) + .join(Item, Item.id == InventoryTxn.item_id) + .join(Warehouse, Warehouse.id == InventoryTxn.warehouse_id) + .outerjoin(WarehouseLocation, WarehouseLocation.id == InventoryTxn.location_id) + .outerjoin(StockLot, StockLot.id == InventoryTxn.lot_id) + .outerjoin(User, User.id == InventoryTxn.operator_user_id) + .outerjoin(operator_employee, operator_employee.id == User.employee_id) + .where(Warehouse.warehouse_type == normalized_warehouse_type) + ) + if warehouse_id: + stmt = stmt.where(InventoryTxn.warehouse_id == warehouse_id) + if txn_type: + stmt = stmt.where(InventoryTxn.txn_type == str(txn_type).upper()) + if item_id: + stmt = stmt.where(InventoryTxn.item_id == item_id) + if lot_no: + stmt = stmt.where(StockLot.lot_no.like(f"%{lot_no.strip()}%")) + if start_date: + stmt = stmt.where(InventoryTxn.biz_time >= start_of_day(start_date)) + if end_date: + stmt = stmt.where(InventoryTxn.biz_time <= end_of_day(end_date)) + if keyword and keyword.strip(): + pattern = f"%{keyword.strip()}%" + stmt = stmt.where( + or_( + Item.item_code.like(pattern), + Item.item_name.like(pattern), + InventoryTxn.txn_type.like(pattern), + InventoryTxn.source_doc_type.like(pattern), + InventoryTxn.txn_no.like(pattern), + InventoryTxn.logistics_waybill_no.like(pattern), + InventoryTxn.remark.like(pattern), + StockLot.lot_no.like(pattern), + ) + ) + normalized_direction = str(direction or "ALL").upper() + if normalized_direction in {"IN", "OUT", "ADJUST"}: + stmt = stmt.where(direction_expr == normalized_direction) + + sort_column = WAREHOUSE_LEDGER_SORT_COLUMNS.get(sort_key, InventoryTxn.biz_time) + if str(sort_direction or "desc").lower() == "asc": + stmt = stmt.order_by(sort_column.asc(), InventoryTxn.id.asc()) + else: + stmt = stmt.order_by(sort_column.desc(), InventoryTxn.id.desc()) + return stmt +``` + +- [ ] **Step 5: Add the summary helper** + +In `backend/app/services/operations.py`, below `get_inventory_txn_ledger_query`, add: + +```python +def summarize_inventory_txn_ledger(db: Session, ledger_stmt: Select) -> dict[str, object]: + rows = db.execute(ledger_stmt).mappings().all() + summary = { + "in_count": 0, + "out_count": 0, + "adjust_count": 0, + "in_qty": Decimal("0"), + "out_qty": Decimal("0"), + "in_weight_kg": Decimal("0"), + "out_weight_kg": Decimal("0"), + "latest_biz_time": None, + } + for row in rows: + direction = str(row["direction"] or "ADJUST").upper() + qty = abs(to_decimal(row["qty_change"])) + weight = abs(to_decimal(row["weight_change_kg"])) + if direction == "IN": + summary["in_count"] += 1 + summary["in_qty"] += qty + summary["in_weight_kg"] += weight + elif direction == "OUT": + summary["out_count"] += 1 + summary["out_qty"] += qty + summary["out_weight_kg"] += weight + else: + summary["adjust_count"] += 1 + biz_time = row["biz_time"] + if biz_time and (summary["latest_biz_time"] is None or biz_time > summary["latest_biz_time"]): + summary["latest_biz_time"] = biz_time + return summary +``` + +- [ ] **Step 6: Import helpers into inventory route** + +In `backend/app/api/routes/inventory.py`, add these service imports to the existing import list from `app.services.operations`: + +```python + WAREHOUSE_LEDGER_TYPES, + get_inventory_txn_ledger_query, + summarize_inventory_txn_ledger, +``` + +- [ ] **Step 7: Add the API endpoint** + +In `backend/app/api/routes/inventory.py`, after `list_inventory_transactions`, add: + +```python +@router.get("/transaction-ledger", response_model=InventoryTxnLedgerResponse) +def list_inventory_transaction_ledger( + warehouse_type: str = Query(...), + warehouse_id: int | None = Query(default=None, ge=1), + keyword: str | None = Query(default=None), + direction: str = Query(default="ALL"), + txn_type: str | None = Query(default=None), + item_id: int | None = Query(default=None, ge=1), + lot_no: str | None = Query(default=None), + start_date: date | None = Query(default=None), + end_date: date | None = Query(default=None), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=10, ge=1, le=100), + sort_key: str = Query(default="biz_time"), + sort_direction: str = Query(default="desc"), + db: Session = Depends(get_db), +) -> InventoryTxnLedgerResponse: + normalized_warehouse_type = str(warehouse_type or "").upper() + if normalized_warehouse_type not in WAREHOUSE_LEDGER_TYPES: + raise HTTPException(status_code=400, detail="未知仓库类型,无法查询出入库流水") + normalized_direction = str(direction or "ALL").upper() + if normalized_direction not in {"ALL", "IN", "OUT", "ADJUST"}: + raise HTTPException(status_code=400, detail="流水方向只能是 ALL、IN、OUT、ADJUST") + + stmt = get_inventory_txn_ledger_query( + warehouse_type=normalized_warehouse_type, + warehouse_id=warehouse_id, + keyword=keyword, + direction=normalized_direction, + txn_type=txn_type, + item_id=item_id, + lot_no=lot_no, + start_date=start_date, + end_date=end_date, + sort_key=sort_key, + sort_direction=sort_direction, + ) + total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0 + summary = summarize_inventory_txn_ledger(db, stmt) + offset = (page - 1) * page_size + rows = db.execute(stmt.offset(offset).limit(page_size)).mappings().all() + return InventoryTxnLedgerResponse( + items=[dict(row) for row in rows], + total=total, + summary=summary, + ) +``` + +- [ ] **Step 8: Run backend tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest tests/test_warehouse_transaction_ledger.py -q +``` + +Expected: PASS. + +- [ ] **Step 9: Run related backend regression tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest tests/test_return_warehouse_flow.py tests/test_opening_inventory_import.py tests/test_scrap_warehouse_flow.py::ScrapWarehouseFlowTest::test_scrap_stocktake_keeps_product_scrap_weight_only_after_confirm -q +``` + +Expected: PASS. + +- [ ] **Step 10: Compile changed backend files** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m py_compile app/api/routes/inventory.py app/services/operations.py app/schemas/operations.py tests/test_warehouse_transaction_ledger.py +``` + +Expected: exit code 0. + +--- + +### Task 3: Add Frontend Static Checks For Warehouse-Level Ledger + +**Files:** +- Create: `frontend/scripts/test-inventory-ledger-transaction-ledger.mjs` + +- [ ] **Step 1: Create the failing static test script** + +Create `frontend/scripts/test-inventory-ledger-transaction-ledger.mjs`: + +```javascript +import fs from "node:fs"; +import path from "node:path"; + +const root = process.cwd(); +const viewPath = path.join(root, "src/views/InventoryLedgerView.vue"); +const view = fs.readFileSync(viewPath, "utf8"); + +const checks = [ + ["ledger button exists", view.includes("openWarehouseLedgerDrawer") && view.includes("查看当前仓库出入库流水")], + ["ledger drawer exists", view.includes("warehouseLedgerDrawerOpen") && view.includes("出入库流水")], + ["ledger endpoint used", view.includes("/inventory/transaction-ledger")], + ["warehouse tab drives ledger", view.includes("activeWarehouseType") && view.includes("activeInventoryLabel")], + ["server pagination state exists", view.includes("warehouseLedgerPage") && view.includes("warehouseLedgerPageSize") && view.includes("warehouseLedgerTotal")], + ["existing material stock detail remains", view.includes("openStockDetail(balance)") && view.includes("selectedTransactions")], + ["six warehouse tabs still present", ["raw", "semi", "finished", "auxiliary", "scrap", "return"].every((key) => view.includes(`key: "${key}"`))] +]; + +const failed = checks.filter(([, passed]) => !passed); +if (failed.length) { + console.error("Warehouse transaction ledger UI checks failed:"); + failed.forEach(([name]) => console.error(`- ${name}`)); + process.exit(1); +} + +console.log("Warehouse transaction ledger UI checks passed"); +``` + +- [ ] **Step 2: Run static test and verify it fails** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node scripts/test-inventory-ledger-transaction-ledger.mjs +``` + +Expected: FAIL and list missing ledger UI checks. + +--- + +### Task 4: Add Warehouse Ledger Drawer UI In InventoryLedgerView + +**Files:** +- Modify: `frontend/src/views/InventoryLedgerView.vue` +- Test: `frontend/scripts/test-inventory-ledger-transaction-ledger.mjs` + +- [ ] **Step 1: Add the ledger button to the operation bar** + +In `frontend/src/views/InventoryLedgerView.vue`, inside `
`, after the two existing `.inventory-io-row` blocks, add: + +```vue + +``` + +- [ ] **Step 2: Add the ledger drawer template** + +In the same Vue file, before ` +
+ + + +
+
+ 入库笔数 + {{ warehouseLedgerSummary.in_count || 0 }} +
+
+ 出库笔数 + {{ warehouseLedgerSummary.out_count || 0 }} +
+
+ 入库重量 + {{ formatWeight(warehouseLedgerSummary.in_weight_kg || 0) }} +
+
+ 出库重量 + {{ formatWeight(warehouseLedgerSummary.out_weight_kg || 0) }} +
+
+ 最近流水 + {{ warehouseLedgerSummary.latest_biz_time ? formatDateTime(warehouseLedgerSummary.latest_biz_time) : "-" }} +
+
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
时间方向类型物料批次号变动数量变动重量(kg)单价金额来源经办人运单/照片备注
{{ formatDateTime(row.biz_time) }}{{ formatWarehouseLedgerDirection(row.direction) }}{{ formatInventoryTxnTypeLabel(row.txn_type) }}{{ row.item_code }} · {{ row.item_name }}{{ row.lot_no || "-" }}{{ formatQty(row.qty_change) }}{{ formatWeight(row.weight_change_kg) }}¥{{ formatAmount(row.unit_cost) }}¥{{ formatAmount(row.amount) }}{{ formatSourceDocTypeLabel(row.source_doc_type) }} #{{ row.source_doc_id }}{{ row.operator_name || "-" }} + {{ row.logistics_waybill_no || "-" }} + + {{ row.remark || "-" }}
+ {{ warehouseLedgerLoading ? "流水加载中..." : `暂无${activeInventoryLabel}出入库流水` }} +
+
+ + +
+ +``` + +- [ ] **Step 3: Add reactive state** + +In ` +``` + +- [ ] **Step 4: Add component CSS** + +Append to `frontend/src/styles/main.css`: + +```css +.drawer-data-table-wrap { + width: 100%; + max-width: 100%; + overflow: hidden; +} + +.drawer-data-table { + width: 100%; + min-width: 0; + table-layout: fixed; +} + +.drawer-data-table th, +.drawer-data-table td { + min-width: 0; +} + +.drawer-table-head-label { + display: inline-block; + max-width: 100%; + line-height: 1.35; + white-space: normal; + word-break: keep-all; +} + +.drawer-cell-actions { + text-align: center; +} + +.mini-icon-button { + border: 1px solid #d9e6f5; + border-radius: 999px; + background: #f5f9ff; + color: #2f74c8; + font-size: 12px; + padding: 4px 9px; + cursor: pointer; +} + +.drawer-row-detail td { + background: #f8fbff; +} + +.drawer-row-detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; +} + +.drawer-row-detail-grid article { + display: grid; + gap: 4px; + padding: 10px 12px; + border: 1px solid #e4edf7; + border-radius: 12px; + background: #ffffff; +} + +.drawer-row-detail-grid span { + color: #7a8699; + font-size: 12px; +} + +.drawer-row-detail-grid strong { + color: #1f2d3d; + font-size: 13px; + overflow-wrap: anywhere; +} +``` + +- [ ] **Step 5: Run static component test** + +Run: + +```bash +cd /Volumes/ForgeFlow-ERP/frontend +node scripts/test-drawer-data-table.mjs +``` + +Expected: PASS with `drawer data table static tests passed`. + +--- + +### Task 4: Migrate Warehouse Drawer Tables + +**Files:** +- Modify: `frontend/src/views/InventoryLedgerView.vue` + +- [ ] **Step 1: Import `DrawerDataTable` and add expansion state** + +Add import: + +```js +import DrawerDataTable from "../components/DrawerDataTable.vue"; +``` + +Add refs near the other table page refs: + +```js +const expandedLotRows = ref([]); +const expandedTxnRows = ref([]); +``` + +- [ ] **Step 2: Add column definitions** + +Add computed columns near the table-related computed values: + +```js +const lotDrawerColumns = computed(() => [ + { key: "lot_no", label: "库存批次号", compactLabel: "批次号", type: "code", width: "220px" }, + ...(isSelectedFinishedWarehouse.value ? [{ key: "inbound_qty", label: "入库数量", compactLabel: "入库数", type: "number", width: "96px", format: (_value, row) => formatQty(row.inbound_qty) }] : []), + ...(selectedWeightOnly.value ? [] : [{ key: "remaining_qty", label: isSelectedFinishedWarehouse.value ? "剩余数量" : "变动数量", compactLabel: isSelectedFinishedWarehouse.value ? "剩余数" : "变动数", type: "number", width: "96px", format: (_value, row) => selectedWarehouseType.value === "SEMI" ? formatSemiMeasureValue(row, row.remaining_qty, "PIECE") : formatQty(row.remaining_qty) }]), + { key: "inbound_weight_kg", label: "入库重量(kg)", compactLabel: "入库重量", type: "number", width: "110px", format: (_value, row) => selectedWarehouseType.value === "SEMI" ? formatSemiMeasureValue(row, row.inbound_weight_kg, "WEIGHT") : formatWeight(row.inbound_weight_kg) }, + { key: "remaining_weight_kg", label: "剩余重量(kg)", compactLabel: "剩余重量", type: "number", width: "110px", format: (_value, row) => selectedWarehouseType.value === "SEMI" ? formatSemiMeasureValue(row, row.remaining_weight_kg, "WEIGHT") : formatWeight(row.remaining_weight_kg) }, + { key: "unit_cost", label: selectedUnitCostColumnLabel.value, compactLabel: isSelectedFinishedWarehouse.value ? "材料成本" : "单价", type: "number", width: "116px", format: (_value, row) => formatSelectedUnitCost(row.unit_cost) }, + { key: "status", label: "状态", type: "status", width: "90px" }, + { key: "warehouse_name", label: "仓库", detailOnly: true }, + { key: "source_purchase_order_no", label: "采购单号", detailOnly: true }, + { key: "source_receipt_no", label: "入库单号", detailOnly: true }, + { key: "source_material_summary", label: "来源库存批次", detailOnly: true }, + { key: "logistics_waybill_no", label: "运单号", detailOnly: true }, + { key: "logistics_freight_amount", label: "运费", detailOnly: true, format: (_value, row) => formatFreight(row.logistics_freight_amount) }, + { key: "quality_release", label: "质检放行人", detailOnly: true, format: (_value, row) => row.item_type === "RAW_MATERIAL" ? formatLotQualityRelease(row) : "-" } +]); + +const txnDrawerColumns = computed(() => [ + { key: "biz_time", label: "时间", type: "longText", width: "128px", format: (_value, row) => formatDateTime(row.biz_time) }, + { key: "txn_type", label: "类型", width: "94px", format: (_value, row) => formatInventoryTxnTypeLabel(row.txn_type) }, + { key: "lot_no", label: "库存批次号", compactLabel: "批次号", type: "code", width: "220px" }, + ...(selectedWeightOnly.value ? [] : [{ key: "qty_change", label: "变动数量", compactLabel: "变动数", type: "number", width: "96px", format: (_value, row) => formatQty(row.qty_change) }]), + { key: "weight_change_kg", label: "变动重量(kg)", compactLabel: "变动重量", type: "number", width: "110px", format: (_value, row) => formatWeight(row.weight_change_kg) }, + { key: "unit_cost", label: isSelectedFinishedWarehouse.value ? "材料成本" : "单价", type: "number", width: "116px", format: (_value, row) => formatSelectedUnitCost(row.unit_cost) }, + { key: "amount", label: "金额", type: "number", width: "104px", format: (_value, row) => `¥${formatAmount(row.amount)}` }, + { key: "source", label: "来源", detailOnly: true, format: (_value, row) => `${formatSourceDocTypeLabel(row.source_doc_type)} #${row.source_doc_id || "-"}` }, + { key: "remark", label: "备注", detailOnly: true } +]); +``` + +- [ ] **Step 3: Replace the lot detail table** + +Replace the current `库存批次明细` table markup with: + +```vue + + + + + +``` + +- [ ] **Step 4: Replace the transaction table** + +Replace the current `库存流水` table markup with: + +```vue + + + + +``` + +- [ ] **Step 5: Use a larger drawer size for complex inventory detail** + +Find the warehouse detail drawer container in `InventoryLedgerView.vue`. If it uses a custom dual drawer mask, add a class that enables fullscreen width: + +```vue +
+``` + +Add CSS: + +```css +.inventory-ledger-detail-mask .dual-drawer-layout { + width: min(1760px, calc(100vw - 28px)); + max-width: calc(100vw - 28px); +} + +.inventory-ledger-detail-mask .workflow-drawer { + min-width: 0; +} +``` + +- [ ] **Step 6: Run focused frontend tests** + +Run: + +```bash +cd /Volumes/ForgeFlow-ERP/frontend +node scripts/test-drawer-data-table.mjs +node scripts/test-inventory-ledger-options.mjs +npm run build +``` + +Expected: +- Static drawer table test passes. +- Existing inventory option test passes. +- Vite build passes. + +--- + +### Task 5: Apply the Same Pattern to Other Drawers + +**Files:** +- Modify high-impact drawer pages after warehouse migration: + - `frontend/src/views/PurchaseOrderView.vue` + - `frontend/src/views/DeliveryManagementView.vue` + - `frontend/src/views/WorkOrderManagementView.vue` + - `frontend/src/views/CompletionReceiptView.vue` + - `frontend/src/components/ProductWorkflowDrawer.vue` + +- [ ] **Step 1: Identify drawer tables that exceed 7 columns** + +Run: + +```bash +cd /Volumes/ForgeFlow-ERP +rg -n " 成品库 -> 产品明细抽屉 +2. 库存批次明细表 +3. 库存流水表 +4. 采购订单详情抽屉 +5. 发货/销售出库详情抽屉 +``` + +Expected: +- No vertical table headers. +- No ugly horizontal scrollbar for normal data. +- Long batch numbers do not deform the table. +- Row expand details reveal hidden fields clearly. +- Pagination bar remains visually consistent. + +--- + +## Self-Review + +- Spec coverage: The plan covers drawer width, table header readability, horizontal-scroll reduction, long-code handling, detail expansion, and migration of warehouse plus other high-impact drawers. +- Placeholder scan: No `TBD`, `TODO`, or unspecified “handle later” steps remain. +- Type consistency: `DrawerDataTable` props are consistently named `rows`, `columns`, `rowKey`, `expandedRows`, and `emptyText`; slot names use `cell-${column.key}` and `detail`. +- Risk note: The current project directory is mounted at `/Volumes/ForgeFlow-ERP` and is not detected as a git repository, so commit steps are intentionally omitted. diff --git a/docs/superpowers/plans/2026-06-02-production-work-order-ledger.md b/docs/superpowers/plans/2026-06-02-production-work-order-ledger.md new file mode 100644 index 0000000..dc4acbf --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-production-work-order-ledger.md @@ -0,0 +1,1486 @@ +# Production Work Order Ledger Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a production work-order ledger under production execution and make finished inbound, production scrap inbound, and production surplus inbound use production work orders as their controlling business object. + +**Architecture:** Reuse the existing `pp_work_order` production work-order tables created by raw-material production outbound. Add a focused backend service that aggregates work-order facts and calculates inbound limits with a 15% tolerance, then connect the warehouse inbound APIs and ERP warehouse drawers to those rules. The ledger page reads derived data from existing fact tables instead of storing a separate disconnected ledger table. + +**Tech Stack:** FastAPI, SQLAlchemy ORM, Pydantic schemas, existing warehouse and production route modules, Vue 3 SFCs, existing `FormDrawer`, `DrawerDataTable`, `StockLotTagSelect`, `TableControls`, and Vite build/static tests. + +--- + +## File Structure + +- Create: `backend/app/services/production_work_order_ledger.py` + - Central production ledger aggregation and limit calculations. + - Exposes `get_work_order_ledger_rows`, `get_work_order_limit_snapshot`, and `validate_inbound_with_tolerance`. + +- Modify: `backend/app/schemas/operations.py` + - Add `WorkOrderLedgerMaterialRead`, `WorkOrderLedgerOperationRead`, `WorkOrderLedgerLimitRead`, and `WorkOrderLedgerRead`. + - Add the derived fields needed by the warehouse drawers and the ledger page. + +- Modify: `backend/app/api/routes/production.py` + - Add `GET /production/work-order-ledger`. + - Use the new limit service in `POST /production/completion-receipts`. + - Apply the 15% tolerance rule to finished production inbound. + +- Modify: `backend/app/api/routes/inventory.py` + - Make `PRODUCTION_SURPLUS` require `source_doc_type="WORK_ORDER"` and `source_doc_id=`. + - Make `PRODUCTION_SCRAP_IN` require `source_doc_type="WORK_ORDER"` and `source_doc_id=`. + - Validate surplus and scrap inbound against the work-order limit service. + - For production surplus inbound, keep returning material to the selected original source stock lot and update `pp_work_order_material.returned_weight_kg`. + - For production scrap inbound, create a scrap lot in scrap warehouse and link its stock lot and inventory transaction to the work order. + +- Modify: `frontend/src/router/index.js` + - Import `WorkOrderManagementView`. + - Add route `/work-order-ledger` with permission `MENU_WORK_ORDER`. + - Add `MENU_WORK_ORDER` to production redirect and permission ordering. + +- Modify: `frontend/src/App.vue` + - Add the production child menu `工单台账`. + - Add `MENU_WORK_ORDER` to the production stage visibility. + +- Modify: `frontend/src/views/WorkOrderManagementView.vue` + - Convert the old production-out list into the new `工单台账` page. + - Remove the production-out create drawer from this page; production outbound remains in raw-material warehouse. + - Show ledger fields, operation summaries, issued lots, inbound limits, and linked inbound totals. + +- Modify: `frontend/src/views/InventoryLedgerView.vue` + - Finished warehouse `生产入库`: first select production work order, display final-operation good quantity, already received quantity, calculated max, and 15% tolerance max. + - Scrap warehouse `生产废料入库`: first select production work order, display scrap formula inputs and calculated max/tolerance max. + - Raw-material warehouse `生产余料入库`: first select production work order, restrict source stock lots to the work order issued lots, display used issued weight, returned weight, max surplus, and tolerance max. + - Send `source_doc_type: "WORK_ORDER"` and `source_doc_id: work_order_id` for production surplus and production scrap inbound. + +- Test: `backend/tests/test_production_work_order_ledger.py` + - Backend service and API tests for ledger aggregation, finished inbound tolerance, scrap inbound tolerance, and surplus return linkage. + +- Test: `frontend/scripts/test-production-work-order-ledger.mjs` + - Static frontend test for route/menu wiring and warehouse drawer payloads. + +--- + +### Task 1: Add Backend Ledger Tests First + +**Files:** +- Create: `backend/tests/test_production_work_order_ledger.py` + +- [ ] **Step 1: Create the test file with fixtures and failing tests** + +Create `backend/tests/test_production_work_order_ledger.py` with this structure: + +```python +from __future__ import annotations + +import unittest +from datetime import datetime +from decimal import Decimal +from types import SimpleNamespace + +from fastapi import HTTPException +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Bom, BomItem, Item, StockBalance, Warehouse # noqa: E402 +from app.models.operations import ( # noqa: E402 + CompletionReceipt, + CompletionReceiptItem, + InventoryTxn, + Process, + ProcessRoute, + ProcessRouteOperation, + StockLot, + WorkCenter, + WorkOrder, + WorkOrderMaterial, + WorkOrderMaterialIssue, + WorkOrderOperation, +) +from app.models.org import Department # noqa: E402 +from app.api.routes.inventory import create_warehouse_inbound # noqa: E402 +from app.api.routes.production import create_completion_receipt # noqa: E402 +from app.schemas.operations import ( # noqa: E402 + CompletionReceiptCreate, + CompletionReceiptItemCreate, + WarehouseInboundCreate, +) +from app.services.production_work_order_ledger import get_work_order_limit_snapshot, get_work_order_ledger_rows # noqa: E402 + + +class Context: + user = SimpleNamespace(id=1) + + +class ProductionWorkOrderLedgerTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + raw_connection = engine.raw_connection() + raw_connection.create_function("greatest", -1, lambda *values: max(value for value in values if value is not None)) + raw_connection.close() + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + + self.raw_item = Item(id=1, item_code="RM-001", item_name="冷轧钢板", item_type="RAW_MATERIAL", unit_weight_kg=Decimal("1"), status="ACTIVE") + self.product = Item(id=2, item_code="FG-001", item_name="钢制碗", item_type="FINISHED_GOOD", unit_weight_kg=Decimal("0.50"), status="ACTIVE") + self.raw_wh = Warehouse(id=1, warehouse_code="WH-RAW", warehouse_name="原材料库", warehouse_type="RAW", status="ACTIVE") + self.finished_wh = Warehouse(id=2, warehouse_code="WH-FG", warehouse_name="成品库", warehouse_type="FINISHED", status="ACTIVE") + self.scrap_wh = Warehouse(id=3, warehouse_code="WH-SCRAP", warehouse_name="废料库", warehouse_type="SCRAP", status="ACTIVE") + self.db.add_all([self.raw_item, self.product, self.raw_wh, self.finished_wh, self.scrap_wh]) + self.db.flush() + + self.bom = Bom(id=1, bom_code="BOM-001", product_item_id=2, version_no="V1.0", is_default=1, status="ACTIVE") + self.bom_item = BomItem( + id=1, + bom_id=1, + seq_no=1, + material_item_id=1, + usage_type="WEIGHT", + net_weight_per_piece_kg=Decimal("0.50"), + gross_weight_per_piece_kg=Decimal("0.60"), + loss_rate=Decimal("0.1667"), + status="ACTIVE", + ) + self.department = Department(id=1, dept_code="PROD", dept_name="生产部", dept_type="PRODUCTION", status="ACTIVE") + self.process1 = Process(id=1, process_code="P1", process_name="开平", process_type="INTERNAL", status="ACTIVE") + self.process2 = Process(id=2, process_code="P2", process_name="冲压", process_type="INTERNAL", status="ACTIVE") + self.work_center = WorkCenter(id=1, center_code="WC-001", center_name="车间", dept_id=1, center_type="INTERNAL", capacity_hours_per_day=Decimal("8"), status="ACTIVE") + self.route = ProcessRoute(id=1, route_code="RT-001", route_name="钢制碗工艺", product_item_id=2, version_no="V1.0", is_default=1, status="ACTIVE") + self.route_op1 = ProcessRouteOperation(id=1, route_id=1, seq_no=1, process_id=1, operation_name="1序", work_center_id=1, std_cycle_seconds=Decimal("10"), status="ACTIVE") + self.route_op2 = ProcessRouteOperation(id=2, route_id=1, seq_no=2, process_id=2, operation_name="2序", work_center_id=1, std_cycle_seconds=Decimal("10"), status="ACTIVE") + self.db.add_all([self.bom, self.bom_item, self.department, self.process1, self.process2, self.work_center, self.route, self.route_op1, self.route_op2]) + + self.source_lot = StockLot( + id=1, + lot_no="JH_冷轧钢板00001_0001", + lot_role="RAW_MATERIAL", + item_id=1, + warehouse_id=1, + source_doc_type="PURCHASE_RECEIPT", + source_doc_id=10, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("500"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("300"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("4"), + quality_status="PASS", + status="AVAILABLE", + ) + self.balance = StockBalance( + id=1, + item_id=1, + warehouse_id=1, + qty_on_hand=Decimal("0"), + weight_on_hand_kg=Decimal("300"), + qty_available=Decimal("0"), + weight_available_kg=Decimal("300"), + qty_allocated=Decimal("0"), + weight_allocated_kg=Decimal("0"), + avg_unit_cost=Decimal("4"), + ) + self.work_order = WorkOrder( + id=1, + work_order_no="WO-001", + work_order_type="NORMAL", + product_item_id=2, + route_id=1, + bom_id=1, + planned_qty=Decimal("333"), + released_qty=Decimal("333"), + finished_qty=Decimal("0"), + scrap_qty=Decimal("0"), + status="RELEASED", + ) + self.material_row = WorkOrderMaterial( + id=1, + work_order_id=1, + bom_item_id=1, + material_item_id=1, + required_qty=Decimal("0"), + required_weight_kg=Decimal("200"), + issued_qty=Decimal("0"), + issued_weight_kg=Decimal("200"), + returned_qty=Decimal("0"), + returned_weight_kg=Decimal("0"), + status="ISSUED", + ) + self.issue = WorkOrderMaterialIssue( + id=1, + work_order_material_id=1, + work_order_id=1, + material_item_id=1, + source_lot_id=1, + material_sub_batch_no="JH_冷轧钢板00001_0001", + issued_qty=Decimal("0"), + issued_weight_kg=Decimal("200"), + unit_cost=Decimal("4"), + amount=Decimal("800"), + issue_time=datetime.now(), + ) + self.op1 = WorkOrderOperation(id=1, work_order_id=1, route_operation_id=1, seq_no=1, operation_name="1序", work_center_id=1, planned_qty=Decimal("333"), report_qty=Decimal("210"), good_qty=Decimal("206"), scrap_qty=Decimal("4"), status="RUNNING") + self.op2 = WorkOrderOperation(id=2, work_order_id=1, route_operation_id=2, seq_no=2, operation_name="2序", work_center_id=1, planned_qty=Decimal("333"), report_qty=Decimal("200"), good_qty=Decimal("198"), scrap_qty=Decimal("2"), status="RUNNING") + self.db.add_all([self.source_lot, self.balance, self.work_order, self.material_row, self.issue, self.op1, self.op2]) + self.db.commit() + + def tearDown(self) -> None: + self.db.close() + + def test_limit_snapshot_uses_max_report_qty_for_used_weight_and_sum_scrap_qty_for_scrap_weight(self) -> None: + snapshot = get_work_order_limit_snapshot(self.db, 1) + + self.assertEqual(float(snapshot.issued_weight_kg), 200) + self.assertEqual(float(snapshot.used_material_weight_kg), 126) + self.assertEqual(float(snapshot.returned_material_weight_kg), 0) + self.assertEqual(float(snapshot.max_surplus_inbound_weight_kg), 74) + self.assertEqual(float(snapshot.final_operation_good_qty), 198) + self.assertEqual(float(snapshot.finished_received_qty), 0) + self.assertEqual(float(snapshot.max_finished_inbound_qty), 198) + self.assertEqual(float(snapshot.total_operation_scrap_qty), 6) + self.assertEqual(float(snapshot.max_scrap_inbound_weight_kg), 36.3) + self.assertEqual(float(snapshot.tolerance_finished_inbound_qty), 227.7) + self.assertEqual(float(snapshot.tolerance_scrap_inbound_weight_kg), 41.745) + self.assertEqual(float(snapshot.tolerance_surplus_inbound_weight_kg), 85.1) + + def test_ledger_rows_include_material_operation_and_limit_details(self) -> None: + rows = get_work_order_ledger_rows(self.db, limit=20) + + self.assertEqual(len(rows), 1) + row = rows[0] + self.assertEqual(row.work_order_no, "WO-001") + self.assertEqual(row.product_name, "钢制碗") + self.assertEqual(row.material_summary, "冷轧钢板") + self.assertEqual(row.source_lot_summary, "JH_冷轧钢板00001_0001") + self.assertEqual(float(row.planned_qty), 333) + self.assertEqual(float(row.issued_weight_kg), 200) + self.assertEqual(float(row.used_material_weight_kg), 126) + self.assertEqual(float(row.returned_material_weight_kg), 0) + self.assertEqual(float(row.finished_received_qty), 0) + self.assertEqual(len(row.operations), 2) + self.assertEqual(row.operations[0].operation_name, "1序") + self.assertEqual(float(row.operations[0].good_qty), 206) + self.assertEqual(float(row.operations[0].scrap_qty), 4) + self.assertEqual(float(row.limits.max_scrap_inbound_weight_kg), 36.3) + + def test_finished_inbound_accepts_15_percent_tolerance_and_rejects_above_tolerance(self) -> None: + with self.assertRaises(HTTPException) as cm: + create_completion_receipt( + CompletionReceiptCreate( + work_order_id=1, + warehouse_id=2, + receiver_employee_id=None, + items=[CompletionReceiptItemCreate(product_item_id=2, receipt_qty=227.701, receipt_weight_kg=0)], + ), + context=Context(), + db=self.db, + ) + self.assertIn("超过允许入库上限", cm.exception.detail) + + accepted = create_completion_receipt( + CompletionReceiptCreate( + work_order_id=1, + warehouse_id=2, + receiver_employee_id=None, + items=[CompletionReceiptItemCreate(product_item_id=2, receipt_qty=227.7, receipt_weight_kg=0)], + ), + context=Context(), + db=self.db, + ) + + self.assertEqual(float(accepted.total_receipt_qty), 227.7) + + def test_production_scrap_inbound_requires_work_order_and_respects_tolerance(self) -> None: + row = create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SCRAP_IN", + item_id=1, + warehouse_id=3, + inbound_weight_kg=41.745, + source_doc_type="WORK_ORDER", + source_doc_id=1, + source_material_sub_batch_no="JH_冷轧钢板00001_0001", + source_material_summary="WO-001生产废料", + remark="生产废料入库", + ), + context=Context(), + db=self.db, + ) + + lot = self.db.get(StockLot, row.lot_id) + txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == row.lot_id)) + self.assertEqual(lot.source_doc_type, "WORK_ORDER") + self.assertEqual(lot.source_doc_id, 1) + self.assertEqual(txn.source_doc_type, "WORK_ORDER") + self.assertEqual(txn.source_doc_id, 1) + + with self.assertRaises(HTTPException) as cm: + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SCRAP_IN", + item_id=1, + warehouse_id=3, + inbound_weight_kg=0.001, + source_doc_type="WORK_ORDER", + source_doc_id=1, + remark="再次超额入库", + ), + context=Context(), + db=self.db, + ) + self.assertIn("超过允许入库上限", cm.exception.detail) + + def test_production_surplus_returns_to_original_lot_and_updates_work_order_returned_weight(self) -> None: + row = create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="PRODUCTION_SURPLUS", + item_id=1, + warehouse_id=1, + source_lot_id=1, + inbound_weight_kg=85.1, + source_doc_type="WORK_ORDER", + source_doc_id=1, + remark="生产余料归还", + ), + context=Context(), + db=self.db, + ) + + self.db.refresh(self.source_lot) + self.db.refresh(self.material_row) + txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "PRODUCTION_SURPLUS_IN")) + self.assertEqual(row.lot_id, 1) + self.assertEqual(float(self.source_lot.remaining_weight_kg), 385.1) + self.assertEqual(float(self.material_row.returned_weight_kg), 85.1) + self.assertEqual(txn.source_doc_type, "WORK_ORDER") + self.assertEqual(txn.source_doc_id, 1) + self.assertEqual(txn.source_line_id, 1) +``` + +- [ ] **Step 2: Run the new tests and verify they fail before implementation** + +Run: + +```bash +cd /Volumes/ForgeFlow-ERP/backend +PYTHONPATH=. .venv/bin/pytest tests/test_production_work_order_ledger.py -v +``` + +Expected: FAIL because `app.services.production_work_order_ledger` does not exist. + +--- + +### Task 2: Add Backend Ledger Schemas and Service + +**Files:** +- Modify: `backend/app/schemas/operations.py` +- Create: `backend/app/services/production_work_order_ledger.py` + +- [ ] **Step 1: Add ledger response schemas** + +Add these schemas after `WorkOrderOperationRead` in `backend/app/schemas/operations.py`: + +```python +class WorkOrderLedgerMaterialRead(BaseModel): + material_item_id: int + material_code: str + material_name: str + source_lot_id: int + source_lot_no: str + issued_weight_kg: float + returned_weight_kg: float = 0 + unit_cost: float = 0 + amount: float = 0 + + +class WorkOrderLedgerOperationRead(BaseModel): + work_order_operation_id: int + seq_no: int + operation_name: str + report_qty: float + good_qty: float + scrap_qty: float + status: str + + +class WorkOrderLedgerLimitRead(BaseModel): + gross_weight_per_piece_kg: float + net_weight_per_piece_kg: float + issued_weight_kg: float + used_material_weight_kg: float + returned_material_weight_kg: float + final_operation_good_qty: float + finished_received_qty: float + total_operation_scrap_qty: float + scrap_inbound_weight_kg: float + max_finished_inbound_qty: float + max_scrap_inbound_weight_kg: float + max_surplus_inbound_weight_kg: float + tolerance_finished_inbound_qty: float + tolerance_scrap_inbound_weight_kg: float + tolerance_surplus_inbound_weight_kg: float + + +class WorkOrderLedgerRead(BaseModel): + work_order_id: int + work_order_no: str + product_item_id: int + product_code: str + product_name: str + planned_qty: float + status: str + material_summary: str + source_lot_summary: str + issued_weight_kg: float + used_material_weight_kg: float + returned_material_weight_kg: float + finished_received_qty: float + max_finished_inbound_qty: float + max_scrap_inbound_weight_kg: float + max_surplus_inbound_weight_kg: float + limits: WorkOrderLedgerLimitRead + materials: list[WorkOrderLedgerMaterialRead] + operations: list[WorkOrderLedgerOperationRead] +``` + +- [ ] **Step 2: Create the service with exact calculations** + +Create `backend/app/services/production_work_order_ledger.py`: + +```python +from __future__ import annotations + +from decimal import Decimal + +from fastapi import HTTPException +from pydantic import BaseModel +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models.master_data import BomItem, Item +from app.models.operations import ( + CompletionReceipt, + CompletionReceiptItem, + InventoryTxn, + StockLot, + WorkOrder, + WorkOrderMaterial, + WorkOrderMaterialIssue, + WorkOrderOperation, +) +from app.services.operations import to_decimal + + +INBOUND_TOLERANCE_MULTIPLIER = Decimal("1.15") + + +class WorkOrderLimitSnapshot(BaseModel): + work_order_id: int + gross_weight_per_piece_kg: Decimal + net_weight_per_piece_kg: Decimal + issued_weight_kg: Decimal + used_material_weight_kg: Decimal + returned_material_weight_kg: Decimal + final_operation_good_qty: Decimal + finished_received_qty: Decimal + total_operation_scrap_qty: Decimal + scrap_inbound_weight_kg: Decimal + max_finished_inbound_qty: Decimal + max_scrap_inbound_weight_kg: Decimal + max_surplus_inbound_weight_kg: Decimal + tolerance_finished_inbound_qty: Decimal + tolerance_scrap_inbound_weight_kg: Decimal + tolerance_surplus_inbound_weight_kg: Decimal + + +class WorkOrderLedgerMaterialRow(BaseModel): + material_item_id: int + material_code: str + material_name: str + source_lot_id: int + source_lot_no: str + issued_weight_kg: Decimal + returned_weight_kg: Decimal = Decimal("0") + unit_cost: Decimal = Decimal("0") + amount: Decimal = Decimal("0") + + +class WorkOrderLedgerOperationRow(BaseModel): + work_order_operation_id: int + seq_no: int + operation_name: str + report_qty: Decimal + good_qty: Decimal + scrap_qty: Decimal + status: str + + +class WorkOrderLedgerRow(BaseModel): + work_order_id: int + work_order_no: str + product_item_id: int + product_code: str + product_name: str + planned_qty: Decimal + status: str + material_summary: str + source_lot_summary: str + issued_weight_kg: Decimal + used_material_weight_kg: Decimal + returned_material_weight_kg: Decimal + finished_received_qty: Decimal + max_finished_inbound_qty: Decimal + max_scrap_inbound_weight_kg: Decimal + max_surplus_inbound_weight_kg: Decimal + limits: WorkOrderLimitSnapshot + materials: list[WorkOrderLedgerMaterialRow] + operations: list[WorkOrderLedgerOperationRow] + + +def _non_negative(value: Decimal) -> Decimal: + return max(value, Decimal("0")) + + +def _round_decimal(value: Decimal, places: str = "0.001") -> Decimal: + return to_decimal(value, places) + + +def _get_work_order(db: Session, work_order_id: int) -> WorkOrder: + work_order = db.get(WorkOrder, work_order_id) + if not work_order: + raise HTTPException(status_code=404, detail="生产工单不存在") + return work_order + + +def _get_weight_profile(db: Session, work_order: WorkOrder) -> tuple[Decimal, Decimal]: + bom_item = db.scalar( + select(BomItem) + .where(BomItem.bom_id == work_order.bom_id) + .order_by(BomItem.seq_no) + .limit(1) + ) + product = db.get(Item, work_order.product_item_id) + gross = to_decimal(bom_item.gross_weight_per_piece_kg if bom_item else 0) + net = to_decimal(bom_item.net_weight_per_piece_kg if bom_item else 0) + if net <= 0 and product: + net = to_decimal(product.unit_weight_kg) + if gross <= 0: + gross = net + if gross <= 0: + raise HTTPException(status_code=400, detail="产品需规清单缺少产品毛重,无法计算工单入库上限") + if net < 0: + net = Decimal("0") + if gross < net: + gross = net + return gross, net + + +def _get_operations(db: Session, work_order_id: int) -> list[WorkOrderOperation]: + return db.scalars( + select(WorkOrderOperation) + .where(WorkOrderOperation.work_order_id == work_order_id) + .order_by(WorkOrderOperation.seq_no, WorkOrderOperation.id) + ).all() + + +def _get_finished_received_qty(db: Session, work_order_id: int) -> Decimal: + value = db.scalar( + select(func.coalesce(func.sum(CompletionReceiptItem.receipt_qty), 0)) + .join(CompletionReceipt, CompletionReceipt.id == CompletionReceiptItem.completion_receipt_id) + .where(CompletionReceipt.work_order_id == work_order_id) + ) + return to_decimal(value) + + +def _get_scrap_inbound_weight(db: Session, work_order_id: int) -> Decimal: + value = db.scalar( + select(func.coalesce(func.sum(InventoryTxn.weight_change_kg), 0)) + .where( + InventoryTxn.txn_type == "PRODUCTION_SCRAP_IN", + InventoryTxn.source_doc_type == "WORK_ORDER", + InventoryTxn.source_doc_id == work_order_id, + ) + ) + return _non_negative(to_decimal(value)) + + +def get_work_order_limit_snapshot(db: Session, work_order_id: int) -> WorkOrderLimitSnapshot: + work_order = _get_work_order(db, work_order_id) + gross_weight, net_weight = _get_weight_profile(db, work_order) + operations = _get_operations(db, work_order_id) + issued_weight = to_decimal( + db.scalar( + select(func.coalesce(func.sum(WorkOrderMaterialIssue.issued_weight_kg), 0)) + .where(WorkOrderMaterialIssue.work_order_id == work_order_id) + ) + ) + returned_weight = to_decimal( + db.scalar( + select(func.coalesce(func.sum(WorkOrderMaterial.returned_weight_kg), 0)) + .where(WorkOrderMaterial.work_order_id == work_order_id) + ) + ) + max_report_qty = max((to_decimal(operation.report_qty) for operation in operations), default=Decimal("0")) + used_material_weight = _round_decimal(max_report_qty * gross_weight) + final_good_qty = to_decimal(operations[-1].good_qty) if operations else Decimal("0") + finished_received_qty = _get_finished_received_qty(db, work_order_id) + total_scrap_qty = sum((to_decimal(operation.scrap_qty) for operation in operations), Decimal("0")) + scrap_inbound_weight = _get_scrap_inbound_weight(db, work_order_id) + edge_scrap_weight = _non_negative((to_decimal(work_order.planned_qty) - total_scrap_qty) * _non_negative(gross_weight - net_weight)) + rejected_product_scrap_weight = total_scrap_qty * gross_weight + max_finished_inbound_qty = _non_negative(final_good_qty - finished_received_qty) + max_scrap_inbound_weight = _non_negative(edge_scrap_weight + rejected_product_scrap_weight - scrap_inbound_weight) + max_surplus_inbound_weight = _non_negative(issued_weight - used_material_weight - returned_weight) + return WorkOrderLimitSnapshot( + work_order_id=work_order_id, + gross_weight_per_piece_kg=_round_decimal(gross_weight), + net_weight_per_piece_kg=_round_decimal(net_weight), + issued_weight_kg=_round_decimal(issued_weight), + used_material_weight_kg=_round_decimal(used_material_weight), + returned_material_weight_kg=_round_decimal(returned_weight), + final_operation_good_qty=_round_decimal(final_good_qty), + finished_received_qty=_round_decimal(finished_received_qty), + total_operation_scrap_qty=_round_decimal(total_scrap_qty), + scrap_inbound_weight_kg=_round_decimal(scrap_inbound_weight), + max_finished_inbound_qty=_round_decimal(max_finished_inbound_qty), + max_scrap_inbound_weight_kg=_round_decimal(max_scrap_inbound_weight), + max_surplus_inbound_weight_kg=_round_decimal(max_surplus_inbound_weight), + tolerance_finished_inbound_qty=_round_decimal(max_finished_inbound_qty * INBOUND_TOLERANCE_MULTIPLIER), + tolerance_scrap_inbound_weight_kg=_round_decimal(max_scrap_inbound_weight * INBOUND_TOLERANCE_MULTIPLIER), + tolerance_surplus_inbound_weight_kg=_round_decimal(max_surplus_inbound_weight * INBOUND_TOLERANCE_MULTIPLIER), + ) + + +def validate_inbound_with_tolerance(label: str, requested: Decimal, calculated_max: Decimal) -> None: + if requested <= 0: + raise HTTPException(status_code=400, detail=f"{label}必须大于0") + allowed = _round_decimal(_non_negative(calculated_max) * INBOUND_TOLERANCE_MULTIPLIER) + if requested > allowed: + raise HTTPException(status_code=400, detail=f"{label}超过允许入库上限 {allowed}") + + +def validate_work_order_source_lot(db: Session, work_order_id: int, source_lot_id: int, item_id: int) -> WorkOrderMaterial: + row = db.execute( + select(WorkOrderMaterial) + .join(WorkOrderMaterialIssue, WorkOrderMaterialIssue.work_order_material_id == WorkOrderMaterial.id) + .where( + WorkOrderMaterial.work_order_id == work_order_id, + WorkOrderMaterial.material_item_id == item_id, + WorkOrderMaterialIssue.source_lot_id == source_lot_id, + ) + .limit(1) + ).scalar_one_or_none() + if not row: + raise HTTPException(status_code=400, detail="来源库存批次号不属于该生产工单") + return row + + +def get_work_order_ledger_rows(db: Session, limit: int = 200, work_order_id: int | None = None) -> list[WorkOrderLedgerRow]: + stmt = select(WorkOrder).order_by(WorkOrder.id.desc()).limit(limit) + if work_order_id: + stmt = stmt.where(WorkOrder.id == work_order_id) + work_orders = db.scalars(stmt).all() + rows: list[WorkOrderLedgerRow] = [] + for work_order in work_orders: + product = db.get(Item, work_order.product_item_id) + limits = get_work_order_limit_snapshot(db, work_order.id) + issue_rows = db.execute( + select( + WorkOrderMaterialIssue.material_item_id, + Item.item_code, + Item.item_name, + WorkOrderMaterialIssue.source_lot_id, + StockLot.lot_no, + WorkOrderMaterialIssue.issued_weight_kg, + WorkOrderMaterialIssue.unit_cost, + WorkOrderMaterialIssue.amount, + ) + .join(Item, Item.id == WorkOrderMaterialIssue.material_item_id) + .join(StockLot, StockLot.id == WorkOrderMaterialIssue.source_lot_id) + .where(WorkOrderMaterialIssue.work_order_id == work_order.id) + .order_by(WorkOrderMaterialIssue.id) + ).mappings().all() + material_rows = [ + WorkOrderLedgerMaterialRow( + material_item_id=int(issue["material_item_id"]), + material_code=issue["item_code"], + material_name=issue["item_name"], + source_lot_id=int(issue["source_lot_id"]), + source_lot_no=issue["lot_no"], + issued_weight_kg=_round_decimal(to_decimal(issue["issued_weight_kg"])), + returned_weight_kg=Decimal("0"), + unit_cost=_round_decimal(to_decimal(issue["unit_cost"]), "0.0001"), + amount=_round_decimal(to_decimal(issue["amount"]), "0.01"), + ) + for issue in issue_rows + ] + operation_rows = [ + WorkOrderLedgerOperationRow( + work_order_operation_id=int(operation.id), + seq_no=int(operation.seq_no), + operation_name=operation.operation_name, + report_qty=_round_decimal(to_decimal(operation.report_qty)), + good_qty=_round_decimal(to_decimal(operation.good_qty)), + scrap_qty=_round_decimal(to_decimal(operation.scrap_qty)), + status=operation.status, + ) + for operation in _get_operations(db, work_order.id) + ] + material_names = sorted({row.material_name for row in material_rows}) + source_lot_nos = [row.source_lot_no for row in material_rows] + rows.append( + WorkOrderLedgerRow( + work_order_id=int(work_order.id), + work_order_no=work_order.work_order_no, + product_item_id=int(work_order.product_item_id), + product_code=product.item_code if product else "", + product_name=product.item_name if product else "", + planned_qty=_round_decimal(to_decimal(work_order.planned_qty)), + status=work_order.status, + material_summary="、".join(material_names) if material_names else "-", + source_lot_summary="、".join(source_lot_nos) if source_lot_nos else "-", + issued_weight_kg=limits.issued_weight_kg, + used_material_weight_kg=limits.used_material_weight_kg, + returned_material_weight_kg=limits.returned_material_weight_kg, + finished_received_qty=limits.finished_received_qty, + max_finished_inbound_qty=limits.max_finished_inbound_qty, + max_scrap_inbound_weight_kg=limits.max_scrap_inbound_weight_kg, + max_surplus_inbound_weight_kg=limits.max_surplus_inbound_weight_kg, + limits=limits, + materials=material_rows, + operations=operation_rows, + ) + ) + return rows +``` + +- [ ] **Step 3: Run backend tests and verify the new service calculations pass except route validations still fail** + +Run: + +```bash +cd /Volumes/ForgeFlow-ERP/backend +PYTHONPATH=. .venv/bin/pytest tests/test_production_work_order_ledger.py::ProductionWorkOrderLedgerTest::test_limit_snapshot_uses_max_report_qty_for_used_weight_and_sum_scrap_qty_for_scrap_weight tests/test_production_work_order_ledger.py::ProductionWorkOrderLedgerTest::test_ledger_rows_include_material_operation_and_limit_details -v +``` + +Expected: PASS for the two service-level tests. + +--- + +### Task 3: Wire Production Ledger API and Finished Inbound Tolerance + +**Files:** +- Modify: `backend/app/api/routes/production.py` + +- [ ] **Step 1: Import the new schema and service functions** + +Add `WorkOrderLedgerRead` to the `app.schemas.operations` import list. + +Add this service import near the existing `app.services.operations` import: + +```python +from app.services.production_work_order_ledger import ( + get_work_order_ledger_rows, + get_work_order_limit_snapshot, + validate_inbound_with_tolerance, +) +``` + +- [ ] **Step 2: Add the ledger endpoint** + +Add this route before `@router.get("/work-orders"`: + +```python +@router.get("/work-order-ledger", response_model=list[WorkOrderLedgerRead]) +def list_work_order_ledger( + work_order_id: int | None = Query(default=None), + limit: int = Query(default=200, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[WorkOrderLedgerRead]: + if work_order_id and sync_work_order_reference_qty(db, work_order_id): + db.commit() + rows = get_work_order_ledger_rows(db, limit=limit, work_order_id=work_order_id) + return [WorkOrderLedgerRead.model_validate(row.model_dump()) for row in rows] +``` + +- [ ] **Step 3: Replace finished inbound max calculation with the shared limit snapshot** + +In `create_completion_receipt`, replace: + +```python + work_order_snapshot = db.execute(get_work_orders_query(limit=1).where(WorkOrder.id == payload.work_order_id)).mappings().first() + if not work_order_snapshot: + raise HTTPException(status_code=404, detail="工单不存在") + max_receivable_qty = to_decimal(work_order_snapshot["max_receivable_qty"]) + if max_receivable_qty <= 0: + raise HTTPException(status_code=400, detail="该工单当前没有可入库的完工数量") +``` + +with: + +```python + limit_snapshot = get_work_order_limit_snapshot(db, payload.work_order_id) + max_receivable_qty = to_decimal(limit_snapshot.max_finished_inbound_qty) + if max_receivable_qty <= 0: + raise HTTPException(status_code=400, detail="该工单当前没有可入库的完工数量") +``` + +Replace the per-line check: + +```python + if receipt_qty > remaining_receivable_qty: + raise HTTPException(status_code=400, detail=f"入库数量不能超过当前最大可入库数量 {remaining_receivable_qty}") +``` + +with: + +```python + validate_inbound_with_tolerance("成品入库数量", receipt_qty, remaining_receivable_qty) +``` + +Keep: + +```python + remaining_receivable_qty -= receipt_qty +``` + +so multiple lines in one receipt still consume the same calculated allowance. + +- [ ] **Step 4: Run focused production tests** + +Run: + +```bash +cd /Volumes/ForgeFlow-ERP/backend +PYTHONPATH=. .venv/bin/pytest tests/test_production_work_order_ledger.py::ProductionWorkOrderLedgerTest::test_finished_inbound_accepts_15_percent_tolerance_and_rejects_above_tolerance tests/test_selected_stock_lot_production_issue.py -v +``` + +Expected: PASS. + +--- + +### Task 4: Enforce Work-Order-Controlled Scrap and Surplus Inbound + +**Files:** +- Modify: `backend/app/api/routes/inventory.py` + +- [ ] **Step 1: Import ledger validation helpers** + +Add this import near the other service imports: + +```python +from app.services.production_work_order_ledger import ( + get_work_order_limit_snapshot, + validate_inbound_with_tolerance, + validate_work_order_source_lot, +) +``` + +- [ ] **Step 2: Add a helper that validates production inbound work-order payloads** + +Add this helper above `create_warehouse_inbound`: + +```python +def _get_required_production_work_order_id(payload: WarehouseInboundCreate, biz_type: str) -> int: + if biz_type not in {"PRODUCTION_SURPLUS", "PRODUCTION_SCRAP_IN"}: + return 0 + if str(payload.source_doc_type or "").upper() != "WORK_ORDER" or not payload.source_doc_id: + raise HTTPException(status_code=400, detail=f"{_inventory_biz_type_label(biz_type)}必须先选择生产工单") + return int(payload.source_doc_id) +``` + +- [ ] **Step 3: Calculate the work-order id before normal inbound validation** + +Inside `create_warehouse_inbound`, after `biz_type` is normalized, add: + +```python + production_work_order_id = _get_required_production_work_order_id(payload, biz_type) +``` + +- [ ] **Step 4: Validate production surplus inbound and update work-order returned weight** + +Inside the `if biz_type in _SOURCE_LOT_RETURN_INBOUND_BIZ_TYPES:` branch, after source lot validation and before increasing `source_lot.remaining_weight_kg`, add: + +```python + work_order_material_row = None + if biz_type == "PRODUCTION_SURPLUS": + limit_snapshot = get_work_order_limit_snapshot(db, production_work_order_id) + validate_inbound_with_tolerance("生产余料入库重量", inbound_weight, to_decimal(limit_snapshot.max_surplus_inbound_weight_kg)) + work_order_material_row = validate_work_order_source_lot( + db, + production_work_order_id, + int(source_lot.id), + int(payload.item_id), + ) +``` + +After `source_lot.updated_at = datetime.now()`, add: + +```python + if work_order_material_row is not None: + work_order_material_row.returned_weight_kg = to_decimal(work_order_material_row.returned_weight_kg) + inbound_weight + work_order_material_row.updated_at = datetime.now() + db.add(work_order_material_row) +``` + +Replace the `create_inventory_txn` source fields in the same branch: + +```python + source_doc_type=source_doc_type, + source_doc_id=source_lot.id, + source_line_id=None, +``` + +with: + +```python + source_doc_type="WORK_ORDER" if biz_type == "PRODUCTION_SURPLUS" else source_doc_type, + source_doc_id=production_work_order_id if biz_type == "PRODUCTION_SURPLUS" else source_lot.id, + source_line_id=source_lot.id if biz_type == "PRODUCTION_SURPLUS" else None, +``` + +- [ ] **Step 5: Validate production scrap inbound and link created lot/transaction to the work order** + +Before the generic lot creation section sets `operation_source_doc_type`, add: + +```python + if biz_type == "PRODUCTION_SCRAP_IN": + limit_snapshot = get_work_order_limit_snapshot(db, production_work_order_id) + validate_inbound_with_tolerance("生产废料入库重量", inbound_weight, to_decimal(limit_snapshot.max_scrap_inbound_weight_kg)) +``` + +After: + +```python + operation_source_doc_type = source_doc_type + operation_source_doc_id = payload.source_doc_id or 0 + operation_source_line_id = payload.source_line_id +``` + +add: + +```python + if biz_type == "PRODUCTION_SCRAP_IN": + operation_source_doc_type = "WORK_ORDER" + operation_source_doc_id = production_work_order_id + operation_source_line_id = None +``` + +- [ ] **Step 6: Run inventory linkage tests** + +Run: + +```bash +cd /Volumes/ForgeFlow-ERP/backend +PYTHONPATH=. .venv/bin/pytest tests/test_production_work_order_ledger.py::ProductionWorkOrderLedgerTest::test_production_scrap_inbound_requires_work_order_and_respects_tolerance tests/test_production_work_order_ledger.py::ProductionWorkOrderLedgerTest::test_production_surplus_returns_to_original_lot_and_updates_work_order_returned_weight tests/test_scrap_warehouse_flow.py::ScrapWarehouseFlowTest::test_production_surplus_returns_weight_to_selected_source_lot tests/test_scrap_warehouse_flow.py::ScrapWarehouseFlowTest::test_production_scrap_inbound_creates_scrap_lot_balance_and_txn -v +``` + +Expected: PASS after updating the two older scrap warehouse tests to pass `source_doc_type="WORK_ORDER"` and `source_doc_id=` only where they exercise production work-order inbound. If older tests intentionally exercise generic historical behavior, update their expected error to confirm production inbound now requires a work order. + +--- + +### Task 5: Add Route and Menu for 工单台账 + +**Files:** +- Modify: `frontend/src/router/index.js` +- Modify: `frontend/src/App.vue` + +- [ ] **Step 1: Add router import** + +In `frontend/src/router/index.js`, add: + +```js +import WorkOrderManagementView from "../views/WorkOrderManagementView.vue"; +``` + +- [ ] **Step 2: Add work-order route to production permission ordering** + +Change `productionPermissionOrder` to: + +```js +const productionPermissionOrder = [ + { + routeName: "work-order-ledger", + permission: "MENU_WORK_ORDER" + }, + { + routeName: "operation-reports", + permission: "MENU_OPERATION_REPORT" + }, + { + routeName: "scrap-rework", + permission: "MENU_SCRAP_REWORK" + } +]; +``` + +Add `MENU_WORK_ORDER` to the `navPermissionOrder` production workspace permission list. + +- [ ] **Step 3: Add route** + +Add this route before `/operation-reports`: + +```js +{ + path: "/work-order-ledger", + name: "work-order-ledger", + component: WorkOrderManagementView, + meta: { title: "工单台账", requiresAuth: true, permission: "MENU_WORK_ORDER" } +}, +``` + +- [ ] **Step 4: Add App production menu child** + +In `frontend/src/App.vue`, add `MENU_WORK_ORDER` to the production stage `permissionsAny`. + +Add this child before `工序报工`: + +```js +{ + key: "work-order-ledger", + label: "工单台账", + to: { name: "work-order-ledger" }, + routeNames: ["work-order-ledger"], + permission: "MENU_WORK_ORDER", + iconPaths: [ + "M5 5H19V19H5V5Z", + "M8 9H16", + "M8 13H16", + "M8 17H13", + "M15 17L17 19L21 14" + ] +}, +``` + +- [ ] **Step 5: Run frontend route sanity build** + +Run: + +```bash +cd /Volumes/ForgeFlow-ERP/frontend +npm run build +``` + +Expected: build succeeds. + +--- + +### Task 6: Convert WorkOrderManagementView into 工单台账 + +**Files:** +- Modify: `frontend/src/views/WorkOrderManagementView.vue` + +- [ ] **Step 1: Replace data loading with the new ledger endpoint** + +Replace the current `loadAll` requests for `/production/work-orders`, `/production/work-order-materials`, and `/production/work-order-operations` with: + +```js +async function loadAll() { + const [ledgerRows] = await Promise.all([ + fetchResource("/production/work-order-ledger?limit=500", []) + ]); + workOrders.value = Array.isArray(ledgerRows) ? ledgerRows : []; + if (!selectedWorkOrderId.value && workOrders.value.length) { + selectedWorkOrderId.value = workOrders.value[0].work_order_id; + } +} +``` + +- [ ] **Step 2: Remove production-out creation from the ledger page** + +Remove the `FormDrawer` titled `新增生产出库`, the `openCreateDrawer` handler, the `submitWorkOrder` handler, and the production-out form state. Keep detail drawer behavior for ledger rows. + +- [ ] **Step 3: Update table columns** + +Change the main table header to: + +```vue + + + + + + + + + + +``` + +Change each row body to: + +```vue + + + + + + + + + + +``` + +- [ ] **Step 4: Add ledger limit cards above the detail tables** + +Inside the detail drawer, add this summary grid before material and operation tables: + +```vue +
+
可入库成品{{ formatQty(activeWorkOrder?.limits?.max_finished_inbound_qty) }}
+
成品容差上限{{ formatQty(activeWorkOrder?.limits?.tolerance_finished_inbound_qty) }}
+
可入库废料{{ formatWeight(activeWorkOrder?.limits?.max_scrap_inbound_weight_kg) }}
+
废料容差上限{{ formatWeight(activeWorkOrder?.limits?.tolerance_scrap_inbound_weight_kg) }}
+
可归还余料{{ formatWeight(activeWorkOrder?.limits?.max_surplus_inbound_weight_kg) }}
+
余料容差上限{{ formatWeight(activeWorkOrder?.limits?.tolerance_surplus_inbound_weight_kg) }}
+
+``` + +- [ ] **Step 5: Use nested arrays from the active ledger row** + +Replace material and operation computed rows with: + +```js +const filteredMaterials = computed(() => activeWorkOrder.value?.materials || []); +const filteredOperations = computed(() => activeWorkOrder.value?.operations || []); +``` + +- [ ] **Step 6: Build check** + +Run: + +```bash +cd /Volumes/ForgeFlow-ERP/frontend +npm run build +``` + +Expected: build succeeds. + +--- + +### Task 7: Update Warehouse Drawers to Select Production Work Orders + +**Files:** +- Modify: `frontend/src/views/InventoryLedgerView.vue` + +- [ ] **Step 1: Load ledger rows alongside existing work-order rows** + +Add state: + +```js +const workOrderLedgerRows = ref([]); +const selectedGenericWorkOrderId = ref(""); +``` + +In `loadAll`, add: + +```js +fetchResource("/production/work-order-ledger?limit=500", []), +``` + +Assign it: + +```js +workOrderLedgerRows.value = Array.isArray(workOrderLedgerResult) ? workOrderLedgerResult : []; +``` + +- [ ] **Step 2: Add computed work-order option lists** + +Add: + +```js +const productionFinishedWorkOrderOptions = computed(() => + workOrderLedgerRows.value.filter((row) => Number(row.max_finished_inbound_qty || 0) > 0) +); + +const productionScrapWorkOrderOptions = computed(() => + workOrderLedgerRows.value.filter((row) => Number(row.max_scrap_inbound_weight_kg || 0) > 0) +); + +const productionSurplusWorkOrderOptions = computed(() => + workOrderLedgerRows.value.filter((row) => Number(row.max_surplus_inbound_weight_kg || 0) > 0) +); + +const selectedGenericWorkOrder = computed(() => + workOrderLedgerRows.value.find((row) => Number(row.work_order_id) === Number(selectedGenericWorkOrderId.value)) || null +); +``` + +- [ ] **Step 3: Rename finished production inbound selector** + +In the finished production inbound drawer, change label text from `库存批次号` to `生产工单`, and change the disabled option text from `请选择库存批次号` to `请选择生产工单`. + +Change option text to: + +```vue +{{ workOrder.work_order_no }} · {{ workOrder.product_name }} · 可入库 {{ formatQty(workOrder.max_receivable_qty) }} +``` + +Then switch the options to the new ledger row list and display tolerance: + +```vue + +``` + +Update `applyCompletionWorkOrder` to read from `workOrderLedgerRows`: + +```js +function applyCompletionWorkOrder() { + const workOrder = workOrderLedgerRows.value.find((item) => item.work_order_id === Number(completionForm.work_order_id)); + if (!workOrder) { + return; + } + const product = products.value.find((item) => item.item_id === Number(workOrder.product_item_id)); + completionForm.product_item_id = workOrder.product_item_id; + completionForm.max_receivable_qty = Number(workOrder.limits?.max_finished_inbound_qty || 0); + completionForm.tolerance_receivable_qty = Number(workOrder.limits?.tolerance_finished_inbound_qty || 0); + completionForm.unit_weight_kg = Number(product?.unit_weight_kg || 0); + completionForm.receipt_qty = Number(workOrder.limits?.max_finished_inbound_qty || 0); + recalculateCompletionWeight(); +} +``` + +Update `recalculateCompletionWeight` to clamp to `completionForm.tolerance_receivable_qty`, not the raw calculated max: + +```js +function recalculateCompletionWeight() { + const toleranceQty = Number(completionForm.tolerance_receivable_qty || completionForm.max_receivable_qty || 0); + if (Number(completionForm.receipt_qty || 0) > toleranceQty) { + completionForm.receipt_qty = toleranceQty; + } + completionForm.receipt_weight_kg = Number((Number(completionForm.receipt_qty || 0) * Number(completionForm.unit_weight_kg || 0)).toFixed(3)); +} +``` + +- [ ] **Step 4: Add production work-order selector to generic drawer for production scrap and surplus** + +Add this block near the top of the generic drawer form, before item selection: + +```vue + +``` + +Add: + +```js +function applyGenericProductionWorkOrder() { + const workOrder = selectedGenericWorkOrder.value; + if (!workOrder) { + return; + } + if (activeOperation.value?.bizType === "PRODUCTION_SCRAP_IN") { + genericForm.item_id = workOrder.materials?.[0]?.material_item_id || ""; + genericForm.item_option_key = String(genericForm.item_id || ""); + genericForm.weight_kg = Number(workOrder.limits?.max_scrap_inbound_weight_kg || 0); + genericForm.source_material_sub_batch_no = workOrder.source_lot_summary || ""; + genericForm.source_material_summary = `${workOrder.work_order_no}生产废料`; + } + if (activeOperation.value?.bizType === "PRODUCTION_SURPLUS") { + genericForm.item_id = workOrder.materials?.[0]?.material_item_id || ""; + genericForm.item_option_key = String(genericForm.item_id || ""); + genericForm.weight_kg = Number(workOrder.limits?.max_surplus_inbound_weight_kg || 0); + selectedGenericSourceLots.value = (workOrder.materials || []).map((material) => ({ + lot_id: material.source_lot_id, + item_id: material.material_item_id, + item_code: material.material_code, + item_name: material.material_name, + lot_no: material.source_lot_no, + remaining_weight_kg: 0, + unit_cost: material.unit_cost, + return_weight_kg: 0 + })); + } +} +``` + +- [ ] **Step 5: Send work-order linkage for production scrap and surplus inbound** + +In the `isSourceLotReturnInbound` loop payload, add: + +```js +source_doc_type: activeOperation.value.bizType === "PRODUCTION_SURPLUS" ? "WORK_ORDER" : null, +source_doc_id: activeOperation.value.bizType === "PRODUCTION_SURPLUS" ? Number(selectedGenericWorkOrderId.value) : null, +``` + +In the generic inbound payload for production scrap, set: + +```js +source_doc_type: activeOperation.value.bizType === "PRODUCTION_SCRAP_IN" ? "WORK_ORDER" : activeWarehouseType.value === "SEMI" ? "PRODUCT_ROUTE_OPERATION" : null, +source_doc_id: activeOperation.value.bizType === "PRODUCTION_SCRAP_IN" ? Number(selectedGenericWorkOrderId.value) : activeWarehouseType.value === "SEMI" ? Number(selectedSourceDocId || 0) : null, +``` + +- [ ] **Step 6: Add frontend validation** + +At the top of `submitGenericOperation`, after logistics validation, add: + +```js +if ((activeOperation.value?.bizType === "PRODUCTION_SCRAP_IN" || activeOperation.value?.bizType === "PRODUCTION_SURPLUS") && !Number(selectedGenericWorkOrderId.value)) { + errorMessage.value = `${activeOperation.value.label}必须先选择生产工单`; + return; +} +``` + +- [ ] **Step 7: Run frontend build** + +Run: + +```bash +cd /Volumes/ForgeFlow-ERP/frontend +npm run build +``` + +Expected: build succeeds. + +--- + +### Task 8: Add Frontend Static Regression Test + +**Files:** +- Create: `frontend/scripts/test-production-work-order-ledger.mjs` + +- [ ] **Step 1: Create the static test** + +Create `frontend/scripts/test-production-work-order-ledger.mjs`: + +```js +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const root = process.cwd(); + +function read(relativePath) { + return fs.readFileSync(path.join(root, relativePath), "utf8"); +} + +function assertIncludes(content, needle, message) { + if (!content.includes(needle)) { + throw new Error(message); + } +} + +const router = read("src/router/index.js"); +const app = read("src/App.vue"); +const inventory = read("src/views/InventoryLedgerView.vue"); +const ledger = read("src/views/WorkOrderManagementView.vue"); + +assertIncludes(router, "WorkOrderManagementView", "router must import WorkOrderManagementView"); +assertIncludes(router, 'name: "work-order-ledger"', "router must define work-order-ledger route"); +assertIncludes(router, 'permission: "MENU_WORK_ORDER"', "work-order-ledger route must use MENU_WORK_ORDER"); +assertIncludes(app, 'label: "工单台账"', "production menu must include 工单台账"); +assertIncludes(app, 'to: { name: "work-order-ledger" }', "工单台账 menu must point to work-order-ledger route"); +assertIncludes(ledger, "/production/work-order-ledger?limit=500", "ledger view must load production work-order ledger endpoint"); +assertIncludes(ledger, "已用领料重量", "ledger view must show used material weight"); +assertIncludes(ledger, "归还原料重量", "ledger view must show returned raw material weight"); +assertIncludes(ledger, "可入库废料", "ledger detail must show scrap inbound limit"); +assertIncludes(inventory, 'source_doc_type: activeOperation.value.bizType === "PRODUCTION_SURPLUS" ? "WORK_ORDER"', "production surplus inbound must link to work order"); +assertIncludes(inventory, 'activeOperation.value.bizType === "PRODUCTION_SCRAP_IN" ? "WORK_ORDER"', "production scrap inbound must link to work order"); +assertIncludes(inventory, "生产工单", "warehouse drawers must expose production work order selector"); + +console.log("production work-order ledger static checks passed"); +``` + +- [ ] **Step 2: Run the static test** + +Run: + +```bash +cd /Volumes/ForgeFlow-ERP/frontend +node scripts/test-production-work-order-ledger.mjs +``` + +Expected: + +```text +production work-order ledger static checks passed +``` + +--- + +### Task 9: Full Verification + +**Files:** +- Verify only. + +- [ ] **Step 1: Run backend focused tests** + +Run: + +```bash +cd /Volumes/ForgeFlow-ERP/backend +PYTHONPATH=. .venv/bin/pytest tests/test_production_work_order_ledger.py tests/test_selected_stock_lot_production_issue.py tests/test_scrap_warehouse_flow.py::ScrapWarehouseFlowTest::test_production_surplus_returns_weight_to_selected_source_lot tests/test_scrap_warehouse_flow.py::ScrapWarehouseFlowTest::test_production_scrap_inbound_creates_scrap_lot_balance_and_txn -v +``` + +Expected: all selected tests pass. + +- [ ] **Step 2: Run frontend static tests** + +Run: + +```bash +cd /Volumes/ForgeFlow-ERP/frontend +node scripts/test-production-work-order-ledger.mjs +node scripts/test-inventory-ledger-options.mjs +npm run build +``` + +Expected: both static scripts pass and Vite build succeeds. + +- [ ] **Step 3: Manual UI smoke test** + +Start the backend and frontend using the project’s normal local commands. In the browser, verify: + +```text +1. 左侧生产执行展开后出现“工单台账”。 +2. 原材料库执行“生产出库”后自动生成一条工单台账记录。 +3. 小程序或测试报工同步后,工单台账的各工序良品/废品累计更新。 +4. 成品库“生产入库”第一步选择生产工单,页面显示可入库数量和容差上限。 +5. 废料库“生产废料入库”第一步选择生产工单,页面显示可入库废料重量和容差上限。 +6. 原材料库“生产余料入库”第一步选择生产工单,来源库存批次号只出现该工单领用过的批次。 +7. 超过 15% 容差提交时后端返回中文错误提示。 +``` + +Expected: all seven checks pass. + +--- + +## Self-Review + +- Spec coverage: The plan covers the production execution menu, automatic work orders from production outbound, work-order ledger fields, finished inbound by work order, scrap inbound by work order, surplus inbound by work order, and the 15% backend tolerance rule. +- Warehouse inbound coverage: Finished warehouse quantity-led inbound, scrap warehouse weight-only inbound, and raw-material warehouse weight-only surplus return are explicit backend and frontend tasks. +- Placeholder scan: The plan contains no placeholder markers or unspecified implementation steps. +- Type consistency: New schema names, service function names, and frontend endpoint names are consistent across tasks. diff --git a/docs/superpowers/plans/2026-06-02-semi-finished-measure-basis.md b/docs/superpowers/plans/2026-06-02-semi-finished-measure-basis.md new file mode 100644 index 0000000..26de275 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-semi-finished-measure-basis.md @@ -0,0 +1,107 @@ +# Semi-Finished Measure Basis Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make semi-finished inventory use one locked measure basis per semi-finished item-operation in a warehouse: by piece or by weight. + +**Architecture:** Do not add a database column unless tests prove derivation is insufficient. Derive the measure basis from semi-finished stock lots: `remaining_qty/inbound_qty` means by piece, `remaining_weight_kg/inbound_weight_kg` means by weight, both means legacy mixed. Backend enforces exactly one inbound basis and blocks mismatched new inbound for existing semi-finished stock; frontend presents only the correct input and availability wording. + +**Tech Stack:** FastAPI, SQLAlchemy, pytest, Vue 3, Vite, Node assertion script. + +--- + +### Task 1: Backend Measure Basis Enforcement + +**Files:** +- Modify: `backend/app/api/routes/inventory.py` +- Modify: `backend/tests/test_semi_finished_operation_inventory.py` + +- [ ] **Step 1: Write failing backend tests** + +Add tests that prove: +- semi-finished inbound rejects filling both quantity and weight; +- semi-finished inbound rejects filling neither quantity nor weight; +- once a semi-finished item-operation has by-piece stock, later by-weight inbound is rejected; +- once a semi-finished item-operation has by-weight stock, later by-piece inbound is rejected. + +- [ ] **Step 2: Run red tests** + +Run: `.venv/bin/python -m pytest tests/test_semi_finished_operation_inventory.py -q` + +Expected before implementation: at least the mismatch/both-filled tests fail because current backend accepts both fields and does not enforce existing basis. + +- [ ] **Step 3: Implement minimal backend helper** + +Add helper logic in `create_warehouse_inbound`: +- determine requested basis: `PIECE` when `inbound_qty > 0 and inbound_weight == 0`, `WEIGHT` when `inbound_weight > 0 and inbound_qty == 0`; +- reject both-filled and neither-filled for `WIP_IN` / `OUTSOURCING_IN`; +- query existing semi-finished lots for same `item_id`, `warehouse_id`, `source_line_id` with remaining quantity or weight; +- derive existing basis; if it is `MIXED`, reject with a Chinese message asking to clean historical stock first; +- if existing basis exists and differs from requested basis, reject with a Chinese message that the semi-finished item must follow existing measure basis. + +- [ ] **Step 4: Run backend tests** + +Run: `.venv/bin/python -m pytest tests/test_semi_finished_operation_inventory.py -q` + +Expected: all semi-finished tests pass. + +### Task 2: Frontend Semi-Finished UI Basis + +**Files:** +- Modify: `frontend/src/views/InventoryLedgerView.vue` +- Modify: `frontend/src/utils/inventoryOperationOptions.js` +- Modify: `frontend/scripts/test-inventory-ledger-options.mjs` + +- [ ] **Step 1: Write/update frontend assertions** + +Assert the source contains: +- user-facing label `半成品` instead of semi warehouse `产品工序`; +- `入库类型`, `按件入库`, `按重入库`; +- helper logic that formats semi availability by basis instead of always `formatWeight(...)`; +- payload sends only `inbound_qty` for piece basis and only `inbound_weight_kg` for weight basis. + +- [ ] **Step 2: Run red frontend assertion** + +Run: `node scripts/test-inventory-ledger-options.mjs` + +Expected before implementation: fails on missing measure-basis UI or payload logic. + +- [ ] **Step 3: Implement frontend UI** + +In `InventoryLedgerView.vue`: +- rename semi item field labels from `产品工序` to `半成品`; +- add `genericForm.measure_basis`; +- when semi inbound item changes, derive existing basis from `semiFinishedBalances` and auto-fill/lock `measure_basis`; +- show only `入库数量` for `PIECE`, only `入库重量(kg)` for `WEIGHT`; +- for semi outbound, derive basis from selected semi option and show only the matching outbound input; +- display `-` for irrelevant quantity/weight values in semi balances and lots. + +In `inventoryOperationOptions.js`: +- carry `measure_basis` through semi outbound options. + +- [ ] **Step 4: Run frontend checks** + +Run: `node scripts/test-inventory-ledger-options.mjs` + +Expected: assertion script passes. + +Run: `npm run build` + +Expected: Vite build completes successfully. + +### Task 3: Final Verification + +**Files:** +- Verify only. + +- [ ] **Step 1: Run backend full test suite** + +Run: `.venv/bin/python -m pytest tests -q` + +Expected: all backend tests pass. + +- [ ] **Step 2: Browser smoke** + +Run frontend dev server and open `http://127.0.0.1:5173/` in the in-app browser. + +Expected: page loads with title `嘉恒智能五金 ERP` and no console errors. diff --git a/docs/superpowers/plans/2026-06-02-semi-finished-operation-inventory.md b/docs/superpowers/plans/2026-06-02-semi-finished-operation-inventory.md new file mode 100644 index 0000000..c70f448 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-semi-finished-operation-inventory.md @@ -0,0 +1,593 @@ +# 半成品产品工序库存 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把嘉恒仓库的半成品库从“通用物料/产品库存”改为“产品 + 已完成工序”的半成品库存模型。 + +**Architecture:** 后端继续使用 `md_item.id` 表示产品,但用 `StockLot.source_line_id` 和 `InventoryTxn.source_line_id` 记录产品需规清单中的 `route_operation_id`,从而区分同一产品不同工序阶段的半成品。前端半成品入库从 `/master-data/process-routes` 展开产品工序选项,半成品出库和库存明细按 `item_id + source_line_id` 过滤与展示,避免跨工序混扣库存。 + +**Tech Stack:** FastAPI + SQLAlchemy + Pytest;Vue 3 + Vite + 现有静态 Node 测试脚本。 + +--- + +### Task 1: 前端选项工具增加产品工序选项 + +**Files:** +- Modify: `frontend/src/utils/inventoryOperationOptions.js` +- Test: `frontend/scripts/test-inventory-ledger-options.mjs` + +- [ ] **Step 1: Write the failing test** + +Add assertions to `frontend/scripts/test-inventory-ledger-options.mjs` after `productMasterRows`: + +```js +const productOperationRows = [ + { + option_key: "601::7001", + item_id: 601, + item_code: "产品需规00003", + item_name: "委外成品", + route_id: 8001, + route_operation_id: 7001, + seq_no: 1, + operation_name: "冲压" + }, + { + option_key: "601::7002", + item_id: 601, + item_code: "产品需规00003", + item_name: "委外成品", + route_id: 8001, + route_operation_id: 7002, + seq_no: 2, + operation_name: "折弯" + } +]; +``` + +Change the semi inbound test to pass `productOperations: productOperationRows` and expect both operation keys: + +```js +assert.deepEqual(semiInboundOptions.map((item) => item.option_key), ["601::7001", "601::7002"]); +assert.deepEqual(semiInboundOptions.map((item) => item.route_operation_id), [7001, 7002]); +assert.deepEqual(finishedInboundOptions.map((item) => item.item_id), [601]); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node frontend/scripts/test-inventory-ledger-options.mjs` + +Expected: FAIL because `buildGenericItemOptions` ignores `productOperations`. + +- [ ] **Step 3: Implement minimal option behavior** + +In `frontend/src/utils/inventoryOperationOptions.js`, add `productOperations = []` to the function parameters and return `productOperations` for semi inbound: + +```js +export function buildGenericItemOptions({ + direction = "in", + activeInventoryTab = "raw", + activeWarehouseType = "", + warehouseId = "", + balances = [], + materials = [], + products = [], + productOperations = [] +} = {}) { + if (String(direction || "").toLowerCase() === "out") { + return buildOutboundItemOptions({ + balances, + warehouseId, + warehouseType: activeWarehouseType, + activeInventoryTab + }); + } + if (activeInventoryTab === "semi" || String(activeWarehouseType || "").toUpperCase() === "SEMI") { + return productOperations; + } + if (activeInventoryTab === "scrap" || String(activeWarehouseType || "").toUpperCase() === "SCRAP") { + return [...materials, ...products]; + } + if (["raw", "auxiliary"].includes(activeInventoryTab)) { + return materials; + } + if (activeInventoryTab === "return" || String(activeWarehouseType || "").toUpperCase() === "RETURN") { + return products; + } + return products; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node frontend/scripts/test-inventory-ledger-options.mjs` + +Expected: PASS. + +--- + +### Task 2: 后端保存半成品工序维度并按工序出库 + +**Files:** +- Modify: `backend/app/schemas/operations.py` +- Modify: `backend/app/services/operations.py` +- Modify: `backend/app/api/routes/inventory.py` +- Test: `backend/tests/test_semi_finished_operation_inventory.py` + +- [ ] **Step 1: Write failing backend tests** + +Create `backend/tests/test_semi_finished_operation_inventory.py` with two tests: + +```python +from decimal import Decimal + +from fastapi import HTTPException +from sqlalchemy import select + +from app.api.routes.inventory import create_warehouse_inbound, create_warehouse_outbound +from app.models.master_data import Item, Process, ProcessRoute, ProcessRouteOperation, Warehouse, WarehouseLocation, WorkCenter +from app.models.operations import InventoryTxn, StockLot +from app.schemas.operations import WarehouseInboundCreate, WarehouseOutboundCreate + + +class _User: + id = 1 + username = "admin" + role = "ADMIN" + + +class _Context: + user = _User() + + +def _seed_route(db): + product = Item(item_code="产品需规00001", item_name="五金支架", item_type="FINISHED_GOOD", status="ACTIVE") + process = Process(process_code="GX01", process_name="冲压", process_type="INHOUSE", status="ACTIVE") + center = WorkCenter(work_center_code="WC01", work_center_name="一号工位", status="ACTIVE") + warehouse = Warehouse(warehouse_code="WH-SEMI-T", warehouse_name="半成品测试仓", warehouse_type="SEMI", status="ACTIVE") + db.add_all([product, process, center, warehouse]) + db.flush() + location = WarehouseLocation( + warehouse_id=warehouse.id, + location_code="SEMI-A", + location_name="半成品默认库位", + is_default=True, + status="ACTIVE", + ) + route = ProcessRoute( + route_code="R-001", + route_name="五金支架工艺", + product_item_id=product.id, + version_no="V1.0", + is_default=True, + status="ACTIVE", + ) + db.add_all([location, route]) + db.flush() + op1 = ProcessRouteOperation( + route_id=route.id, + seq_no=1, + process_id=process.id, + work_center_id=center.id, + operation_name="冲压", + status="ACTIVE", + ) + op2 = ProcessRouteOperation( + route_id=route.id, + seq_no=2, + process_id=process.id, + work_center_id=center.id, + operation_name="折弯", + status="ACTIVE", + ) + db.add_all([op1, op2]) + db.commit() + return product, warehouse, route, op1, op2 + + +def test_semi_inbound_records_product_operation_source_line(db_session): + product, warehouse, route, op1, _ = _seed_route(db_session) + + row = create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="WIP_IN", + item_id=product.id, + warehouse_id=warehouse.id, + inbound_weight_kg=120, + unit_cost=8, + source_doc_type="PRODUCT_ROUTE_OPERATION", + source_doc_id=route.id, + source_line_id=op1.id, + source_material_summary="产品:五金支架;已完成工序:第1道 冲压", + ), + context=_Context(), + db=db_session, + ) + + lot = db_session.get(StockLot, row.lot_id) + assert lot.source_line_id == op1.id + assert lot.source_doc_id == route.id + assert "第1道" in lot.source_material_summary + txn = db_session.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == lot.id)) + assert txn.source_line_id == op1.id + + +def test_semi_outbound_deducts_only_selected_product_operation(db_session): + product, warehouse, route, op1, op2 = _seed_route(db_session) + context = _Context() + first = create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="WIP_IN", + item_id=product.id, + warehouse_id=warehouse.id, + inbound_weight_kg=50, + unit_cost=8, + source_doc_type="PRODUCT_ROUTE_OPERATION", + source_doc_id=route.id, + source_line_id=op1.id, + source_material_summary="产品:五金支架;已完成工序:第1道 冲压", + ), + context=context, + db=db_session, + ) + second = create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="WIP_IN", + item_id=product.id, + warehouse_id=warehouse.id, + inbound_weight_kg=80, + unit_cost=9, + source_doc_type="PRODUCT_ROUTE_OPERATION", + source_doc_id=route.id, + source_line_id=op2.id, + source_material_summary="产品:五金支架;已完成工序:第2道 折弯", + ), + context=context, + db=db_session, + ) + + create_warehouse_outbound( + WarehouseOutboundCreate( + biz_type="WIP_OUT", + item_id=product.id, + warehouse_id=warehouse.id, + outbound_weight_kg=30, + target_doc_type="PRODUCT_ROUTE_OPERATION", + target_doc_id=route.id, + target_doc_line_id=op2.id, + target_material_sub_batch_no="第2道 折弯", + ), + context=context, + db=db_session, + ) + + first_lot = db_session.get(StockLot, first.lot_id) + second_lot = db_session.get(StockLot, second.lot_id) + assert first_lot.remaining_weight_kg == Decimal("50.000") + assert second_lot.remaining_weight_kg == Decimal("50.000") +``` + +- [ ] **Step 2: Run backend test to verify it fails** + +Run: `cd backend && .venv/bin/python -m pytest tests/test_semi_finished_operation_inventory.py -q` + +Expected: FAIL because `WarehouseInboundCreate` has no `source_line_id` and inbound ignores operation. + +- [ ] **Step 3: Implement schema and query fields** + +Modify `backend/app/schemas/operations.py`: + +```python +class StockLotRead(BaseModel): + ... + source_doc_id: int + source_line_id: int | None = None + ... + + +class WarehouseInboundCreate(BaseModel): + ... + source_doc_id: int | None = None + source_line_id: int | None = None + ... +``` + +Modify `backend/app/services/operations.py` inside `get_stock_lots_query` select list: + +```python +StockLot.source_doc_id.label("source_doc_id"), +StockLot.source_line_id.label("source_line_id"), +``` + +- [ ] **Step 4: Implement inbound validation and persistence** + +In `backend/app/api/routes/inventory.py`, import `ProcessRoute` and `ProcessRouteOperation` if missing. Add helper: + +```python +def _validate_semi_product_operation(db: Session, item_id: int, source_line_id: int | None) -> ProcessRouteOperation: + if not source_line_id: + raise HTTPException(status_code=400, detail="半成品入出库必须选择产品工序") + operation = db.get(ProcessRouteOperation, source_line_id) + if not operation: + raise HTTPException(status_code=404, detail="产品工序不存在") + route = db.get(ProcessRoute, operation.route_id) + if not route or route.product_item_id != item_id: + raise HTTPException(status_code=400, detail="产品工序与所选产品不一致") + if str(operation.status or "").upper() != "ACTIVE": + raise HTTPException(status_code=400, detail="停用的产品工序不能用于半成品入出库") + return operation +``` + +Before creating a new half-finished inbound lot: + +```python +if warehouse_type == "SEMI" and biz_type in {"WIP_IN", "OUTSOURCING_IN"}: + operation = _validate_semi_product_operation(db, payload.item_id, payload.source_line_id) + source_doc_type = payload.source_doc_type or "PRODUCT_ROUTE_OPERATION" + source_doc_id = payload.source_doc_id or operation.route_id + source_line_id = operation.id +else: + source_doc_id = payload.source_doc_id or 0 + source_line_id = payload.source_line_id +``` + +Then use `source_doc_id` and `source_line_id` when creating `StockLot` and `InventoryTxn`. + +- [ ] **Step 5: Implement semi outbound operation filter** + +In `create_warehouse_outbound`, before FIFO query for non-selected lots: + +```python +if warehouse_type == "SEMI" and biz_type in {"WIP_OUT", "OUTSOURCING_OUT"}: + _validate_semi_product_operation(db, payload.item_id, payload.target_doc_line_id) +``` + +Add this filter to `lot_filters`: + +```python +if warehouse_type == "SEMI" and payload.target_doc_line_id: + lot_filters.append(StockLot.source_line_id == payload.target_doc_line_id) +``` + +- [ ] **Step 6: Run backend test to verify it passes** + +Run: `cd backend && .venv/bin/python -m pytest tests/test_semi_finished_operation_inventory.py -q` + +Expected: PASS. + +--- + +### Task 3: 前端半成品页面按产品工序展示、提交和过滤 + +**Files:** +- Modify: `frontend/src/views/InventoryLedgerView.vue` +- Test: `frontend/scripts/test-inventory-ledger-options.mjs` + +- [ ] **Step 1: Add process route loading state** + +Add: + +```js +const processRoutes = ref([]); +``` + +Update `loadAll()` to fetch: + +```js +fetchResource("/master-data/process-routes?limit=500", []) +``` + +and assign: + +```js +processRoutes.value = processRouteRows; +``` + +- [ ] **Step 2: Build active product operation options** + +Add computed: + +```js +const productOperationOptions = computed(() => + processRoutes.value + .filter((route) => String(route.status || "").toUpperCase() === "ACTIVE") + .flatMap((route) => + (route.operations || []) + .filter((operation) => String(operation.status || "").toUpperCase() === "ACTIVE") + .map((operation) => ({ + option_key: `${route.product_item_id}::${operation.route_operation_id}`, + item_id: Number(route.product_item_id), + item_code: route.product_code, + item_name: route.product_name, + version_no: route.version_no, + route_id: Number(route.route_id), + route_operation_id: Number(operation.route_operation_id), + seq_no: Number(operation.seq_no || 0), + operation_name: operation.operation_name || operation.process_name || "", + source_material_summary: `产品:${route.product_name};已完成工序:第${operation.seq_no}道 ${operation.operation_name || operation.process_name || ""}` + })) + ) + .sort((left, right) => + String(left.item_code || "").localeCompare(String(right.item_code || ""), "zh-CN", { numeric: true }) || + Number(left.seq_no || 0) - Number(right.seq_no || 0) + ) +); +``` + +- [ ] **Step 3: Change generic form selection to option key** + +Extend `buildEmptyGenericForm()`: + +```js +item_option_key: "", +source_doc_id: null, +source_line_id: null, +``` + +Add: + +```js +const isSemiWarehouseOperation = computed(() => activeWarehouseType.value === "SEMI"); +const genericItemFieldLabel = computed(() => (isSemiWarehouseOperation.value ? "产品工序" : activeInventoryTab.value === "finished" || activeInventoryTab.value === "return" ? "产品" : "物料")); +const genericItemPlaceholder = computed(() => `请选择${genericItemFieldLabel.value}`); +const selectedGenericItemOption = computed(() => + genericItemOptions.value.find((item) => String(item.option_key || item.item_id) === String(genericForm.item_option_key)) || null +); +``` + +Change the generic item ` + + + + + + 剩余可回库 {{ formatQty(selectedReworkInboundDisposition.remaining_rework_inbound_qty) }},已回库 {{ formatQty(selectedReworkInboundDisposition.rework_inbound_qty) }}。 + +``` + +Update the manual batch-number hiding condition so rework inbound does not show the manual batch field: + +```vue +
+``` + +Update the source/provider field condition so rework inbound can record the rework team: + +```vue + +``` + +Update the source-lot selector `allow-unavailable` binding: + +```vue + :allow-unavailable="isSourceLotReturnInbound || isSemiSourceLotTraceInbound || isFinishedReworkInbound || isOutsourcingScrapInbound" +``` + +- [ ] **Step 4: Validate and submit rework inbound** + +In `submitGenericOperation()`, inside the inbound branch before building `inboundWeightKg`, add: + +```javascript + if (isFinishedReworkInbound.value) { + const inboundQty = Number(genericForm.qty || 0); + if (!selectedReworkInboundDisposition.value) { + errorMessage.value = "请选择返工出库记录"; + submittingOperation.value = false; + return; + } + if (!Number.isFinite(inboundQty) || inboundQty <= 0) { + errorMessage.value = "成品返工入库数量必须大于0"; + submittingOperation.value = false; + return; + } + const remainingQty = Number(selectedReworkInboundDisposition.value.remaining_rework_inbound_qty || 0); + if (inboundQty > remainingQty) { + errorMessage.value = "返工入库数量不能超过剩余可回库数量"; + submittingOperation.value = false; + return; + } + } +``` + +Update `inboundWeightKg`: + +```javascript + const inboundWeightKg = activeWarehouseType.value === "AUX" || isFinishedOutsourcingInbound.value || isFinishedReworkInbound.value + ? 0 + : isSemiSourceLotTraceInbound.value && selectedSemiMeasureBasis.value === "PIECE" + ? 0 + : Number(genericForm.weight_kg || 0); +``` + +Update `inboundQty`: + +```javascript + const inboundQty = activeWarehouseType.value === "AUX" || isFinishedOutsourcingInbound.value || isFinishedReworkInbound.value + ? Number(genericForm.qty || 0) + : isSemiSourceLotTraceInbound.value && selectedSemiMeasureBasis.value === "PIECE" + ? Number(genericForm.qty || 0) + : 0; +``` + +Update `unit_cost` in the inbound payload: + +```javascript + unit_cost: isSemiSourceLotTraceInbound.value || isFinishedReworkInbound.value ? null : activeOperation.value.bizType === "CUSTOMER_SUPPLIED" ? 0 : Number(genericForm.unit_cost || 0), +``` + +Update source document fields in the inbound payload: + +```javascript + source_doc_type: isFinishedReworkInbound.value + ? "RETURN_DISPOSITION" + : isProductionScrapInbound.value + ? "WORK_ORDER" + : activeWarehouseType.value === "SEMI" + ? "PRODUCT_ROUTE_OPERATION" + : null, + source_doc_id: isFinishedReworkInbound.value + ? Number(selectedReworkInboundDisposition.value?.return_disposition_id || 0) + : isProductionScrapInbound.value + ? Number(selectedGenericWorkOrder.value.work_order_id) + : activeWarehouseType.value === "SEMI" + ? Number(selectedSourceDocId || 0) + : null, + source_line_id: isFinishedReworkInbound.value + ? Number(selectedReworkInboundDisposition.value?.return_item_id || 0) + : activeWarehouseType.value === "SEMI" + ? Number(selectedSourceLineId || 0) + : null, +``` + +Update `source_material_summary` branch: + +```javascript + : isFinishedReworkInbound.value + ? selectedReworkInboundDisposition.value?.disposition_no || genericForm.provider_name || null +``` + +- [ ] **Step 5: Add dictionary labels** + +In `frontend/src/utils/dictionaries.js`, add: + +```javascript + REWORK_IN: "返工入库", +``` + +to `INVENTORY_TXN_TYPE_LABELS`, near other inbound labels. Add this to `SOURCE_DOC_TYPE_LABELS` if it is not already present: + +```javascript + RETURN_DISPOSITION: "退货处置", +``` + +- [ ] **Step 6: Run frontend static tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node scripts/test-finished-rework-inbound.mjs +node scripts/test-inventory-ledger-return-warehouse.mjs +node scripts/test-return-delivery-line-labels.mjs +node scripts/test-return-warehouse-source-lot-display.mjs +node scripts/test-outsourcing-scrap-in-source-lots.mjs +node scripts/test-inventory-ledger-options.mjs +``` + +Expected: all scripts print their success messages and exit with code 0. + +## Task 5: End-to-End Build and Runtime Verification + +**Files:** +- Verify: `backend/app/api/routes/inventory.py` +- Verify: `frontend/src/views/InventoryLedgerView.vue` + +- [ ] **Step 1: Run backend targeted test set** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +PYTHONPATH=. .venv/bin/pytest tests/test_return_warehouse_flow.py tests/test_sales_order_delivery_trace.py tests/test_scrap_warehouse_flow.py tests/test_warehouse_transaction_ledger.py -q +``` + +Expected: PASS. + +- [ ] **Step 2: Run frontend production build** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +npm run build +``` + +Expected: build completes without TypeScript/Vite errors. + +- [ ] **Step 3: Browser smoke test** + +Open the existing local app in the Codex in-app browser at: + +```text +http://127.0.0.1:5173/ +``` + +Manual checks: + +- Login if needed. +- Navigate to `嘉恒仓库`. +- Select `成品库`. +- Confirm the inbound operation row shows `返工入库`. +- Click `返工入库`. +- Confirm the drawer shows `产品`, `返工出库记录`, `来源库存批次号`, `入库数量`, `来源/提供方`, and `备注`. +- Select a product that has a rework outbound record. +- Confirm the `返工出库记录` options show remaining rework-inbound quantity in Chinese. +- Confirm the raw-material stock-lot selector uses `JH...` source lot numbers and not internal return/finished lot numbers. + +- [ ] **Step 4: Record verification checkpoint** + +Because this workspace is not a git repository, record the final verification in the assistant response instead of committing. If executing in a git worktree, run: + +```bash +git status --short +git add backend/app/schemas/operations.py backend/app/services/operations.py backend/app/api/routes/inventory.py backend/tests/test_return_warehouse_flow.py frontend/src/views/InventoryLedgerView.vue frontend/src/utils/dictionaries.js frontend/scripts/test-finished-rework-inbound.mjs docs/superpowers/plans/2026-06-03-finished-rework-inbound.md +git commit -m "feat: add finished rework inbound" +``` + +Expected in this mounted workspace: `git status --short` reports `fatal: not a git repository`, so no commit is created here. + +## Self-Review + +- Spec coverage: The plan covers the finished warehouse operation, mandatory return-disposition binding, product-filtered rework records, raw-material source stock lot traceability, quantity-based finished inventory updates, derived rework return status, Chinese labels, frontend UI, backend validation, and tests. +- Placeholder scan: No `TBD`, `TODO`, or undefined "add later" steps are present. Each implementation step includes concrete code or exact commands. +- Type consistency: `REWORK_IN`, `RETURN_DISPOSITION`, `return_disposition_id`, `return_item_id`, `rework_inbound_qty`, `remaining_rework_inbound_qty`, and `rework_inbound_status` are used consistently across schema, query, backend validation, frontend filtering, and tests. diff --git a/docs/superpowers/plans/2026-06-03-tencent-ip-port-dev-deploy.md b/docs/superpowers/plans/2026-06-03-tencent-ip-port-dev-deploy.md new file mode 100644 index 0000000..87a0908 --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-tencent-ip-port-dev-deploy.md @@ -0,0 +1,1125 @@ +# Tencent IP Port Dev Deploy Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将当前 ForgeFlow ERP 前后端部署到腾讯云 1Panel Ubuntu 服务器,临时开启动态加载,通过 `公网 IP + 端口` 访问,数据库继续连接阿里云 MySQL。 + +**Architecture:** 临时测试阶段不走域名、HTTPS、Nginx/OpenResty 反代。浏览器访问 Vite dev server `http://腾讯云公网IP:5173`,前端请求 FastAPI `http://腾讯云公网IP:8000/api`,后端连接阿里云 MySQL `121.40.97.156:3306`。稳定后再切到正式生产架构:前端静态构建、OpenResty 反代 `/api`、HTTPS、后端仅监听本机。 + +**Tech Stack:** Ubuntu 22.04/1Panel, Python 3, FastAPI/Uvicorn, Vue 3/Vite, Node.js 20, MySQL on Aliyun. + +--- + +## Phase 0: 部署边界确认 + +本计划只做临时测试部署,不代表最终生产配置。 + +临时测试访问方式: + +```text +浏览器 + -> http://<腾讯云公网IP>:5173 + Vite dev server,支持前端热更新 + +前端 API 请求 + -> http://<腾讯云公网IP>:8000/api + FastAPI/Uvicorn,使用 --reload 支持后端动态加载 + +后端数据库连接 + -> 121.40.97.156:3306 + 阿里云 MySQL +``` + +需要提前准备的信息: + +```bash +export TENCENT_IP="<腾讯云服务器公网IP>" +export ALIYUN_MYSQL_HOST="121.40.97.156" +export ALIYUN_MYSQL_PORT="3306" +export ERP_DB_NAME="jiaheng_erp" +export ERP_DB_USER="<数据库用户名>" +export ERP_DB_PASSWORD="<数据库密码>" +``` + +本阶段必须接受的临时风险: + +- `5173` 和 `8000` 会临时暴露在公网,只适合测试期。 +- Vite dev server 和 `uvicorn --reload` 不是最终生产运行方式。 +- 如果直接连现有阿里云数据库,腾讯云上的操作会真实写入同一个库。 +- 数据库账号不要长期使用 `root`,至少正式前要换成 ERP 专用账号。 + +--- + +## Phase 1: 腾讯云服务器基础准备 + +### Task 1: 登录服务器并确认系统 + +- [ ] **Step 1: SSH 登录腾讯云服务器** + +```bash +ssh root@<腾讯云公网IP> +``` + +Expected: + +```text +成功进入 Ubuntu shell +``` + +- [ ] **Step 2: 确认系统版本和架构** + +```bash +uname -a +lsb_release -a || cat /etc/os-release +``` + +Expected: + +```text +Linux VM-0-14-ubuntu ... +Ubuntu ... +x86_64 +``` + +### Task 2: 安装基础依赖 + +- [ ] **Step 1: 更新 apt 包索引** + +```bash +sudo apt update +``` + +Expected: + +```text +Reading package lists... Done +``` + +- [ ] **Step 2: 安装 Python、构建工具、Git、MySQL 客户端** + +```bash +sudo apt install -y git curl build-essential python3 python3-venv python3-pip mysql-client tmux +``` + +Expected: + +```text +git, curl, python3, python3-venv, mysql-client, tmux 安装完成 +``` + +- [ ] **Step 3: 安装 Node.js 20** + +如果当前没有 Node,或者 `node -v` 低于 20,执行: + +```bash +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - +sudo apt install -y nodejs +``` + +验证: + +```bash +node -v +npm -v +``` + +Expected: + +```text +v20.x.x +10.x.x 或更高 +``` + +--- + +## Phase 2: 网络和防火墙放行 + +### Task 3: 腾讯云安全组放行临时端口 + +- [ ] **Step 1: 在腾讯云控制台安全组放行 TCP 端口** + +放行入站规则: + +```text +TCP 5173 来源:你的办公公网 IP/32 +TCP 8000 来源:你的办公公网 IP/32 +TCP 22 来源:你的办公公网 IP/32 +``` + +如果测试人员公网 IP 不固定,临时可用: + +```text +TCP 5173 来源:0.0.0.0/0 +TCP 8000 来源:0.0.0.0/0 +``` + +但这只能用于短期测试,稳定后必须收口。 + +Expected: + +```text +外网可以访问 5173 和 8000,不再被腾讯云安全组拦截 +``` + +### Task 4: 1Panel 或 Ubuntu 防火墙放行端口 + +- [ ] **Step 1: 如果使用 1Panel 防火墙,在 1Panel 面板中放行** + +```text +端口:5173/tcp +端口:8000/tcp +``` + +- [ ] **Step 2: 如果服务器启用了 ufw,执行放行** + +```bash +sudo ufw allow 5173/tcp +sudo ufw allow 8000/tcp +sudo ufw status +``` + +Expected: + +```text +5173/tcp ALLOW +8000/tcp ALLOW +``` + +### Task 5: 阿里云 MySQL 放行腾讯云公网 IP + +- [ ] **Step 1: 在阿里云服务器或 RDS 安全组中放行腾讯云公网 IP** + +放行规则: + +```text +入站 TCP 3306 来源:<腾讯云公网IP>/32 +``` + +- [ ] **Step 2: 在腾讯云服务器测试 MySQL 连通性** + +```bash +mysql -h 121.40.97.156 -P 3306 -u <数据库用户名> -p -e "SELECT NOW() AS db_time;" +``` + +Expected: + +```text ++---------------------+ +| db_time | ++---------------------+ +| 2026-... | ++---------------------+ +``` + +如果报错: + +```text +ERROR 2003 Can't connect to MySQL server +``` + +处理方向: + +```text +优先检查阿里云安全组、MySQL bind-address、防火墙、账号 Host 权限。 +``` + +--- + +## Phase 3: 部署项目代码 + +### Task 6: 准备项目目录 + +- [ ] **Step 1: 创建部署目录** + +```bash +sudo mkdir -p /opt/ForgeFlow-ERP +sudo chown -R $USER:$USER /opt/ForgeFlow-ERP +``` + +Expected: + +```text +/opt/ForgeFlow-ERP 可由当前用户读写 +``` + +### Task 7: 上传或拉取代码 + +推荐方式 A:服务器可以访问 Git 仓库时使用 `git clone`。 + +- [ ] **Step 1A: 克隆项目** + +```bash +cd /opt +git clone ForgeFlow-ERP +cd /opt/ForgeFlow-ERP +git status +``` + +Expected: + +```text +On branch ... +working tree clean +``` + +推荐方式 B:如果仓库暂时不方便从服务器拉取,就从 Mac 同步代码。 + +- [ ] **Step 1B: 在 Mac 上执行 rsync 上传** + +```bash +rsync -av --delete \ + --exclude 'frontend/node_modules' \ + --exclude 'backend/.venv' \ + --exclude '.git' \ + /Users/souplearn/Gitlab/py/ForgeFlow-ERP/ \ + root@<腾讯云公网IP>:/opt/ForgeFlow-ERP/ +``` + +Expected: + +```text +代码同步到 /opt/ForgeFlow-ERP +``` + +- [ ] **Step 2: 在服务器确认目录结构** + +```bash +cd /opt/ForgeFlow-ERP +ls -la +ls -la backend frontend +``` + +Expected: + +```text +backend/ +frontend/ +README.md +``` + +--- + +## Phase 4: 后端动态加载部署 + +### Task 8: 配置后端 `.env` + +- [ ] **Step 1: 创建后端环境变量文件** + +```bash +cd /opt/ForgeFlow-ERP/backend +cp .env.example .env +``` + +- [ ] **Step 2: 编辑 `.env`** + +```bash +nano /opt/ForgeFlow-ERP/backend/.env +``` + +推荐内容: + +```env +APP_NAME=Jiaheng Hardware ERP API +APP_ENV=staging-dev +APP_HOST=0.0.0.0 +APP_PORT=8000 + +MYSQL_HOST=121.40.97.156 +MYSQL_PORT=3306 +MYSQL_DATABASE=jiaheng_erp +MYSQL_USER=<数据库用户名> +MYSQL_PASSWORD=<数据库密码> + +ALLOWED_ORIGINS=http://<腾讯云公网IP>:5173 + +AUTH_SECRET_KEY=<生成一串至少32位的随机字符串> +AUTH_TOKEN_EXPIRE_MINUTES=480 +``` + +生成随机密钥: + +```bash +python3 - <<'PY' +import secrets +print(secrets.token_urlsafe(48)) +PY +``` + +Expected: + +```text +输出一串随机字符串,将它填入 AUTH_SECRET_KEY +``` + +注意: + +```text +不要把正式数据库密码、AUTH_SECRET_KEY 发到聊天或提交到 Git。 +``` + +### Task 9: 安装后端依赖 + +- [ ] **Step 1: 创建虚拟环境** + +```bash +cd /opt/ForgeFlow-ERP/backend +python3 -m venv .venv +``` + +- [ ] **Step 2: 安装依赖** + +```bash +cd /opt/ForgeFlow-ERP/backend +. .venv/bin/activate +pip install -U pip +pip install -r requirements.txt +``` + +Expected: + +```text +Successfully installed fastapi uvicorn sqlalchemy pymysql ... +``` + +### Task 10: 临时启动后端动态加载 + +- [ ] **Step 1: 用 tmux 启动后端** + +```bash +tmux new -s forgeflow-backend +``` + +在 tmux 窗口内执行: + +```bash +cd /opt/ForgeFlow-ERP/backend +. .venv/bin/activate +uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +``` + +Expected: + +```text +Uvicorn running on http://0.0.0.0:8000 +Started reloader process +``` + +退出 tmux 但保留进程: + +```text +按 Ctrl+B,然后按 D +``` + +- [ ] **Step 2: 服务器本机验证后端** + +```bash +curl -s http://127.0.0.1:8000/ | python3 -m json.tool +curl -s http://127.0.0.1:8000/api/system/health | python3 -m json.tool +``` + +Expected: + +```json +{ + "app": "ok", + "database": { + "connected": true + } +} +``` + +- [ ] **Step 3: Mac 或浏览器外网验证后端** + +```bash +curl -s http://<腾讯云公网IP>:8000/api/system/health +``` + +Expected: + +```text +能返回 app ok,database connected true +``` + +--- + +## Phase 5: 前端动态加载部署 + +### Task 11: 配置前端 `.env` + +- [ ] **Step 1: 创建前端环境变量文件** + +```bash +cd /opt/ForgeFlow-ERP/frontend +cp .env.example .env +``` + +- [ ] **Step 2: 修改 API 地址** + +```bash +nano /opt/ForgeFlow-ERP/frontend/.env +``` + +内容: + +```env +VITE_API_BASE_URL=http://<腾讯云公网IP>:8000/api +``` + +Expected: + +```text +前端所有 API 请求都会打到腾讯云后端 8000 端口 +``` + +### Task 12: 安装前端依赖 + +- [ ] **Step 1: 安装 npm 依赖** + +```bash +cd /opt/ForgeFlow-ERP/frontend +npm install +``` + +Expected: + +```text +added ... packages +``` + +### Task 13: 临时启动前端动态加载 + +- [ ] **Step 1: 用 tmux 启动前端** + +```bash +tmux new -s forgeflow-frontend +``` + +在 tmux 窗口内执行: + +```bash +cd /opt/ForgeFlow-ERP/frontend +npm run dev -- --host 0.0.0.0 --port 5173 +``` + +Expected: + +```text +VITE v5.x.x ready +Local: http://localhost:5173/ +Network: http://<内网IP>:5173/ +``` + +退出 tmux 但保留进程: + +```text +按 Ctrl+B,然后按 D +``` + +- [ ] **Step 2: 服务器本机验证前端** + +```bash +curl -I http://127.0.0.1:5173 +``` + +Expected: + +```text +HTTP/1.1 200 OK +``` + +- [ ] **Step 3: 浏览器访问** + +```text +http://<腾讯云公网IP>:5173/login?redirect=/ +``` + +Expected: + +```text +出现 ERP 登录页 +``` + +--- + +## Phase 6: 测试期进程守护 + +tmux 适合第一天快速验证。如果需要测试环境持续跑几天,建议改成 systemd 管理,但仍然保留动态加载。 + +### Task 14: 创建后端 dev systemd 服务 + +- [ ] **Step 1: 创建服务文件** + +```bash +sudo nano /etc/systemd/system/forgeflow-backend-dev.service +``` + +内容: + +```ini +[Unit] +Description=ForgeFlow ERP Backend Dev Reload +After=network.target + +[Service] +Type=simple +WorkingDirectory=/opt/ForgeFlow-ERP/backend +ExecStart=/opt/ForgeFlow-ERP/backend/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +Restart=always +RestartSec=3 +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=multi-user.target +``` + +- [ ] **Step 2: 启动服务** + +```bash +sudo systemctl daemon-reload +sudo systemctl enable forgeflow-backend-dev +sudo systemctl start forgeflow-backend-dev +sudo systemctl status forgeflow-backend-dev --no-pager +``` + +Expected: + +```text +Active: active (running) +``` + +- [ ] **Step 3: 查看日志** + +```bash +journalctl -u forgeflow-backend-dev -f +``` + +Expected: + +```text +Uvicorn running on http://0.0.0.0:8000 +``` + +### Task 15: 创建前端 dev systemd 服务 + +- [ ] **Step 1: 查出 npm 路径** + +```bash +which npm +``` + +Expected: + +```text +/usr/bin/npm +``` + +- [ ] **Step 2: 创建服务文件** + +```bash +sudo nano /etc/systemd/system/forgeflow-frontend-dev.service +``` + +如果 `which npm` 输出 `/usr/bin/npm`,内容如下: + +```ini +[Unit] +Description=ForgeFlow ERP Frontend Vite Dev Server +After=network.target + +[Service] +Type=simple +WorkingDirectory=/opt/ForgeFlow-ERP/frontend +ExecStart=/usr/bin/npm run dev -- --host 0.0.0.0 --port 5173 +Restart=always +RestartSec=3 +Environment=NODE_ENV=development + +[Install] +WantedBy=multi-user.target +``` + +- [ ] **Step 3: 启动服务** + +```bash +sudo systemctl daemon-reload +sudo systemctl enable forgeflow-frontend-dev +sudo systemctl start forgeflow-frontend-dev +sudo systemctl status forgeflow-frontend-dev --no-pager +``` + +Expected: + +```text +Active: active (running) +``` + +- [ ] **Step 4: 查看日志** + +```bash +journalctl -u forgeflow-frontend-dev -f +``` + +Expected: + +```text +VITE v5.x.x ready +``` + +--- + +## Phase 7: 动态加载更新流程 + +### Task 16: 代码更新后验证热加载 + +- [ ] **Step 1: 更新代码** + +如果使用 Git: + +```bash +cd /opt/ForgeFlow-ERP +git pull +``` + +如果使用 rsync,在 Mac 重新执行同步命令: + +```bash +rsync -av --delete \ + --exclude 'frontend/node_modules' \ + --exclude 'backend/.venv' \ + --exclude '.git' \ + /Users/souplearn/Gitlab/py/ForgeFlow-ERP/ \ + root@<腾讯云公网IP>:/opt/ForgeFlow-ERP/ +``` + +- [ ] **Step 2: 如果只改后端 Python 代码** + +Expected: + +```text +uvicorn --reload 自动重载,无需手动重启 +``` + +验证: + +```bash +journalctl -u forgeflow-backend-dev -n 80 --no-pager +``` + +- [ ] **Step 3: 如果改前端 Vue/CSS/JS** + +Expected: + +```text +Vite dev server 自动热更新浏览器页面 +``` + +验证: + +```bash +journalctl -u forgeflow-frontend-dev -n 80 --no-pager +``` + +- [ ] **Step 4: 如果改了依赖** + +后端依赖变更: + +```bash +cd /opt/ForgeFlow-ERP/backend +. .venv/bin/activate +pip install -r requirements.txt +sudo systemctl restart forgeflow-backend-dev +``` + +前端依赖变更: + +```bash +cd /opt/ForgeFlow-ERP/frontend +npm install +sudo systemctl restart forgeflow-frontend-dev +``` + +Expected: + +```text +依赖安装成功,服务重启成功 +``` + +--- + +## Phase 8: 功能验收清单 + +### Task 17: 登录和基础页面验收 + +- [ ] **Step 1: 打开登录页** + +```text +http://<腾讯云公网IP>:5173/login?redirect=/ +``` + +Expected: + +```text +登录页正常渲染,浏览器控制台无 CORS 报错 +``` + +- [ ] **Step 2: 登录 ERP** + +Expected: + +```text +登录成功,进入首页 +``` + +- [ ] **Step 3: 打开浏览器开发者工具 Network** + +Expected: + +```text +API 请求地址是 http://<腾讯云公网IP>:8000/api/... +状态码不是 CORS error +``` + +### Task 18: 核心业务验收 + +- [ ] **Step 1: 基础资料页面** + +验证: + +```text +供应商名录、客户名录、原材料名录、产品需规清单能打开 +``` + +- [ ] **Step 2: 订单与采购页面** + +验证: + +```text +销售订单列表、采购订单、到货入库、质量校验能打开 +``` + +- [ ] **Step 3: 仓库页面** + +验证: + +```text +嘉恒仓库六大库能打开,出入库弹窗能打开 +``` + +- [ ] **Step 4: 生产执行页面** + +验证: + +```text +工单台账、工序报工能打开 +``` + +- [ ] **Step 5: 发货概览页面** + +验证: + +```text +发货台账能打开 +``` + +### Task 19: Excel 导入导出验收 + +- [ ] **Step 1: 测试导出** + +验证: + +```text +供应商名录、原材料名录、客户名录、仓库期初模板、盘库清单导出正常 +``` + +- [ ] **Step 2: 测试导入** + +验证: + +```text +上传 Excel 时无 413、CORS、权限、临时目录报错 +``` + +- [ ] **Step 3: 检查响应头** + +Expected: + +```text +浏览器能识别 Content-Disposition,下载文件名正常 +``` + +说明: + +```text +后端 CORS 已 expose Content-Disposition,只要 ALLOWED_ORIGINS 配对即可。 +``` + +--- + +## Phase 9: 常见故障处理 + +### Task 20: 前端页面打不开 + +- [ ] **Step 1: 检查 Vite 服务** + +```bash +sudo systemctl status forgeflow-frontend-dev --no-pager +journalctl -u forgeflow-frontend-dev -n 120 --no-pager +``` + +Expected: + +```text +Active: active (running) +VITE ready +``` + +- [ ] **Step 2: 检查端口监听** + +```bash +ss -lntp | grep 5173 +``` + +Expected: + +```text +0.0.0.0:5173 +``` + +如果只看到 `127.0.0.1:5173`,说明没有绑定公网网卡,需要加: + +```bash +npm run dev -- --host 0.0.0.0 --port 5173 +``` + +### Task 21: 后端接口打不开 + +- [ ] **Step 1: 检查后端服务** + +```bash +sudo systemctl status forgeflow-backend-dev --no-pager +journalctl -u forgeflow-backend-dev -n 120 --no-pager +``` + +- [ ] **Step 2: 检查端口监听** + +```bash +ss -lntp | grep 8000 +``` + +Expected: + +```text +0.0.0.0:8000 +``` + +- [ ] **Step 3: 本机 curl** + +```bash +curl -s http://127.0.0.1:8000/api/system/health +``` + +Expected: + +```text +本机通则进程正常;外网不通则优先查腾讯云安全组和 1Panel 防火墙。 +``` + +### Task 22: 登录时报 CORS + +- [ ] **Step 1: 确认前端访问地址** + +```text +http://<腾讯云公网IP>:5173 +``` + +- [ ] **Step 2: 确认后端 `.env`** + +```bash +grep ALLOWED_ORIGINS /opt/ForgeFlow-ERP/backend/.env +``` + +Expected: + +```env +ALLOWED_ORIGINS=http://<腾讯云公网IP>:5173 +``` + +- [ ] **Step 3: 修改后重启后端** + +```bash +sudo systemctl restart forgeflow-backend-dev +``` + +### Task 23: 数据库连接失败 + +- [ ] **Step 1: 用 mysql 客户端直连** + +```bash +mysql -h 121.40.97.156 -P 3306 -u <数据库用户名> -p -e "SELECT 1;" +``` + +Expected: + +```text +返回 1 +``` + +- [ ] **Step 2: 如果 MySQL 客户端通,但后端 health 不通** + +检查: + +```bash +grep MYSQL /opt/ForgeFlow-ERP/backend/.env +journalctl -u forgeflow-backend-dev -n 120 --no-pager +``` + +处理方向: + +```text +数据库名、账号、密码、账号 Host 权限、SQLAlchemy 连接字符串。 +``` + +--- + +## Phase 10: 临时阶段转正式生产阶段 + +临时测试稳定后,再做以下切换。 + +### Task 24: 正式化前端 + +- [ ] **Step 1: 生产构建** + +```bash +cd /opt/ForgeFlow-ERP/frontend +npm run build +``` + +Expected: + +```text +dist/ 构建成功 +``` + +- [ ] **Step 2: 用 1Panel OpenResty/Nginx 托管 dist** + +站点根目录: + +```text +/opt/ForgeFlow-ERP/frontend/dist +``` + +### Task 25: 正式化后端 + +- [ ] **Step 1: 后端改为只监听本机** + +正式阶段后端不再暴露公网端口: + +```text +127.0.0.1:8000 +``` + +- [ ] **Step 2: systemd 去掉 `--reload`** + +正式命令: + +```bash +/opt/ForgeFlow-ERP/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 +``` + +### Task 26: 正式化反向代理 + +- [ ] **Step 1: 配置 OpenResty/Nginx** + +核心规则: + +```nginx +location /api/ { + proxy_pass http://127.0.0.1:8000/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} + +location / { + try_files $uri $uri/ /index.html; +} +``` + +- [ ] **Step 2: 前端正式环境 API 改为相对路径** + +```env +VITE_API_BASE_URL=/api +``` + +### Task 27: 正式化安全策略 + +- [ ] **Step 1: 关闭临时公网端口** + +腾讯云安全组和服务器防火墙关闭: + +```text +5173/tcp +8000/tcp +``` + +只保留: + +```text +80/tcp +443/tcp +22/tcp +``` + +- [ ] **Step 2: 配置域名和 HTTPS** + +```text +ERP 正式访问地址:https://<正式域名> +``` + +- [ ] **Step 3: 数据库账号最小权限** + +正式账号只给 ERP 所需库权限: + +```sql +CREATE USER 'forgeflow_erp'@'<腾讯云公网IP>' IDENTIFIED BY '<强密码>'; +GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, REFERENCES +ON jiaheng_erp.* TO 'forgeflow_erp'@'<腾讯云公网IP>'; +FLUSH PRIVILEGES; +``` + +--- + +## Final Verification + +- [ ] 腾讯云服务器能访问阿里云 MySQL。 +- [ ] `http://<腾讯云公网IP>:8000/api/system/health` 返回后端和数据库正常。 +- [ ] `http://<腾讯云公网IP>:5173/login?redirect=/` 能打开登录页。 +- [ ] 登录后页面 API 请求没有 CORS 报错。 +- [ ] Excel 导入导出正常。 +- [ ] 基础资料、订单、采购、仓库、生产、发货页面都能打开。 +- [ ] 修改前端代码后 Vite 能热更新。 +- [ ] 修改后端代码后 Uvicorn 能自动 reload。 +- [ ] 测试期结束后有明确计划关闭公网 `5173` 和 `8000`。 + +--- + +## Recommended Execution Mode + +先用 `tmux` 跑通当天部署,再切 `systemd` 守护。 + +原因: + +```text +tmux 启动最快,排错最直观;确认前后端、数据库、跨域都通后,再用 systemd 固化。 +``` + +如果需要我来执行,建议按这个顺序: + +```text +1. 先要腾讯云公网 IP、SSH 登录方式、数据库测试账号。 +2. 执行 Phase 1 到 Phase 5,让系统先能访问。 +3. 执行 Phase 8 做业务验收。 +4. 验收没问题后执行 Phase 6,把进程挂 systemd。 +5. 等测试稳定后,再执行 Phase 10 转正式生产配置。 +``` diff --git a/docs/superpowers/plans/2026-06-06-return-rework-miniapp-closed-loop.md b/docs/superpowers/plans/2026-06-06-return-rework-miniapp-closed-loop.md new file mode 100644 index 0000000..7e13d74 --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-return-rework-miniapp-closed-loop.md @@ -0,0 +1,1571 @@ +# 退货返工小程序报工闭环 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将客户退货可返工品从 `退货库 -> 返工出库` 串到 `返工工单 -> 小程序报工 -> 成品库返工入库/废料库返工废料入库 -> 返工工单结单`,形成完整闭环。 + +**Architecture:** ERP 负责退货、返工工单、仓库回库和闭环结单;小程序继续复用现有扫码报工流程,只把原“原材料分批次号”选择项作为工单短码 `JHXXXX` 使用。返工工单不重新领原材料,返工成品/废料的来源追溯从原发货明细、退货明细、原成品批次和原材料库存批次链路继承。 + +**Tech Stack:** FastAPI, SQLAlchemy ORM, Pydantic schemas, Vue 3 Composition API, 微信小程序原生页面, pytest/unittest, Node.js static checks. + +--- + +## Scope Check + +本计划覆盖一个跨系统闭环,但每一部分都围绕同一个业务对象:`退货返工工单`。 + +本计划包含: + +- ERP 退货库返工出库自动生成可被小程序识别的返工工单。 +- 小程序工单短码 `JHXXXX` 下拉包含未结单返工工单。 +- ERP 同步小程序报工时能稳定绑定返工工单。 +- 成品库新增 `返工入库`,按返工工单最后工序良品数回库。 +- 废料库新增 `返工废料入库`,按返工工单报废数入废料库。 +- 返工工单只有在 `返工入库数量 + 返工废料入库数量` 覆盖返工出库数量后才能结单。 + +本计划不包含: + +- 不新增小程序独立返工页面。 +- 不让返工工单重新领原材料。 +- 不把退货返工当普通原材料生产工单处理。 +- 不引入新的数据库表;优先复用 `pp_work_order`、`rt_return_disposition`、`wh_stock_lot`、`wh_inventory_txn`、`pp_completion_receipt`。 + +当前工作目录 `/Users/souplearn/Gitlab/py/ForgeFlow-ERP` 不是 Git 仓库;本计划中的 commit 步骤在当前挂载目录执行时改为验证检查。如果在真实 Git worktree 中执行,应按任务提交。 + +--- + +## File Structure + +### ERP Backend + +- Modify `backend/app/services/operations.py` + - 新增共享工单号生成 helper。 + - 修改 `create_rework_work_order()`,让返工工单号符合 `20260001-产品名` 规则。 + - 扩展 `get_return_dispositions_query()`,返回返工工单、已报工、已回库、已入废料和剩余待处理数量。 + +- Modify `backend/app/api/routes/production.py` + - 普通生产工单和返工工单共用年度递增工单号规则。 + - 小程序同步报工继续支持 `JHXXXX` 绑定返工工单。 + - 成品入库支持返工工单:返工工单不要求原材料领用批次,来源追溯从退货/发货链路继承。 + - 成品返工入库使用清晰库存流水类型 `REWORK_FINISHED_IN`,中文展示为 `返工入库`。 + +- Modify `backend/app/api/routes/inventory.py` + - 新增废料库入库业务类型 `REWORK_SCRAP_IN`。 + - 按返工工单报废数控制可入废料数量。 + - 写入 `返工废料入库` 库存批次和流水。 + - 支持返工工单在成品回库和废料回库完成后结单。 + +- Modify `backend/app/schemas/operations.py` + - 增加字典值、响应字段和入库 payload 兼容字段。 + - `ReturnDispositionRead` 增加返工闭环展示字段。 + +- Modify `backend/tests/test_return_warehouse_flow.py` + - 增加退货返工工单、小程序短码、返工成品入库、返工废料入库、结单校验的后端测试。 + +### ERP Frontend + +- Modify `frontend/src/views/InventoryLedgerView.vue` + - 成品库入库区新增 `返工入库`。 + - 废料库入库区新增 `返工废料入库`。 + - 两个入口都必须先选择返工工单。 + - 展示可返工入库数量、可返工废料入库数量、已报工良品/废品、已回库数量、已入废料数量。 + +- Modify `frontend/src/utils/dictionaries.js` + - 增加 `REWORK_FINISHED_IN: "返工入库"`。 + - 增加 `REWORK_SCRAP_IN: "返工废料入库"`。 + +- Create `frontend/scripts/test-return-rework-closed-loop.mjs` + - 静态检查 ERP 前端是否出现新操作、中文字典、API payload 字段。 + +### Miniapp Backend + +- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reports.py` + - 保持现有 `/api/reports/draft` 流程。 + - 确认 `_raw_material_batch_options()` 返回普通工单和返工工单。 + - 排除已结单、取消、关闭的返工工单。 + - 可选增加接口响应中的显示名称,但本计划默认小程序仍显示 `JHXXXX`。 + +- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_work_order_batch_options.py` + - 增加返工工单短码下拉测试。 + +### Miniapp Frontend + +- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.wxml` + - 将“原材料分批次号”相关展示文案改为 `工单号` 或 `生产/返工工单号`。 + +- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.js` + - 保持字段名 `rawMaterialBatchNo` 不变,避免改动小程序后端和 ERP 同步字段。 + - 校验提示从“请选择原材料分批次号”改为“请选择工单号”。 + +--- + +## Task 1: ERP Backend Failing Tests + +**Files:** +- Modify: `backend/tests/test_return_warehouse_flow.py` + +- [ ] **Step 1: Add tests for rework work-order creation** + +Add this test method inside `ReturnWarehouseFlowTest`: + +```python + def test_return_rework_outbound_creates_miniapp_visible_rework_work_order(self) -> None: + from decimal import Decimal + + from app.api.routes.returns import create_return_order, create_return_rework_outbound + from app.models.operations import ReturnDisposition, WorkOrder + from app.schemas.operations import ReturnDispositionCreate, ReturnItemCreate, ReturnOrderCreate + + class Context: + user = type("User", (), {"id": 1})() + + seeded = self.seed_delivery_trace() + create_return_order( + ReturnOrderCreate( + customer_id=seeded["customer_id"], + delivery_id=seeded["delivery_id"], + warehouse_id=seeded["return_warehouse_id"], + return_reason="客户退回,可返工", + items=[ + ReturnItemCreate( + delivery_item_id=seeded["delivery_item_id"], + product_item_id=seeded["product_item_id"], + return_qty=10, + return_weight_kg=Decimal("25"), + ) + ], + ), + context=Context(), + db=self.db, + ) + + result = create_return_rework_outbound( + ReturnDispositionCreate(return_item_id=1, disposition_type="REWORK", extra_cost_amount=20, remark="返工出库"), + context=Context(), + db=self.db, + ) + + disposition = self.db.get(ReturnDisposition, result.return_disposition_id) + work_order = self.db.get(WorkOrder, disposition.rework_work_order_id) + + self.assertIsNotNone(work_order) + self.assertEqual(work_order.work_order_type, "REWORK") + self.assertEqual(work_order.product_item_id, seeded["product_item_id"]) + self.assertEqual(work_order.planned_qty, Decimal("10.000000")) + self.assertEqual(work_order.released_qty, Decimal("10.000000")) + self.assertRegex(work_order.work_order_no, r"^20\\d{2}\\d{4}-冲压支架$") + self.assertEqual(work_order.status, "RELEASED") +``` + +- [ ] **Step 2: Add test for finished rework inbound from miniapp reports** + +Add this test method inside `ReturnWarehouseFlowTest`: + +```python + def test_finished_rework_inbound_uses_rework_work_order_good_qty(self) -> None: + from decimal import Decimal + + from fastapi import HTTPException + + from app.api.routes.production import create_completion_receipt, create_operation_report + from app.api.routes.returns import create_return_order, create_return_rework_outbound + from app.models.operations import InventoryTxn, StockLot, WorkOrder, WorkOrderOperation + from app.schemas.operations import ( + CompletionReceiptCreate, + CompletionReceiptItemCreate, + OperationReportCreate, + ReturnDispositionCreate, + ReturnItemCreate, + ReturnOrderCreate, + ) + + class Context: + user = type("User", (), {"id": 1})() + + seeded = self.seed_delivery_trace() + create_return_order( + ReturnOrderCreate( + customer_id=seeded["customer_id"], + delivery_id=seeded["delivery_id"], + warehouse_id=seeded["return_warehouse_id"], + return_reason="客户退回,可返工", + items=[ReturnItemCreate(delivery_item_id=seeded["delivery_item_id"], product_item_id=seeded["product_item_id"], return_qty=10, return_weight_kg=Decimal("25"))], + ), + context=Context(), + db=self.db, + ) + disposition = create_return_rework_outbound( + ReturnDispositionCreate(return_item_id=1, disposition_type="REWORK", extra_cost_amount=20, remark="返工出库"), + context=Context(), + db=self.db, + ) + work_order = self.db.get(WorkOrder, disposition.rework_work_order_id) + final_operation = self.db.scalars( + select(WorkOrderOperation) + .where(WorkOrderOperation.work_order_id == work_order.id) + .order_by(WorkOrderOperation.seq_no.desc()) + ).first() + + create_operation_report( + OperationReportCreate( + work_order_id=work_order.id, + work_order_operation_id=final_operation.id, + employee_id=1, + item_id=seeded["product_item_id"], + report_qty=10, + good_qty=8, + scrap_qty=2, + ), + context=Context(), + db=self.db, + ) + + receipt = create_completion_receipt( + CompletionReceiptCreate( + work_order_id=work_order.id, + warehouse_id=seeded["finished_warehouse_id"], + settle_work_order=False, + remark="返工良品回库", + items=[ + CompletionReceiptItemCreate( + product_item_id=seeded["product_item_id"], + receipt_qty=8, + receipt_weight_kg=0, + ) + ], + ), + context=Context(), + db=self.db, + ) + + lot = self.db.scalar(select(StockLot).where(StockLot.source_doc_id == receipt.completion_receipt_id).order_by(StockLot.id.desc())) + txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == lot.id)) + + self.assertEqual(receipt.total_receipt_qty, 8) + self.assertEqual(lot.inbound_qty, Decimal("8.000000")) + self.assertEqual(lot.source_doc_type, "REWORK_RECEIPT") + self.assertIn("退货返工", lot.source_material_summary) + self.assertEqual(txn.txn_type, "REWORK_FINISHED_IN") + self.assertEqual(txn.amount_basis, "QTY") + + with self.assertRaises(HTTPException) as ctx: + create_completion_receipt( + CompletionReceiptCreate( + work_order_id=work_order.id, + warehouse_id=seeded["finished_warehouse_id"], + items=[CompletionReceiptItemCreate(product_item_id=seeded["product_item_id"], receipt_qty=1)], + ), + context=Context(), + db=self.db, + ) + self.assertEqual(ctx.exception.status_code, 400) + self.assertIn("返工工单当前没有可返工入库数量", ctx.exception.detail) +``` + +- [ ] **Step 3: Add test for rework scrap inbound and settlement** + +Add this test method inside `ReturnWarehouseFlowTest`: + +```python + def test_rework_scrap_inbound_plus_finished_inbound_can_settle_rework_order(self) -> None: + from decimal import Decimal + + from app.api.routes.inventory import create_warehouse_inbound + from app.api.routes.production import create_completion_receipt, create_operation_report + from app.api.routes.returns import create_return_order, create_return_rework_outbound + from app.models.operations import InventoryTxn, WorkOrder, WorkOrderOperation + from app.schemas.operations import ( + CompletionReceiptCreate, + CompletionReceiptItemCreate, + OperationReportCreate, + ReturnDispositionCreate, + ReturnItemCreate, + ReturnOrderCreate, + WarehouseInboundCreate, + ) + + class Context: + user = type("User", (), {"id": 1})() + + seeded = self.seed_delivery_trace() + create_return_order( + ReturnOrderCreate( + customer_id=seeded["customer_id"], + delivery_id=seeded["delivery_id"], + warehouse_id=seeded["return_warehouse_id"], + return_reason="客户退回,可返工", + items=[ReturnItemCreate(delivery_item_id=seeded["delivery_item_id"], product_item_id=seeded["product_item_id"], return_qty=10, return_weight_kg=Decimal("25"))], + ), + context=Context(), + db=self.db, + ) + disposition = create_return_rework_outbound( + ReturnDispositionCreate(return_item_id=1, disposition_type="REWORK", extra_cost_amount=20, remark="返工出库"), + context=Context(), + db=self.db, + ) + work_order = self.db.get(WorkOrder, disposition.rework_work_order_id) + final_operation = self.db.scalars( + select(WorkOrderOperation) + .where(WorkOrderOperation.work_order_id == work_order.id) + .order_by(WorkOrderOperation.seq_no.desc()) + ).first() + create_operation_report( + OperationReportCreate( + work_order_id=work_order.id, + work_order_operation_id=final_operation.id, + employee_id=1, + item_id=seeded["product_item_id"], + report_qty=10, + good_qty=8, + scrap_qty=2, + ), + context=Context(), + db=self.db, + ) + create_completion_receipt( + CompletionReceiptCreate( + work_order_id=work_order.id, + warehouse_id=seeded["finished_warehouse_id"], + items=[CompletionReceiptItemCreate(product_item_id=seeded["product_item_id"], receipt_qty=8)], + ), + context=Context(), + db=self.db, + ) + create_warehouse_inbound( + WarehouseInboundCreate( + biz_type="REWORK_SCRAP_IN", + item_id=seeded["product_item_id"], + warehouse_id=seeded["scrap_warehouse_id"], + source_doc_type="WORK_ORDER", + source_doc_id=work_order.id, + inbound_qty=2, + inbound_weight_kg=5, + settle_work_order=True, + settle_remark="返工良品和返工废料均已处理", + ), + context=Context(), + db=self.db, + ) + + self.db.refresh(work_order) + scrap_txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "REWORK_SCRAP_IN")) + + self.assertEqual(scrap_txn.qty_change, Decimal("2.000000")) + self.assertEqual(scrap_txn.weight_change_kg, Decimal("5.000000")) + self.assertEqual(work_order.status, "SETTLED") + self.assertIn("返工工单已结单", work_order.remark) +``` + +- [ ] **Step 4: Run tests and confirm they fail** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +. .venv/bin/activate +python -m pytest tests/test_return_warehouse_flow.py -k "rework" -v +``` + +Expected before implementation: + +```text +At least one test fails because rework work-order numbering, REWORK_FINISHED_IN, or REWORK_SCRAP_IN is not implemented yet. +``` + +--- + +## Task 2: Shared Work Order Numbering + +**Files:** +- Modify: `backend/app/services/operations.py` +- Modify: `backend/app/api/routes/production.py` +- Test: `backend/tests/test_return_warehouse_flow.py` + +- [ ] **Step 1: Add shared yearly work-order number helper** + +Add near `create_rework_work_order()` in `backend/app/services/operations.py`: + +```python +def build_yearly_product_work_order_no(db: Session, product: Item) -> str: + year_part = date.today().strftime("%Y") + existing_nos = db.scalars( + select(WorkOrder.work_order_no).where(WorkOrder.work_order_no.like(f"{year_part}____-%")) + ).all() + sequences = [ + int(work_order_no[4:8]) + for work_order_no in existing_nos + if len(work_order_no) > 8 + and work_order_no[:4] == year_part + and work_order_no[4:8].isdigit() + and work_order_no[8] == "-" + ] + next_sequence = (max(sequences) if sequences else 0) + 1 + return f"{year_part}{next_sequence:04d}-{product.item_name}" +``` + +- [ ] **Step 2: Use shared helper for rework work orders** + +Change `create_rework_work_order()`: + +```python +product = db.get(Item, product_item_id) +if not product: + raise HTTPException(status_code=404, detail="返工工单产品不存在") +work_order = WorkOrder( + work_order_no=build_yearly_product_work_order_no(db, product), + work_order_type="REWORK", + ... +) +``` + +- [ ] **Step 3: Use shared helper for normal work orders** + +In `backend/app/api/routes/production.py`, import: + +```python +from app.services.operations import build_yearly_product_work_order_no +``` + +Change `_build_work_order_no()`: + +```python +def _build_work_order_no(db: Session, product: Item) -> str: + return build_yearly_product_work_order_no(db, product) +``` + +- [ ] **Step 4: Run targeted test** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +. .venv/bin/activate +python -m pytest tests/test_return_warehouse_flow.py::ReturnWarehouseFlowTest::test_return_rework_outbound_creates_miniapp_visible_rework_work_order -v +``` + +Expected: + +```text +PASSED +``` + +--- + +## Task 3: Return Disposition Closed-Loop Query + +**Files:** +- Modify: `backend/app/services/operations.py` +- Modify: `backend/app/schemas/operations.py` +- Test: `backend/tests/test_return_warehouse_flow.py` + +- [ ] **Step 1: Extend `ReturnDispositionRead`** + +In `backend/app/schemas/operations.py`, add fields to `ReturnDispositionRead`: + +```python +product_item_id: int | None = None +product_code: str | None = None +product_name: str | None = None +return_qty: float = 0 +return_weight_kg: float = 0 +rework_report_good_qty: float = 0 +rework_report_scrap_qty: float = 0 +rework_finished_inbound_qty: float = 0 +rework_scrap_inbound_qty: float = 0 +remaining_rework_finished_inbound_qty: float = 0 +remaining_rework_scrap_inbound_qty: float = 0 +rework_close_ready: bool = False +rework_close_status: str | None = None +``` + +- [ ] **Step 2: Add aggregation subqueries in `get_return_dispositions_query()`** + +In `backend/app/services/operations.py`, add subqueries inside `get_return_dispositions_query()`: + +```python +report_subquery = ( + select( + OperationReport.work_order_id.label("work_order_id"), + func.coalesce(func.sum(OperationReport.good_qty), 0).label("rework_report_good_qty"), + func.coalesce(func.sum(OperationReport.scrap_qty), 0).label("rework_report_scrap_qty"), + ) + .group_by(OperationReport.work_order_id) + .subquery() +) + +finished_inbound_subquery = ( + select( + InventoryTxn.source_doc_id.label("work_order_id"), + func.coalesce(func.sum(InventoryTxn.qty_change), 0).label("rework_finished_inbound_qty"), + ) + .where(InventoryTxn.txn_type == "REWORK_FINISHED_IN") + .group_by(InventoryTxn.source_doc_id) + .subquery() +) + +scrap_inbound_subquery = ( + select( + InventoryTxn.source_doc_id.label("work_order_id"), + func.coalesce(func.sum(InventoryTxn.qty_change), 0).label("rework_scrap_inbound_qty"), + ) + .where(InventoryTxn.txn_type == "REWORK_SCRAP_IN") + .group_by(InventoryTxn.source_doc_id) + .subquery() +) +``` + +Join those subqueries on `ReturnDisposition.rework_work_order_id`. + +- [ ] **Step 3: Return derived Chinese status** + +Add selected labels: + +```python +(func.coalesce(report_subquery.c.rework_report_good_qty, 0)).label("rework_report_good_qty"), +(func.coalesce(report_subquery.c.rework_report_scrap_qty, 0)).label("rework_report_scrap_qty"), +(func.coalesce(finished_inbound_subquery.c.rework_finished_inbound_qty, 0)).label("rework_finished_inbound_qty"), +(func.coalesce(scrap_inbound_subquery.c.rework_scrap_inbound_qty, 0)).label("rework_scrap_inbound_qty"), +case( + ( + func.coalesce(finished_inbound_subquery.c.rework_finished_inbound_qty, 0) + + func.coalesce(scrap_inbound_subquery.c.rework_scrap_inbound_qty, 0) + >= ReturnItem.return_qty, + "可结单", + ), + ( + func.coalesce(finished_inbound_subquery.c.rework_finished_inbound_qty, 0) + + func.coalesce(scrap_inbound_subquery.c.rework_scrap_inbound_qty, 0) + > 0, + "部分处理", + ), + else_="待处理", +).label("rework_close_status"), +``` + +- [ ] **Step 4: Run return disposition query tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +. .venv/bin/activate +python -m pytest tests/test_return_warehouse_flow.py -k "rework" -v +``` + +Expected after Task 3: + +```text +The work-order creation test passes; inbound-related tests still fail until Tasks 4 and 5. +``` + +--- + +## Task 4: Finished Rework Inbound + +**Files:** +- Modify: `backend/app/api/routes/production.py` +- Modify: `backend/app/services/operations.py` +- Modify: `backend/app/schemas/operations.py` +- Test: `backend/tests/test_return_warehouse_flow.py` + +- [ ] **Step 1: Add helper to detect rework work orders** + +In `backend/app/api/routes/production.py`, add: + +```python +def _is_rework_work_order(work_order: WorkOrder) -> bool: + return str(work_order.work_order_type or "").upper() == "REWORK" +``` + +- [ ] **Step 2: Add helper to build source trace from return disposition** + +Add: + +```python +def _build_rework_source_material_summary(db: Session, work_order_id: int) -> dict[str, object | None]: + row = db.execute( + select( + ReturnDisposition.id.label("return_disposition_id"), + ReturnItem.source_lot_id.label("source_lot_id"), + StockLot.source_material_lot_id.label("source_material_lot_id"), + StockLot.source_material_sub_batch_no.label("source_material_sub_batch_no"), + StockLot.source_material_summary.label("source_material_summary"), + StockLot.unit_cost.label("unit_cost"), + ) + .join(ReturnItem, ReturnItem.id == ReturnDisposition.return_item_id) + .outerjoin(StockLot, StockLot.id == ReturnItem.source_lot_id) + .where(ReturnDisposition.rework_work_order_id == work_order_id) + .limit(1) + ).mappings().first() + if not row: + raise HTTPException(status_code=400, detail="该返工工单缺少退货返工来源,不能返工入库") + source_material_sub_batch_no = row["source_material_sub_batch_no"] or "退货返工来源批次未指定" + source_material_summary = row["source_material_summary"] or "退货返工来源" + return { + "source_doc_type": "RETURN_DISPOSITION", + "source_doc_id": row["return_disposition_id"], + "source_material_lot_id": row["source_material_lot_id"], + "source_material_sub_batch_no": source_material_sub_batch_no, + "source_material_summary": f"退货返工;{source_material_summary}", + "source_material_unit_cost": to_decimal(row["unit_cost"], "0.0001"), + } +``` + +- [ ] **Step 3: Branch `create_completion_receipt()` for rework** + +In `create_completion_receipt()`, replace: + +```python +source_material = _build_work_order_source_material_summary(db, payload.work_order_id) +if not source_material["source_material_sub_batch_no"]: + raise HTTPException(status_code=400, detail="该工单没有原材料库存批次领用记录,不能进行成品入库") +``` + +with: + +```python +if _is_rework_work_order(work_order): + source_material = _build_rework_source_material_summary(db, payload.work_order_id) +else: + source_material = _build_work_order_source_material_summary(db, payload.work_order_id) + if not source_material["source_material_sub_batch_no"]: + raise HTTPException(status_code=400, detail="该工单没有原材料库存批次领用记录,不能进行成品入库") +``` + +- [ ] **Step 4: Use rework-specific source doc and transaction type** + +Inside the completion receipt item loop, set: + +```python +is_rework = _is_rework_work_order(work_order) +stock_source_doc_type = "REWORK_RECEIPT" if is_rework else "FG_RECEIPT" +txn_type = "REWORK_FINISHED_IN" if is_rework else "FG_IN" +txn_remark = "返工入库" if is_rework else "完工入库" +``` + +Use those values when creating `StockLot` and `InventoryTxn`: + +```python +source_doc_type=stock_source_doc_type, +... +txn_type=txn_type, +... +remark=txn_remark, +source_doc_type="WORK_ORDER" if is_rework else "FG_RECEIPT", +source_doc_id=work_order.id if is_rework else completion.id, +``` + +- [ ] **Step 5: Add rework max inbound guard** + +Before creating `CompletionReceipt`, after `max_receivable_qty`: + +```python +if _is_rework_work_order(work_order) and total_receipt_qty > max_receivable_qty: + raise HTTPException(status_code=400, detail=f"返工入库数量不能超过当前可返工入库数量 {max_receivable_qty}") +``` + +The existing `validate_inbound_with_tolerance()` can stay for normal production. For rework use exact upper limit: + +```python +if _is_rework_work_order(work_order): + if total_receipt_qty > max_receivable_qty: + raise HTTPException(status_code=400, detail=f"返工入库数量不能超过当前可返工入库数量 {max_receivable_qty}") +else: + validate_inbound_with_tolerance("成品入库数量", total_receipt_qty, max_receivable_qty) +``` + +- [ ] **Step 6: Run finished rework inbound test** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +. .venv/bin/activate +python -m pytest tests/test_return_warehouse_flow.py::ReturnWarehouseFlowTest::test_finished_rework_inbound_uses_rework_work_order_good_qty -v +``` + +Expected: + +```text +PASSED +``` + +--- + +## Task 5: Rework Scrap Inbound + +**Files:** +- Modify: `backend/app/api/routes/inventory.py` +- Modify: `backend/app/schemas/operations.py` +- Modify: `frontend/src/utils/dictionaries.js` +- Test: `backend/tests/test_return_warehouse_flow.py` + +- [ ] **Step 1: Add inbound biz type** + +In `backend/app/schemas/operations.py`, add `REWORK_SCRAP_IN` to `WAREHOUSE_INBOUND_BIZ_TYPES`: + +```python +"REWORK_SCRAP_IN", # 返工废料入库(废料库) +``` + +- [ ] **Step 2: Add inventory route mapping** + +In `backend/app/api/routes/inventory.py`, update `_INBOUND_BIZ_MAP`: + +```python +"REWORK_SCRAP_IN": ("REWORK_SCRAP", "REWORK_SCRAP_IN", "REWORK_SCRAP_IN", None), +``` + +Update `_ALLOWED_INBOUND_BY_WAREHOUSE_TYPE`: + +```python +"SCRAP": {"PRODUCTION_SCRAP_IN", "OPENING", "OUTSOURCING_SCRAP_IN", "RETURN_SCRAP_IN", "REWORK_SCRAP_IN"}, +``` + +Update `_INVENTORY_BIZ_TYPE_LABELS`: + +```python +"REWORK_SCRAP_IN": "返工废料入库", +``` + +- [ ] **Step 3: Add helper to validate rework scrap inbound** + +In `backend/app/api/routes/inventory.py`, add: + +```python +def _get_rework_work_order_for_scrap_inbound(db: Session, payload: WarehouseInboundCreate) -> WorkOrder: + if str(payload.source_doc_type or "").upper() != "WORK_ORDER" or not payload.source_doc_id: + raise HTTPException(status_code=400, detail="返工废料入库请选择返工工单") + work_order = db.get(WorkOrder, int(payload.source_doc_id)) + if not work_order: + raise HTTPException(status_code=404, detail="返工工单不存在") + if str(work_order.work_order_type or "").upper() != "REWORK": + raise HTTPException(status_code=400, detail="返工废料入库只能选择返工工单") + if str(work_order.status or "").upper() == "SETTLED": + raise HTTPException(status_code=400, detail="该返工工单已结单,不能继续入废料库") + return work_order +``` + +- [ ] **Step 4: Add max rework scrap inbound calculation** + +Add: + +```python +def _rework_scrap_inbound_qty(db: Session, work_order_id: int) -> Decimal: + return to_decimal( + db.scalar( + select(func.coalesce(func.sum(InventoryTxn.qty_change), 0)).where( + InventoryTxn.txn_type == "REWORK_SCRAP_IN", + InventoryTxn.source_doc_type == "WORK_ORDER", + InventoryTxn.source_doc_id == work_order_id, + ) + ), + "0.000001", + ) + + +def _rework_report_scrap_qty(db: Session, work_order_id: int) -> Decimal: + return to_decimal( + db.scalar( + select(func.coalesce(func.sum(OperationReport.scrap_qty), 0)).where(OperationReport.work_order_id == work_order_id) + ), + "0.000001", + ) +``` + +- [ ] **Step 5: Validate and route `REWORK_SCRAP_IN`** + +Inside `create_warehouse_inbound()` after `biz_type` normalization: + +```python +rework_scrap_work_order = None +if biz_type == "REWORK_SCRAP_IN": + rework_scrap_work_order = _get_rework_work_order_for_scrap_inbound(db, payload) + reported_scrap_qty = _rework_report_scrap_qty(db, rework_scrap_work_order.id) + already_scrap_inbound_qty = _rework_scrap_inbound_qty(db, rework_scrap_work_order.id) + max_rework_scrap_qty = reported_scrap_qty - already_scrap_inbound_qty + if inbound_qty <= 0: + raise HTTPException(status_code=400, detail="返工废料入库数量必须大于0") + if inbound_qty > max_rework_scrap_qty: + raise HTTPException(status_code=400, detail=f"返工废料入库数量不能超过当前可入库废品数量 {max_rework_scrap_qty}") + if inbound_weight <= 0: + product = db.get(Item, payload.item_id) + inbound_weight = inbound_qty * to_decimal(product.unit_weight_kg if product else 0, "0.000001") +``` + +Before `StockLot` creation, set: + +```python +if biz_type == "REWORK_SCRAP_IN": + operation_source_doc_type = "WORK_ORDER" + operation_source_doc_id = rework_scrap_work_order.id + source_material_summary = payload.source_material_summary or f"返工废料入库;工单:{rework_scrap_work_order.work_order_no}" +``` + +- [ ] **Step 6: Set `amount_basis` to quantity for rework scrap** + +In `create_inventory_txn()` call: + +```python +amount_basis="QTY" if (is_aux_quantity_inbound or is_finished_outsourcing_quantity_inbound or biz_type == "REWORK_SCRAP_IN") else "WEIGHT", +``` + +- [ ] **Step 7: Run rework scrap inbound test** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +. .venv/bin/activate +python -m pytest tests/test_return_warehouse_flow.py::ReturnWarehouseFlowTest::test_rework_scrap_inbound_plus_finished_inbound_can_settle_rework_order -v +``` + +Expected: + +```text +PASSED +``` + +--- + +## Task 6: Rework Work Order Settlement Rule + +**Files:** +- Modify: `backend/app/api/routes/inventory.py` +- Modify: `backend/app/api/routes/production.py` +- Test: `backend/tests/test_return_warehouse_flow.py` + +- [ ] **Step 1: Add helper to summarize rework closure** + +Add this helper to `backend/app/api/routes/inventory.py` and duplicate or import it in `backend/app/api/routes/production.py` if needed: + +```python +def _rework_processed_qty(db: Session, work_order_id: int) -> Decimal: + finished_qty = to_decimal( + db.scalar( + select(func.coalesce(func.sum(InventoryTxn.qty_change), 0)).where( + InventoryTxn.txn_type == "REWORK_FINISHED_IN", + InventoryTxn.source_doc_type == "WORK_ORDER", + InventoryTxn.source_doc_id == work_order_id, + ) + ), + "0.000001", + ) + scrap_qty = to_decimal( + db.scalar( + select(func.coalesce(func.sum(InventoryTxn.qty_change), 0)).where( + InventoryTxn.txn_type == "REWORK_SCRAP_IN", + InventoryTxn.source_doc_type == "WORK_ORDER", + InventoryTxn.source_doc_id == work_order_id, + ) + ), + "0.000001", + ) + return finished_qty + scrap_qty +``` + +- [ ] **Step 2: Add strict closure helper** + +```python +def _settle_rework_work_order_if_ready(db: Session, work_order: WorkOrder, settle_remark: str | None) -> None: + planned_qty = to_decimal(work_order.planned_qty) + processed_qty = _rework_processed_qty(db, work_order.id) + if processed_qty < planned_qty: + raise HTTPException( + status_code=400, + detail=f"返工工单尚未闭环:返工出库 {planned_qty} 件,已回库/入废料 {processed_qty} 件", + ) + work_order.status = "SETTLED" + work_order.actual_end_time = work_order.actual_end_time or datetime.now() + parts = [part.strip() for part in str(work_order.remark or "").replace(";", ";").split(";") if part.strip()] + if "返工工单已结单" not in parts: + parts.append("返工工单已结单") + remark = str(settle_remark or "").strip() + if remark: + parts.append(f"返工结单说明:{remark}") + work_order.remark = ";".join(dict.fromkeys(parts))[-255:] + db.add(work_order) +``` + +- [ ] **Step 3: Apply settlement in finished rework inbound** + +In `create_completion_receipt()`, replace direct `SETTLED` assignment for rework: + +```python +if payload.settle_work_order: + if _is_rework_work_order(work_order): + _settle_rework_work_order_if_ready(db, work_order, payload.settle_remark) + else: + work_order.status = "SETTLED" + work_order.actual_end_time = work_order.actual_end_time or datetime.now() + work_order.updated_at = datetime.now() + _append_finished_work_order_settle_remark(work_order, payload.settle_remark) + db.add(work_order) +``` + +- [ ] **Step 4: Apply settlement in rework scrap inbound** + +After creating `REWORK_SCRAP_IN` inventory transaction: + +```python +if biz_type == "REWORK_SCRAP_IN" and payload.settle_work_order and rework_scrap_work_order is not None: + _settle_rework_work_order_if_ready(db, rework_scrap_work_order, payload.settle_remark) +``` + +- [ ] **Step 5: Run all return rework tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +. .venv/bin/activate +python -m pytest tests/test_return_warehouse_flow.py -k "rework" -v +``` + +Expected: + +```text +All rework tests pass. +``` + +--- + +## Task 7: Miniapp Backend Work Order Options + +**Files:** +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reports.py` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_work_order_batch_options.py` + +- [ ] **Step 1: Add miniapp backend test for rework work orders** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_work_order_batch_options.py`, first extend the in-memory test table so the test can represent normal and rework work orders: + +```python + create table pp_work_order ( + id integer primary key, + work_order_no text, + work_order_type text, + product_item_id integer, + status text + ) +``` + +Then add: + +```python +def test_raw_material_batch_options_returns_rework_work_order_short_code(monkeypatch): + class FrozenDateTime(datetime): + @classmethod + def now(cls, tz=None): # noqa: ANN001 + _ = tz + return cls(2026, 6, 2, 10, 0, 0) + + monkeypatch.setattr(reports, "datetime", FrozenDateTime) + db = _session() + try: + db.execute(text("insert into md_item (id, item_code, item_name) values (1, 'FG-001', '钢制碗')")) + db.execute( + text( + """ + insert into pp_work_order (id, work_order_no, work_order_type, product_item_id, status) + values (1, '20260001-钢制碗', 'REWORK', 1, 'RELEASED') + """ + ) + ) + db.execute( + text("insert into pp_work_order_operation (id, work_order_id, operation_name) values (1, 1, '返工开平')") + ) + db.commit() + + options = reports._raw_material_batch_options( + db, + project_no="FG-001", + product_name="钢制碗", + process_name="返工开平", + ) + + assert options == ["JH0001"] + finally: + db.close() +``` + +- [ ] **Step 2: Keep query inclusive of normal and rework work orders** + +Confirm `_raw_material_batch_options()` does not filter out `work_order_type='REWORK'`. The SQL should continue to filter by work-order number, status, product, and process only: + +```sql +and upper(coalesce(wo.status, '')) not in ('SETTLED', 'CANCELED', 'CANCELLED') +and (:process_name = '' or op.operation_name = :process_name) +``` + +- [ ] **Step 3: Add explicit order by available JH code** + +Keep: + +```sql +order by wo.work_order_no asc +``` + +This gives `JH0001, JH0002, JH0003...`. + +- [ ] **Step 4: Run miniapp backend tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +pytest tests/test_work_order_batch_options.py -v +``` + +Expected: + +```text +All tests pass, including the new rework short-code test. +``` + +--- + +## Task 8: Miniapp Frontend Label Rename + +**Files:** +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.wxml` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.js` + +- [ ] **Step 1: Rename visible label in WXML** + +Search: + +```bash +rg -n "原材料分批次号|rawMaterialBatch" /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm +``` + +Replace visible text: + +```text +原材料分批次号 +``` + +with: + +```text +工单号 +``` + +Keep bound fields unchanged: + +```text +rawMaterialBatchNo +rawMaterialBatchOptions +rawMaterialBatchIndex +``` + +- [ ] **Step 2: Rename validation toast** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.js`, change: + +```javascript +title: `${blockDisplay(block)}请选择原材料分批次号`, +``` + +to: + +```javascript +title: `${blockDisplay(block)}请选择工单号`, +``` + +- [ ] **Step 3: Static check** + +Run: + +```bash +rg -n "原材料分批次号" /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm +``` + +Expected: + +```text +No matches in reportForm visible UI files. +``` + +--- + +## Task 9: ERP Frontend Inventory Operations + +**Files:** +- Modify: `frontend/src/views/InventoryLedgerView.vue` +- Modify: `frontend/src/utils/dictionaries.js` +- Create: `frontend/scripts/test-return-rework-closed-loop.mjs` + +- [ ] **Step 1: Add dictionary labels** + +In `frontend/src/utils/dictionaries.js`, update `INVENTORY_TXN_TYPE_LABELS`: + +```javascript +REWORK_FINISHED_IN: "返工入库", +REWORK_SCRAP_IN: "返工废料入库", +``` + +Update `SOURCE_DOC_TYPE_LABELS`: + +```javascript +REWORK_RECEIPT: "返工入库", +RETURN_DISPOSITION: "退货返工处置", +``` + +- [ ] **Step 2: Add inventory operation buttons** + +In `frontend/src/views/InventoryLedgerView.vue`, update operation matrix: + +```javascript +finished: { + inbound: [ + makeOperation("finished-production-in", "in", "PRODUCTION_FINISHED", "生产入库", "生产完工成品入库,并生成成品批次。", "completion"), + makeOperation("finished-rework-in", "in", "REWORK_FINISHED_IN", "返工入库", "退货返工完成后的成品回库。", "reworkCompletion"), + makeOperation("finished-outsourcing-in", "in", "OUTSOURCING_IN", "委外入库", "委外加工完成后的成品入库。"), + makeOperation("finished-opening-in", "in", "OPENING", "期初入库", "仓库初始化时导入的初始成品。") + ], + outbound: [ + makeOperation("finished-sales-out", "out", "SALES_OUT", "销售出库", "按销售订单进行成品发货出库。", "delivery") + ] +}, +scrap: { + inbound: [ + makeOperation("scrap-production-in", "in", "PRODUCTION_SCRAP_IN", "生产废料入库", "生产过程产生的废料入库。"), + makeOperation("scrap-rework-in", "in", "REWORK_SCRAP_IN", "返工废料入库", "退货返工失败产生的废品入废料库。", "reworkScrapInbound"), + makeOperation("scrap-opening-in", "in", "OPENING", "期初入库", "仓库初始化时导入的初始废料。"), + makeOperation("scrap-outsourcing-in", "in", "OUTSOURCING_SCRAP_IN", "委外废料入库", "委外加工产生的废料入库。"), + makeOperation("scrap-return-in", "in", "RETURN_SCRAP_IN", "退货废料入库", "客户退回后判定报废的产品直接进入废料库。", "returnScrapInbound") + ], + outbound: [ + makeOperation("scrap-sale-out", "out", "SCRAP_SALE_OUT", "售卖出库", "废料称重售卖出库。") + ] +} +``` + +- [ ] **Step 3: Add computed lists for rework work orders** + +Use existing `workOrders` data source if present. Add: + +```javascript +const reworkWorkOrders = computed(() => + workOrders.value.filter((workOrder) => String(workOrder.work_order_type || "").toUpperCase() === "REWORK") +); + +const reworkFinishedInboundOptions = computed(() => + reworkWorkOrders.value.filter((workOrder) => Number(workOrder.max_finished_inbound_qty || workOrder.limits?.max_finished_inbound_qty || 0) > 0) +); + +const reworkScrapInboundOptions = computed(() => + reworkWorkOrders.value.filter((workOrder) => Number(workOrder.max_rework_scrap_inbound_qty || workOrder.limits?.max_rework_scrap_inbound_qty || 0) > 0) +); +``` + +- [ ] **Step 4: Route `reworkCompletion` drawer to existing completion form** + +When opening `finished-rework-in`, reuse the completion drawer but change title and filter: + +```javascript +if (operation.drawerType === "reworkCompletion") { + activeOperation.value = operation; + resetCompletionForm(); + completionDrawerOpen.value = true; + return; +} +``` + +In the completion work-order select, use: + +```javascript +const completionWorkOrderOptions = computed(() => + activeOperation.value?.drawerType === "reworkCompletion" + ? reworkFinishedInboundOptions.value + : productionCompletionWorkOrderOptions.value +); +``` + +- [ ] **Step 5: Submit rework completion to existing completion endpoint** + +Keep using: + +```javascript +postResource("/production/completion-receipts", payload) +``` + +Payload remains: + +```javascript +{ + work_order_id: Number(completionForm.work_order_id), + warehouse_id: Number(completionForm.warehouse_id), + settle_work_order: Boolean(completionForm.settle_work_order), + settle_remark: completionForm.settle_remark || null, + items: [ + { + product_item_id: Number(completionForm.product_item_id), + receipt_qty: Number(completionForm.receipt_qty || 0), + receipt_weight_kg: 0 + } + ] +} +``` + +Backend determines `REWORK_FINISHED_IN` from `work_order_type`. + +- [ ] **Step 6: Submit rework scrap inbound** + +For `reworkScrapInbound`, submit: + +```javascript +await postResource("/inventory/inbound", { + biz_type: "REWORK_SCRAP_IN", + item_id: Number(genericForm.item_id), + warehouse_id: Number(genericForm.warehouse_id), + source_doc_type: "WORK_ORDER", + source_doc_id: Number(genericForm.work_order_id), + inbound_qty: Number(genericForm.inbound_qty || 0), + inbound_weight_kg: Number(genericForm.inbound_weight_kg || 0), + settle_work_order: Boolean(genericForm.settle_work_order), + settle_remark: genericForm.settle_remark || null, + remark: genericForm.remark || null +}); +``` + +- [ ] **Step 7: Add static guard script** + +Create `frontend/scripts/test-return-rework-closed-loop.mjs`: + +```javascript +import { readFileSync } from "node:fs"; + +const inventory = readFileSync(new URL("../src/views/InventoryLedgerView.vue", import.meta.url), "utf8"); +const dictionaries = readFileSync(new URL("../src/utils/dictionaries.js", import.meta.url), "utf8"); + +const requiredInventorySnippets = [ + "REWORK_FINISHED_IN", + "返工入库", + "REWORK_SCRAP_IN", + "返工废料入库", + "reworkCompletion", + "reworkScrapInbound" +]; + +const requiredDictionarySnippets = [ + "REWORK_FINISHED_IN: \"返工入库\"", + "REWORK_SCRAP_IN: \"返工废料入库\"" +]; + +for (const snippet of requiredInventorySnippets) { + if (!inventory.includes(snippet)) { + throw new Error(`InventoryLedgerView.vue missing ${snippet}`); + } +} + +for (const snippet of requiredDictionarySnippets) { + if (!dictionaries.includes(snippet)) { + throw new Error(`dictionaries.js missing ${snippet}`); + } +} + +console.log("return rework closed-loop UI guard passed"); +``` + +- [ ] **Step 8: Run frontend checks** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node scripts/test-return-rework-closed-loop.mjs +npm run build +``` + +Expected: + +```text +return rework closed-loop UI guard passed +vite build exits 0 +``` + +--- + +## Task 10: ERP Sync Miniapp Report Verification + +**Files:** +- Modify: `backend/tests/test_return_warehouse_flow.py` +- Verify: `backend/app/api/routes/production.py` + +- [ ] **Step 1: Add test for miniapp `JHXXXX` resolving rework order** + +Add: + +```python + def test_miniapp_jh_short_code_binds_to_rework_work_order(self) -> None: + from decimal import Decimal + + from app.api.routes.production import _new_miniapp_resolve_cache, _sync_miniapp_item_to_erp + from app.api.routes.returns import create_return_order, create_return_rework_outbound + from app.models.operations import OperationReport, WorkOrder + from app.schemas.operations import ReturnDispositionCreate, ReturnItemCreate, ReturnOrderCreate + + class Context: + user = type("User", (), {"id": 1})() + + seeded = self.seed_delivery_trace() + create_return_order( + ReturnOrderCreate( + customer_id=seeded["customer_id"], + delivery_id=seeded["delivery_id"], + warehouse_id=seeded["return_warehouse_id"], + return_reason="客户退回,可返工", + items=[ReturnItemCreate(delivery_item_id=seeded["delivery_item_id"], product_item_id=seeded["product_item_id"], return_qty=10, return_weight_kg=Decimal("25"))], + ), + context=Context(), + db=self.db, + ) + disposition = create_return_rework_outbound( + ReturnDispositionCreate(return_item_id=1, disposition_type="REWORK", remark="返工出库"), + context=Context(), + db=self.db, + ) + work_order = self.db.get(WorkOrder, disposition.rework_work_order_id) + short_code = f"JH{int(work_order.work_order_no[4:8]):04d}" + row = { + "source_report_id": 9001, + "source_item_id": 900101, + "project_no": "产品需规00001", + "product_name": "冲压支架", + "process_name": "开平", + "raw_material_batch_no": short_code, + "employee_phone": "13800000001", + "employee_name": "返工员工", + "attendance_point_name": "", + "good_qty": 8, + "defect_qty": 0, + "miniapp_scrap_qty": 2, + "standard_beat": 10, + "started_at": datetime.now(UTC), + "end_time": datetime.now(UTC), + "status": "approved", + } + + report = _sync_miniapp_item_to_erp( + self.db, + int(row["source_item_id"]), + required=True, + row=row, + cache=_new_miniapp_resolve_cache(), + ) + + saved = self.db.get(OperationReport, report.id) + self.assertEqual(saved.work_order_id, work_order.id) + self.assertEqual(saved.good_qty, Decimal("8.000000")) + self.assertEqual(saved.scrap_qty, Decimal("2.000000")) +``` + +- [ ] **Step 2: Run sync test** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +. .venv/bin/activate +python -m pytest tests/test_return_warehouse_flow.py -k "miniapp_jh_short_code" -v +``` + +Expected: + +```text +PASSED +``` + +--- + +## Task 11: End-to-End Backend Verification + +**Files:** +- Verify backend suite. + +- [ ] **Step 1: Run ERP return and production tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +. .venv/bin/activate +python -m pytest tests/test_return_warehouse_flow.py tests/test_production_work_order_flow.py -v +``` + +Expected: + +```text +All selected tests pass. +``` + +- [ ] **Step 2: Run miniapp backend tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +pytest tests/test_work_order_batch_options.py tests/test_metrics.py -v +``` + +Expected: + +```text +All selected tests pass. +``` + +- [ ] **Step 3: Run frontend build** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +npm run build +``` + +Expected: + +```text +vite build exits 0. +``` + +--- + +## Task 12: Manual Acceptance Checklist + +**Files:** +- No code changes. + +- [ ] **Step 1: ERP creates rework order** + +Manual flow: + +```text +成品库销售出库 +-> 退货库退货入库 +-> 退货库返工出库 +``` + +Expected: + +```text +工单台账出现一张 work_order_type=REWORK 的返工工单。 +工单号格式为 20260001-产品名。 +退货返工处置记录能看到返工工单号。 +``` + +- [ ] **Step 2: Miniapp sees rework order** + +Manual flow: + +```text +小程序扫码同一产品工序二维码 +-> 报工页面 +-> 工单号下拉 +``` + +Expected: + +```text +下拉中出现 JH0001。 +已结单的返工工单不出现。 +``` + +- [ ] **Step 3: Miniapp report syncs to ERP** + +Manual flow: + +```text +小程序选择 JH0001 +-> 填写良品 8,废品 2 +-> 提交并审核 +-> ERP 工序报工同步 +``` + +Expected: + +```text +ERP 工单台账中返工工单累计良品 8、废品 2。 +普通生产工单数据不被串入该返工工单。 +``` + +- [ ] **Step 4: Finished rework inbound** + +Manual flow: + +```text +嘉恒仓库 -> 成品库 -> 返工入库 +-> 选择返工工单 JH0001 对应工单 +-> 入库 8 件 +``` + +Expected: + +```text +成品库存增加 8 件。 +库存流水显示“返工入库”。 +成品批次追溯到原销售/原发货/原材料库存批次。 +``` + +- [ ] **Step 5: Rework scrap inbound and close** + +Manual flow: + +```text +嘉恒仓库 -> 废料库 -> 返工废料入库 +-> 选择同一返工工单 +-> 入库 2 件对应废料重量 +-> 勾选结单 +``` + +Expected: + +```text +废料库存增加。 +库存流水显示“返工废料入库”。 +返工工单状态变为已结单。 +小程序 JH0001 不再显示。 +``` + +--- + +## Self-Review Checklist + +- [ ] 退货可返工品有入口:`退货库 · 退货入库`。 +- [ ] 退货返工出库会生成 `work_order_type=REWORK` 工单。 +- [ ] 返工工单号符合 `20260001-产品名`,小程序可转 `JH0001`。 +- [ ] 小程序不新增返工页面,只改文案和测试。 +- [ ] ERP 同步小程序报工能用 `JHXXXX` 匹配返工工单。 +- [ ] 成品库返工入库按返工工单最后工序良品数控制。 +- [ ] 废料库返工废料入库按返工工单废品数控制。 +- [ ] 返工工单结单要求良品回库 + 废品入废料库覆盖返工出库数量。 +- [ ] 返工入库不要求原材料领用记录。 +- [ ] 所有新增展示值为中文,不展示 `REWORK_FINISHED_IN` 或 `REWORK_SCRAP_IN` 给用户。 + +--- + +## Recommended Execution Mode + +推荐使用 **Inline Execution**。 + +原因: + +```text +这次改动跨 ERP 后端、ERP 前端、小程序后端、小程序前端,多个任务共享同一条返工闭环上下文。Inline Execution 更适合连续验证,不容易让子任务之间对业务口径产生偏差。 +``` + +执行时按顺序推进: + +```text +1. 后端测试先行。 +2. 工单号和返工工单生成。 +3. 小程序工单短码验证。 +4. 成品返工入库。 +5. 返工废料入库。 +6. ERP 前端入口。 +7. 小程序文案。 +8. 全链路手工验收。 +``` diff --git a/docs/superpowers/plans/2026-06-06-special-warehouse-adjustment.md b/docs/superpowers/plans/2026-06-06-special-warehouse-adjustment.md new file mode 100644 index 0000000..1645512 --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-special-warehouse-adjustment.md @@ -0,0 +1,1790 @@ +# Special Warehouse Adjustment Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 为嘉恒仓库 6 大库的入库与出库操作区分别增加“特殊入库”“特殊出库”,允许在强制填写说明后,精确到库存明细行进行受控库存调整,并把说明写入所有变更记录。 + +**Architecture:** 新增独立的“特殊调整”后端服务与审计主/明细表,库存变动仍复用现有 `wh_stock_lot`、`wh_stock_balance`、`wh_inventory_txn` 三层账本。前端在现有 `InventoryLedgerView.vue` 的仓库出入库操作矩阵中增加特殊操作入口,打开专用抽屉完成说明填写、明细选择、调整前后预览和提交。 + +**Tech Stack:** FastAPI, SQLAlchemy ORM, MySQL 8, SQLite unit tests, Vue 3, Vite, Node.js static UI scripts. + +--- + +## Scope And Invariants + +本计划只实现“特殊入库/特殊出库”,不改变正常采购、销售、生产、盘库链路。 + +必须保持这些业务不变量: + +- 原材料库:只按重量调整,精确到原材料库存批次号。 +- 半成品库:按该半成品库存既有口径调整,按件库存只能调数量,按重库存只能调重量。 +- 成品库:主维度按数量调整,重量作为辅助字段随同记录。 +- 辅料库:只按数量调整。 +- 废料库:只按重量调整,并保留来源原材料库存批次追溯展示。 +- 退货库:按退货品库存明细调整,保留发货来源、来源原材料库存批次追溯。 +- 特殊出库不能超过所选明细当前可用量。 +- 盘库锁库期间禁止特殊入库和特殊出库。 +- 操作说明必填,且必须写入特殊调整主单、明细、库存流水 `remark`。 +- 特殊调整不联动采购订单、销售订单、生产工单状态,避免污染正常业务状态。 +- 用户界面展示必须使用中文业务名,不显示英文状态或英文标志。 + +--- + +## File Structure + +Create: + +- `backend/sql/add_special_warehouse_adjustment_patch.sql` + - MySQL 生产/测试库补丁,新增特殊调整主表与明细表。 +- `backend/app/services/special_inventory_adjustment.py` + - 特殊调整核心服务,负责校验、明细变更、汇总库存更新、流水生成、审计记录。 +- `backend/tests/test_special_warehouse_adjustment.py` + - 后端 SQLite 单元测试,覆盖强制说明、原材料重量调整、辅料数量调整、成品数量调整、锁库拦截。 +- `frontend/src/utils/specialAdjustmentRules.js` + - 前端特殊调整口径与展示规则,避免把口径判断全部堆进 Vue 单文件。 +- `frontend/scripts/test-special-adjustment-ui.mjs` + - 前端静态脚本测试,检查 6 大库都出现特殊入库/特殊出库入口,检查口径规则。 + +Modify: + +- `backend/app/models/operations.py` + - 新增 `SpecialWarehouseAdjustment`、`SpecialWarehouseAdjustmentLine` ORM 模型。 +- `backend/app/schemas/operations.py` + - 新增特殊调整请求/响应 schema。 +- `backend/app/api/routes/inventory.py` + - 新增 `/inventory/special-adjustments` 接口,接入服务。 +- `backend/app/services/operations.py` + - 扩展仓库流水方向与类型中文展示能力,确保特殊调整可在流水中查到。 +- `frontend/src/views/InventoryLedgerView.vue` + - 增加 6 大库操作按钮、专用抽屉、提交逻辑、调整前后预览。 + +--- + +## Task 1: Backend Failing Tests For Special Adjustment + +**Files:** + +- Create: `backend/tests/test_special_warehouse_adjustment.py` +- Future dependency: `backend/app/services/special_inventory_adjustment.py` +- Future dependency: `backend/app/schemas/operations.py` + +- [ ] **Step 1: Write the failing backend test file** + +Create `backend/tests/test_special_warehouse_adjustment.py` with this content: + +```python +from __future__ import annotations + +import unittest +from datetime import UTC, datetime +from decimal import Decimal + +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.master_data # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Item, StockBalance, Warehouse # noqa: E402 +from app.models.operations import InventoryTxn, StockLot, WarehouseLocation # noqa: E402 +from app.schemas.operations import SpecialWarehouseAdjustmentCreate, SpecialWarehouseAdjustmentLineCreate # noqa: E402 +from app.services.special_inventory_adjustment import create_special_inventory_adjustment # noqa: E402 + + +class SpecialWarehouseAdjustmentTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self._seed() + + def tearDown(self) -> None: + self.db.close() + + def _seed(self) -> None: + now = datetime.now(UTC) + self.raw = Warehouse( + id=1, + warehouse_code="WH-RAW", + warehouse_name="原材料库", + warehouse_type="RAW", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.aux = Warehouse( + id=2, + warehouse_code="WH-AUX", + warehouse_name="辅料库", + warehouse_type="AUX", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.finished = Warehouse( + id=3, + warehouse_code="WH-FG", + warehouse_name="成品库", + warehouse_type="FINISHED", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.raw_loc = WarehouseLocation( + id=1, + warehouse_id=1, + location_code="RAW-A", + location_name="原材料默认库位", + is_default=1, + is_locked=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.aux_loc = WarehouseLocation( + id=2, + warehouse_id=2, + location_code="AUX-A", + location_name="辅料默认库位", + is_default=1, + is_locked=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.finished_loc = WarehouseLocation( + id=3, + warehouse_id=3, + location_code="FG-A", + location_name="成品默认库位", + is_default=1, + is_locked=0, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.raw_item = Item( + id=1, + item_code="原材料00001", + item_name="冷轧钢板", + item_type="RAW_MATERIAL", + unit_weight_kg=Decimal("1"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.aux_item = Item( + id=2, + item_code="原材料00002", + item_name="劳保手套", + item_type="RAW_MATERIAL", + unit_weight_kg=Decimal("0"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.finished_item = Item( + id=3, + item_code="产品需规00001", + item_name="钢制碗", + item_type="FINISHED_GOOD", + unit_weight_kg=Decimal("0.6"), + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.raw_lot = StockLot( + id=1, + lot_no="JH_冷轧钢板00001_0001", + lot_role="OPENING_STOCK", + item_id=1, + warehouse_id=1, + location_id=1, + source_doc_type="OPENING_STOCK", + source_doc_id=1, + inbound_qty=Decimal("0"), + inbound_weight_kg=Decimal("500"), + remaining_qty=Decimal("0"), + remaining_weight_kg=Decimal("300"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("4.5"), + quality_status="PASS", + status="AVAILABLE", + created_at=now, + updated_at=now, + ) + self.aux_lot = StockLot( + id=2, + lot_no="AUX-20260606-001", + lot_role="OPENING_STOCK", + item_id=2, + warehouse_id=2, + location_id=2, + source_doc_type="OPENING_STOCK", + source_doc_id=2, + inbound_qty=Decimal("120"), + inbound_weight_kg=Decimal("0"), + remaining_qty=Decimal("80"), + remaining_weight_kg=Decimal("0"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("2"), + quality_status="PASS", + status="AVAILABLE", + created_at=now, + updated_at=now, + ) + self.finished_lot = StockLot( + id=3, + lot_no="FG-20260606-001", + lot_role="INBOUND_FINISHED", + item_id=3, + warehouse_id=3, + location_id=3, + source_doc_type="FG_RECEIPT", + source_doc_id=3, + inbound_qty=Decimal("100"), + inbound_weight_kg=Decimal("60"), + remaining_qty=Decimal("70"), + remaining_weight_kg=Decimal("42"), + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=Decimal("2.7"), + quality_status="PASS", + status="AVAILABLE", + created_at=now, + updated_at=now, + ) + self.raw_balance = StockBalance( + item_id=1, + warehouse_id=1, + location_id=1, + qty_on_hand=Decimal("0"), + weight_on_hand_kg=Decimal("300"), + qty_available=Decimal("0"), + weight_available_kg=Decimal("300"), + qty_allocated=Decimal("0"), + weight_allocated_kg=Decimal("0"), + avg_unit_cost=Decimal("4.5"), + created_at=now, + updated_at=now, + ) + self.aux_balance = StockBalance( + item_id=2, + warehouse_id=2, + location_id=2, + qty_on_hand=Decimal("80"), + weight_on_hand_kg=Decimal("0"), + qty_available=Decimal("80"), + weight_available_kg=Decimal("0"), + qty_allocated=Decimal("0"), + weight_allocated_kg=Decimal("0"), + avg_unit_cost=Decimal("2"), + created_at=now, + updated_at=now, + ) + self.finished_balance = StockBalance( + item_id=3, + warehouse_id=3, + location_id=3, + qty_on_hand=Decimal("70"), + weight_on_hand_kg=Decimal("42"), + qty_available=Decimal("70"), + weight_available_kg=Decimal("42"), + qty_allocated=Decimal("0"), + weight_allocated_kg=Decimal("0"), + avg_unit_cost=Decimal("2.7"), + created_at=now, + updated_at=now, + ) + self.db.add_all([ + self.raw, + self.aux, + self.finished, + self.raw_loc, + self.aux_loc, + self.finished_loc, + self.raw_item, + self.aux_item, + self.finished_item, + self.raw_lot, + self.aux_lot, + self.finished_lot, + self.raw_balance, + self.aux_balance, + self.finished_balance, + ]) + self.db.commit() + + def test_special_outbound_requires_reason_before_stock_change(self) -> None: + payload = SpecialWarehouseAdjustmentCreate( + warehouse_id=self.raw.id, + direction="OUT", + reason="", + lines=[ + SpecialWarehouseAdjustmentLineCreate( + item_id=self.raw_item.id, + lot_id=self.raw_lot.id, + adjust_weight_kg=Decimal("10"), + ) + ], + ) + + with self.assertRaisesRegex(ValueError, "特殊出库必须填写说明"): + create_special_inventory_adjustment(self.db, payload, user_id=9) + + unchanged_lot = self.db.get(StockLot, self.raw_lot.id) + self.assertEqual(Decimal(str(unchanged_lot.remaining_weight_kg)), Decimal("300.000000")) + + def test_raw_special_outbound_updates_lot_balance_and_inventory_txn(self) -> None: + payload = SpecialWarehouseAdjustmentCreate( + warehouse_id=self.raw.id, + direction="OUT", + reason="现场发现边角料已线下报废,补做特殊出库", + lines=[ + SpecialWarehouseAdjustmentLineCreate( + item_id=self.raw_item.id, + lot_id=self.raw_lot.id, + adjust_weight_kg=Decimal("25.5"), + ) + ], + ) + + result = create_special_inventory_adjustment(self.db, payload, user_id=9) + + adjusted_lot = self.db.get(StockLot, self.raw_lot.id) + balance = self.db.scalar(select(StockBalance).where(StockBalance.item_id == self.raw_item.id)) + txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "SPECIAL_OUT")) + + self.assertEqual(result.direction, "OUT") + self.assertEqual(Decimal(str(adjusted_lot.remaining_weight_kg)), Decimal("274.500000")) + self.assertEqual(Decimal(str(balance.weight_on_hand_kg)), Decimal("274.500000")) + self.assertEqual(Decimal(str(txn.weight_change_kg)), Decimal("-25.500000")) + self.assertIn("特殊出库说明:现场发现边角料已线下报废,补做特殊出库", txn.remark) + self.assertIn("调整前重量 300", txn.remark) + self.assertIn("调整后重量 274.5", txn.remark) + + def test_special_outbound_cannot_exceed_available_lot_quantity_or_weight(self) -> None: + payload = SpecialWarehouseAdjustmentCreate( + warehouse_id=self.aux.id, + direction="OUT", + reason="盘点前发现辅料账实差异,补做出库", + lines=[ + SpecialWarehouseAdjustmentLineCreate( + item_id=self.aux_item.id, + lot_id=self.aux_lot.id, + adjust_qty=Decimal("81"), + ) + ], + ) + + with self.assertRaisesRegex(ValueError, "特殊出库数量不能超过当前可用数量"): + create_special_inventory_adjustment(self.db, payload, user_id=9) + + def test_aux_special_inbound_uses_quantity_only(self) -> None: + payload = SpecialWarehouseAdjustmentCreate( + warehouse_id=self.aux.id, + direction="IN", + reason="供应商赠送辅料漏入账,补做特殊入库", + lines=[ + SpecialWarehouseAdjustmentLineCreate( + item_id=self.aux_item.id, + lot_id=self.aux_lot.id, + adjust_qty=Decimal("12"), + adjust_weight_kg=Decimal("99"), + ) + ], + ) + + create_special_inventory_adjustment(self.db, payload, user_id=9) + + adjusted_lot = self.db.get(StockLot, self.aux_lot.id) + balance = self.db.scalar(select(StockBalance).where(StockBalance.item_id == self.aux_item.id)) + txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "SPECIAL_IN")) + + self.assertEqual(Decimal(str(adjusted_lot.remaining_qty)), Decimal("92.000000")) + self.assertEqual(Decimal(str(adjusted_lot.remaining_weight_kg)), Decimal("0.000000")) + self.assertEqual(Decimal(str(balance.qty_on_hand)), Decimal("92.000000")) + self.assertEqual(Decimal(str(txn.qty_change)), Decimal("12.000000")) + self.assertEqual(Decimal(str(txn.weight_change_kg)), Decimal("0.000000")) + + def test_finished_special_outbound_uses_qty_and_keeps_weight_auxiliary(self) -> None: + payload = SpecialWarehouseAdjustmentCreate( + warehouse_id=self.finished.id, + direction="OUT", + reason="客户样品领用,补做成品特殊出库", + lines=[ + SpecialWarehouseAdjustmentLineCreate( + item_id=self.finished_item.id, + lot_id=self.finished_lot.id, + adjust_qty=Decimal("5"), + adjust_weight_kg=Decimal("3"), + ) + ], + ) + + create_special_inventory_adjustment(self.db, payload, user_id=9) + + adjusted_lot = self.db.get(StockLot, self.finished_lot.id) + balance = self.db.scalar(select(StockBalance).where(StockBalance.item_id == self.finished_item.id)) + + self.assertEqual(Decimal(str(adjusted_lot.remaining_qty)), Decimal("65.000000")) + self.assertEqual(Decimal(str(adjusted_lot.remaining_weight_kg)), Decimal("39.000000")) + self.assertEqual(Decimal(str(balance.qty_on_hand)), Decimal("65.000000")) + self.assertEqual(Decimal(str(balance.weight_on_hand_kg)), Decimal("39.000000")) +``` + +- [ ] **Step 2: Run the failing backend tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest tests/test_special_warehouse_adjustment.py -v +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'app.services.special_inventory_adjustment'` or import errors for `SpecialWarehouseAdjustmentCreate`. + +- [ ] **Step 3: Commit the failing test** + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +git add backend/tests/test_special_warehouse_adjustment.py +git commit -m "test: cover special warehouse adjustments" +``` + +--- + +## Task 2: Database And ORM Audit Tables + +**Files:** + +- Create: `backend/sql/add_special_warehouse_adjustment_patch.sql` +- Modify: `backend/app/models/operations.py` + +- [ ] **Step 1: Add MySQL migration patch** + +Create `backend/sql/add_special_warehouse_adjustment_patch.sql`: + +```sql +-- 特殊出入库调整主表和明细表 +-- 说明: +-- 1. 特殊调整不替代库存流水,所有库存变动仍写入 wh_inventory_txn。 +-- 2. 该表用于把一次特殊操作中涉及的多行明细聚合成一张审计单。 + +CREATE TABLE IF NOT EXISTS wh_special_adjustment ( + id BIGINT NOT NULL AUTO_INCREMENT, + adjustment_no VARCHAR(50) NOT NULL, + warehouse_id BIGINT NOT NULL, + warehouse_type VARCHAR(32) NOT NULL, + direction VARCHAR(16) NOT NULL, + reason VARCHAR(1000) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'CONFIRMED', + operator_user_id BIGINT NULL, + adjusted_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_wh_special_adjustment_no (adjustment_no), + KEY idx_wh_special_adjustment_warehouse (warehouse_id, adjusted_at), + KEY idx_wh_special_adjustment_direction (direction, adjusted_at), + CONSTRAINT fk_wh_special_adjustment_warehouse + FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓库特殊出入库调整主表'; + +CREATE TABLE IF NOT EXISTS wh_special_adjustment_line ( + id BIGINT NOT NULL AUTO_INCREMENT, + adjustment_id BIGINT NOT NULL, + line_no INT NOT NULL, + item_id BIGINT NOT NULL, + warehouse_id BIGINT NOT NULL, + location_id BIGINT NULL, + lot_id BIGINT NOT NULL, + inventory_txn_id BIGINT NULL, + before_qty DECIMAL(18,6) NOT NULL DEFAULT 0, + change_qty DECIMAL(18,6) NOT NULL DEFAULT 0, + after_qty DECIMAL(18,6) NOT NULL DEFAULT 0, + before_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0, + change_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0, + after_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0, + unit_cost DECIMAL(18,4) NOT NULL DEFAULT 0, + line_reason VARCHAR(1000) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_wh_special_adjustment_line_adjustment (adjustment_id, line_no), + KEY idx_wh_special_adjustment_line_lot (lot_id), + KEY idx_wh_special_adjustment_line_txn (inventory_txn_id), + CONSTRAINT fk_wh_special_adjustment_line_adjustment + FOREIGN KEY (adjustment_id) REFERENCES wh_special_adjustment(id), + CONSTRAINT fk_wh_special_adjustment_line_item + FOREIGN KEY (item_id) REFERENCES md_item(id), + CONSTRAINT fk_wh_special_adjustment_line_warehouse + FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse(id), + CONSTRAINT fk_wh_special_adjustment_line_lot + FOREIGN KEY (lot_id) REFERENCES wh_stock_lot(id), + CONSTRAINT fk_wh_special_adjustment_line_txn + FOREIGN KEY (inventory_txn_id) REFERENCES wh_inventory_txn(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓库特殊出入库调整明细表'; +``` + +- [ ] **Step 2: Add ORM models** + +Modify `backend/app/models/operations.py` after `InventoryTxn`: + +```python +class SpecialWarehouseAdjustment(Base): + __tablename__ = "wh_special_adjustment" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + adjustment_no: Mapped[str] = mapped_column(String(50)) + warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id")) + warehouse_type: Mapped[str] = mapped_column(String(32)) + direction: Mapped[str] = mapped_column(String(16)) + reason: Mapped[str] = mapped_column(String(1000)) + status: Mapped[str] = mapped_column(String(32), server_default=text("'CONFIRMED'")) + operator_user_id: Mapped[int | None] = mapped_column(ForeignKey("sys_user.id"), nullable=True) + adjusted_at: Mapped[object] = mapped_column(DateTime) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) + + +class SpecialWarehouseAdjustmentLine(Base): + __tablename__ = "wh_special_adjustment_line" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + adjustment_id: Mapped[int] = mapped_column(ForeignKey("wh_special_adjustment.id")) + line_no: Mapped[int] = mapped_column(Integer) + item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) + warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id")) + location_id: Mapped[int | None] = mapped_column(ForeignKey("wh_location.id"), nullable=True) + lot_id: Mapped[int] = mapped_column(ForeignKey("wh_stock_lot.id")) + inventory_txn_id: Mapped[int | None] = mapped_column(ForeignKey("wh_inventory_txn.id"), nullable=True) + before_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + change_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + after_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + before_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + change_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + after_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) + unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0")) + line_reason: Mapped[str | None] = mapped_column(String(1000), nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) +``` + +- [ ] **Step 3: Run model import check** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python - <<'PY' +import app.models.operations as operations +print(operations.SpecialWarehouseAdjustment.__tablename__) +print(operations.SpecialWarehouseAdjustmentLine.__tablename__) +PY +``` + +Expected: + +```text +wh_special_adjustment +wh_special_adjustment_line +``` + +- [ ] **Step 4: Re-run backend tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest tests/test_special_warehouse_adjustment.py -v +``` + +Expected: still FAIL because schemas and service are not implemented yet. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +git add backend/sql/add_special_warehouse_adjustment_patch.sql backend/app/models/operations.py +git commit -m "feat: add special adjustment audit tables" +``` + +--- + +## Task 3: Backend Schemas And Core Service + +**Files:** + +- Modify: `backend/app/schemas/operations.py` +- Create: `backend/app/services/special_inventory_adjustment.py` + +- [ ] **Step 1: Add request and response schemas** + +Modify `backend/app/schemas/operations.py` near the warehouse operation schemas: + +```python +class SpecialWarehouseAdjustmentLineCreate(BaseModel): + item_id: int + lot_id: int | None = None + location_id: int | None = None + adjust_qty: float | None = 0 + adjust_weight_kg: float | None = 0 + unit_cost: float | None = None + line_reason: str | None = None + + +class SpecialWarehouseAdjustmentCreate(BaseModel): + warehouse_id: int + direction: str + reason: str + lines: list[SpecialWarehouseAdjustmentLineCreate] = Field(min_length=1) + + +class SpecialWarehouseAdjustmentLineRead(BaseModel): + line_id: int + line_no: int + item_id: int + lot_id: int + inventory_txn_id: int | None = None + before_qty: float + change_qty: float + after_qty: float + before_weight_kg: float + change_weight_kg: float + after_weight_kg: float + unit_cost: float + line_reason: str | None = None + + +class SpecialWarehouseAdjustmentRead(BaseModel): + adjustment_id: int + adjustment_no: str + warehouse_id: int + warehouse_type: str + direction: str + reason: str + status: str + adjusted_at: datetime + lines: list[SpecialWarehouseAdjustmentLineRead] = Field(default_factory=list) +``` + +- [ ] **Step 2: Implement service constants and helpers** + +Create `backend/app/services/special_inventory_adjustment.py` with these imports and helpers: + +```python +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.master_data import Item, StockBalance, Warehouse +from app.models.operations import ( + InventoryTxn, + SpecialWarehouseAdjustment, + SpecialWarehouseAdjustmentLine, + StockLot, +) +from app.schemas.operations import ( + SpecialWarehouseAdjustmentCreate, + SpecialWarehouseAdjustmentLineRead, + SpecialWarehouseAdjustmentRead, +) +from app.services.operations import create_inventory_txn, get_location_or_default, next_doc_no, to_decimal, upsert_stock_balance +from app.services.stocktake import ensure_warehouses_unlocked + +SPECIAL_SOURCE_DOC_TYPE = "SPECIAL_ADJUSTMENT" +SPECIAL_IN_TXN_TYPE = "SPECIAL_IN" +SPECIAL_OUT_TXN_TYPE = "SPECIAL_OUT" +WEIGHT_ONLY_WAREHOUSE_TYPES = {"RAW", "SCRAP"} +QTY_ONLY_WAREHOUSE_TYPES = {"AUX"} +QTY_PRIMARY_WAREHOUSE_TYPES = {"FINISHED", "RETURN"} + + +@dataclass(frozen=True) +class AdjustmentMeasure: + qty: Decimal + weight: Decimal + amount_basis: str + + +def _clean_required_reason(direction: str, reason: str | None) -> str: + label = "特殊入库" if direction == "IN" else "特殊出库" + cleaned = str(reason or "").strip() + if not cleaned: + raise ValueError(f"{label}必须填写说明") + if len(cleaned) < 4: + raise ValueError(f"{label}说明不能少于4个字") + return cleaned + + +def _direction_label(direction: str) -> str: + return "特殊入库" if direction == "IN" else "特殊出库" + + +def _normalize_direction(value: str | None) -> str: + direction = str(value or "").upper() + if direction not in {"IN", "OUT"}: + raise ValueError("特殊调整方向只能是特殊入库或特殊出库") + return direction + + +def _measure_for_warehouse(warehouse_type: str, raw_qty: Decimal, raw_weight: Decimal) -> AdjustmentMeasure: + if warehouse_type in WEIGHT_ONLY_WAREHOUSE_TYPES: + if raw_weight <= 0: + raise ValueError("该仓库特殊调整必须填写重量") + return AdjustmentMeasure(qty=Decimal("0"), weight=raw_weight, amount_basis="WEIGHT") + if warehouse_type in QTY_ONLY_WAREHOUSE_TYPES: + if raw_qty <= 0: + raise ValueError("该仓库特殊调整必须填写数量") + return AdjustmentMeasure(qty=raw_qty, weight=Decimal("0"), amount_basis="QTY") + if warehouse_type in QTY_PRIMARY_WAREHOUSE_TYPES: + if raw_qty <= 0: + raise ValueError("该仓库特殊调整必须填写数量") + return AdjustmentMeasure(qty=raw_qty, weight=max(raw_weight, Decimal("0")), amount_basis="QTY") + if raw_qty > 0 and raw_weight > 0: + raise ValueError("半成品特殊调整只能按件或按重选择一种口径") + if raw_qty > 0: + return AdjustmentMeasure(qty=raw_qty, weight=Decimal("0"), amount_basis="QTY") + if raw_weight > 0: + return AdjustmentMeasure(qty=Decimal("0"), weight=raw_weight, amount_basis="WEIGHT") + raise ValueError("半成品特殊调整必须填写数量或重量") + + +def _format_decimal(value: Decimal) -> str: + text = f"{value.normalize():f}" + return text.rstrip("0").rstrip(".") if "." in text else text +``` + +- [ ] **Step 3: Implement new lot creation for special inbound** + +Append this helper to `backend/app/services/special_inventory_adjustment.py`: + +```python +def _build_special_lot_no(db: Session, item: Item, warehouse: Warehouse, now: datetime) -> str: + warehouse_type = str(warehouse.warehouse_type or "").upper() + if warehouse_type == "RAW": + from app.services.operations import build_inventory_lot_no + + return build_inventory_lot_no(db, item.id) + prefix = { + "SEMI": "半成品特殊", + "FINISHED": "成品特殊", + "AUX": "辅料特殊", + "SCRAP": "废料特殊", + "RETURN": "退货特殊", + }.get(warehouse_type, "库存特殊") + base = f"{prefix}{now.strftime('%Y%m%d')}" + existing_count = db.scalar( + select(SpecialWarehouseAdjustmentLine.id) + .join(SpecialWarehouseAdjustment, SpecialWarehouseAdjustment.id == SpecialWarehouseAdjustmentLine.adjustment_id) + .where(SpecialWarehouseAdjustment.warehouse_id == warehouse.id) + .order_by(SpecialWarehouseAdjustmentLine.id.desc()) + .limit(1) + ) + next_seq = int(existing_count or 0) + 1 + candidate = f"{base}-{next_seq:04d}" + while db.scalar(select(StockLot.id).where(StockLot.lot_no == candidate)): + next_seq += 1 + candidate = f"{base}-{next_seq:04d}" + return candidate + + +def _create_special_inbound_lot( + db: Session, + *, + item: Item, + warehouse: Warehouse, + location_id: int | None, + measure: AdjustmentMeasure, + unit_cost: Decimal, + adjustment_id: int, + line_id: int, + reason: str, + now: datetime, +) -> StockLot: + location = get_location_or_default(db, warehouse.id, location_id) + lot = StockLot( + lot_no=_build_special_lot_no(db, item, warehouse, now), + lot_role="SPECIAL_ADJUSTMENT", + item_id=item.id, + warehouse_id=warehouse.id, + location_id=location.id if location else None, + source_doc_type=SPECIAL_SOURCE_DOC_TYPE, + source_doc_id=adjustment_id, + source_line_id=line_id, + source_material_summary=f"特殊入库说明:{reason}", + inbound_qty=measure.qty, + inbound_weight_kg=measure.weight, + remaining_qty=measure.qty, + remaining_weight_kg=measure.weight, + locked_qty=Decimal("0"), + locked_weight_kg=Decimal("0"), + unit_cost=unit_cost, + production_date=now.date(), + quality_status="PASS", + status="AVAILABLE", + remark=f"特殊入库说明:{reason}"[:255], + created_at=now, + updated_at=now, + ) + db.add(lot) + db.flush() + return lot +``` + +- [ ] **Step 4: Implement main service** + +Append this function to `backend/app/services/special_inventory_adjustment.py`: + +```python +def create_special_inventory_adjustment( + db: Session, + payload: SpecialWarehouseAdjustmentCreate, + *, + user_id: int | None, +) -> SpecialWarehouseAdjustmentRead: + direction = _normalize_direction(payload.direction) + reason = _clean_required_reason(direction, payload.reason) + warehouse = db.get(Warehouse, payload.warehouse_id) + if not warehouse: + raise ValueError("仓库不存在") + ensure_warehouses_unlocked(db, [warehouse.id], _direction_label(direction)) + warehouse_type = str(warehouse.warehouse_type or "").upper() + now = datetime.now() + adjustment = SpecialWarehouseAdjustment( + adjustment_no=next_doc_no(db, SpecialWarehouseAdjustment, "adjustment_no", f"特殊调整{now.strftime('%Y%m%d')}"), + warehouse_id=warehouse.id, + warehouse_type=warehouse_type, + direction=direction, + reason=reason, + status="CONFIRMED", + operator_user_id=user_id, + adjusted_at=now, + created_at=now, + updated_at=now, + ) + db.add(adjustment) + db.flush() + + read_lines: list[SpecialWarehouseAdjustmentLineRead] = [] + for index, payload_line in enumerate(payload.lines, start=1): + item = db.get(Item, payload_line.item_id) + if not item: + raise ValueError(f"第 {index} 行物料不存在") + + raw_qty = to_decimal(payload_line.adjust_qty) + raw_weight = to_decimal(payload_line.adjust_weight_kg) + if raw_qty < 0 or raw_weight < 0: + raise ValueError("特殊调整数量和重量不能小于0") + measure = _measure_for_warehouse(warehouse_type, raw_qty, raw_weight) + + lot = db.get(StockLot, payload_line.lot_id) if payload_line.lot_id else None + if direction == "OUT" and lot is None: + raise ValueError("特殊出库必须选择库存明细") + if lot is not None: + if lot.item_id != item.id or lot.warehouse_id != warehouse.id: + raise ValueError("库存明细与所选物料或仓库不一致") + if payload_line.location_id and lot.location_id != payload_line.location_id: + raise ValueError("库存明细不属于所选库位") + + if direction == "IN" and lot is None: + unit_cost = to_decimal(payload_line.unit_cost, "0.0001") + placeholder_line = SpecialWarehouseAdjustmentLine( + adjustment_id=adjustment.id, + line_no=index, + item_id=item.id, + warehouse_id=warehouse.id, + location_id=payload_line.location_id, + lot_id=0, + before_qty=Decimal("0"), + change_qty=measure.qty, + after_qty=measure.qty, + before_weight_kg=Decimal("0"), + change_weight_kg=measure.weight, + after_weight_kg=measure.weight, + unit_cost=unit_cost, + line_reason=payload_line.line_reason, + created_at=now, + updated_at=now, + ) + db.add(placeholder_line) + db.flush() + lot = _create_special_inbound_lot( + db, + item=item, + warehouse=warehouse, + location_id=payload_line.location_id, + measure=measure, + unit_cost=unit_cost, + adjustment_id=adjustment.id, + line_id=placeholder_line.id, + reason=reason, + now=now, + ) + placeholder_line.lot_id = lot.id + db.add(placeholder_line) + line = placeholder_line + before_qty = Decimal("0") + before_weight = Decimal("0") + else: + assert lot is not None + before_qty = to_decimal(lot.remaining_qty) + before_weight = to_decimal(lot.remaining_weight_kg) + unit_cost = to_decimal(payload_line.unit_cost, "0.0001") if payload_line.unit_cost is not None else to_decimal(lot.unit_cost, "0.0001") + if direction == "OUT": + available_qty = before_qty - to_decimal(lot.locked_qty) + available_weight = before_weight - to_decimal(lot.locked_weight_kg) + if measure.qty > 0 and measure.qty > available_qty: + raise ValueError("特殊出库数量不能超过当前可用数量") + if measure.weight > 0 and measure.weight > available_weight: + raise ValueError("特殊出库重量不能超过当前可用重量") + change_qty = -measure.qty + change_weight = -measure.weight + else: + change_qty = measure.qty + change_weight = measure.weight + line = SpecialWarehouseAdjustmentLine( + adjustment_id=adjustment.id, + line_no=index, + item_id=item.id, + warehouse_id=warehouse.id, + location_id=lot.location_id, + lot_id=lot.id, + before_qty=before_qty, + change_qty=change_qty, + after_qty=before_qty + change_qty, + before_weight_kg=before_weight, + change_weight_kg=change_weight, + after_weight_kg=before_weight + change_weight, + unit_cost=unit_cost, + line_reason=payload_line.line_reason, + created_at=now, + updated_at=now, + ) + db.add(line) + db.flush() + lot.remaining_qty = line.after_qty + lot.remaining_weight_kg = line.after_weight_kg + lot.status = "AVAILABLE" if line.after_qty > 0 or line.after_weight_kg > 0 else "DEPLETED" + lot.updated_at = now + db.add(lot) + + line_reason = str(payload_line.line_reason or "").strip() + direction_text = _direction_label(direction) + remark_parts = [ + f"{direction_text}说明:{reason}", + f"调整前数量 {_format_decimal(to_decimal(line.before_qty))}", + f"调整数量 {_format_decimal(to_decimal(line.change_qty))}", + f"调整后数量 {_format_decimal(to_decimal(line.after_qty))}", + f"调整前重量 {_format_decimal(to_decimal(line.before_weight_kg))}", + f"调整重量 {_format_decimal(to_decimal(line.change_weight_kg))}", + f"调整后重量 {_format_decimal(to_decimal(line.after_weight_kg))}", + ] + if line_reason: + remark_parts.append(f"明细说明:{line_reason}") + txn = create_inventory_txn( + db, + txn_type=SPECIAL_IN_TXN_TYPE if direction == "IN" else SPECIAL_OUT_TXN_TYPE, + item_id=item.id, + warehouse_id=warehouse.id, + location_id=lot.location_id, + lot_id=lot.id, + qty_change=to_decimal(line.change_qty), + weight_change=to_decimal(line.change_weight_kg), + unit_cost=unit_cost, + source_doc_type=SPECIAL_SOURCE_DOC_TYPE, + source_doc_id=adjustment.id, + source_line_id=line.id, + biz_time=now, + operator_user_id=user_id, + remark=";".join(remark_parts)[:255], + amount_basis=measure.amount_basis, + ) + line.inventory_txn_id = txn.id + db.add(line) + upsert_stock_balance( + db, + item_id=item.id, + warehouse_id=warehouse.id, + location_id=lot.location_id, + qty_delta=to_decimal(line.change_qty), + weight_delta=to_decimal(line.change_weight_kg), + available_qty_delta=to_decimal(line.change_qty), + available_weight_delta=to_decimal(line.change_weight_kg), + unit_cost=unit_cost, + ) + read_lines.append( + SpecialWarehouseAdjustmentLineRead( + line_id=line.id, + line_no=line.line_no, + item_id=line.item_id, + lot_id=line.lot_id, + inventory_txn_id=txn.id, + before_qty=float(line.before_qty), + change_qty=float(line.change_qty), + after_qty=float(line.after_qty), + before_weight_kg=float(line.before_weight_kg), + change_weight_kg=float(line.change_weight_kg), + after_weight_kg=float(line.after_weight_kg), + unit_cost=float(line.unit_cost), + line_reason=line.line_reason, + ) + ) + + db.commit() + return SpecialWarehouseAdjustmentRead( + adjustment_id=adjustment.id, + adjustment_no=adjustment.adjustment_no, + warehouse_id=adjustment.warehouse_id, + warehouse_type=adjustment.warehouse_type, + direction=adjustment.direction, + reason=adjustment.reason, + status=adjustment.status, + adjusted_at=adjustment.adjusted_at, + lines=read_lines, + ) +``` + +- [ ] **Step 5: Run the backend tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest tests/test_special_warehouse_adjustment.py -v +``` + +Expected: PASS for the new test file. If SQLite rejects `lot_id=0` because of a foreign key in the placeholder line, adjust the implementation by creating the special inbound `StockLot` first with `source_line_id=0`, then create `SpecialWarehouseAdjustmentLine`, then update `StockLot.source_line_id` to the line id before commit. + +- [ ] **Step 6: Commit** + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +git add backend/app/schemas/operations.py backend/app/services/special_inventory_adjustment.py backend/tests/test_special_warehouse_adjustment.py +git commit -m "feat: implement special warehouse adjustment service" +``` + +--- + +## Task 4: API Route And Ledger Visibility + +**Files:** + +- Modify: `backend/app/api/routes/inventory.py` +- Modify: `backend/app/services/operations.py` +- Modify: `backend/tests/test_special_warehouse_adjustment.py` + +- [ ] **Step 1: Extend tests with API-style route behavior** + +Append to `SpecialWarehouseAdjustmentTest`: + +```python + def test_special_adjustment_ledger_direction_and_chinese_remark(self) -> None: + from app.services.operations import get_inventory_txn_ledger_query + + payload = SpecialWarehouseAdjustmentCreate( + warehouse_id=self.raw.id, + direction="OUT", + reason="仓管复核后确认线下已处理,补做特殊出库", + lines=[ + SpecialWarehouseAdjustmentLineCreate( + item_id=self.raw_item.id, + lot_id=self.raw_lot.id, + adjust_weight_kg=Decimal("6"), + ) + ], + ) + create_special_inventory_adjustment(self.db, payload, user_id=9) + + row = self.db.execute( + get_inventory_txn_ledger_query(warehouse_type="RAW", txn_type="SPECIAL_OUT") + ).mappings().first() + + self.assertIsNotNone(row) + self.assertEqual(row["direction"], "OUT") + self.assertEqual(row["txn_type"], "SPECIAL_OUT") + self.assertIn("特殊出库说明:仓管复核后确认线下已处理,补做特殊出库", row["remark"]) +``` + +- [ ] **Step 2: Run route/ledger test before modifying ledger labels** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest tests/test_special_warehouse_adjustment.py::SpecialWarehouseAdjustmentTest::test_special_adjustment_ledger_direction_and_chinese_remark -v +``` + +Expected: PASS or FAIL only if `SPECIAL_OUT` is not included in ledger filtering/summary logic. If it already passes, still complete Step 3 to add explicit label coverage. + +- [ ] **Step 3: Add route imports and endpoint** + +Modify imports in `backend/app/api/routes/inventory.py`: + +```python +from app.schemas.operations import ( + # existing imports... + SpecialWarehouseAdjustmentCreate, + SpecialWarehouseAdjustmentRead, +) +from app.services.special_inventory_adjustment import create_special_inventory_adjustment +``` + +Add this endpoint near the generic inbound/outbound endpoints: + +```python +@router.post("/special-adjustments", response_model=SpecialWarehouseAdjustmentRead) +def create_inventory_special_adjustment( + payload: SpecialWarehouseAdjustmentCreate, + context: AuthContext = Depends(require_authenticated_user), + db: Session = Depends(get_db), +) -> SpecialWarehouseAdjustmentRead: + try: + return create_special_inventory_adjustment(db, payload, user_id=context.user.id) + except ValueError as exc: + db.rollback() + raise HTTPException(status_code=400, detail=str(exc)) from exc +``` + +- [ ] **Step 4: Add Chinese business labels for special types** + +Modify `_INVENTORY_BIZ_TYPE_LABELS` in `backend/app/api/routes/inventory.py`: + +```python + "SPECIAL_IN": "特殊入库", + "SPECIAL_OUT": "特殊出库", +``` + +Modify any front/back ledger display helper that maps raw `txn_type` to labels if one exists. If no helper exists, keep `txn_type` as internal code but make sure `remark` contains the Chinese label because current warehouse ledger UI searches and displays remarks. + +- [ ] **Step 5: Run focused backend tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest tests/test_special_warehouse_adjustment.py tests/test_warehouse_transaction_ledger.py -v +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +git add backend/app/api/routes/inventory.py backend/app/services/operations.py backend/tests/test_special_warehouse_adjustment.py +git commit -m "feat: expose special warehouse adjustment api" +``` + +--- + +## Task 5: Frontend Rules And Static Tests + +**Files:** + +- Create: `frontend/src/utils/specialAdjustmentRules.js` +- Create: `frontend/scripts/test-special-adjustment-ui.mjs` + +- [ ] **Step 1: Add frontend measure rules** + +Create `frontend/src/utils/specialAdjustmentRules.js`: + +```javascript +export function normalizeWarehouseType(value) { + return String(value || "").toUpperCase(); +} + +export function specialAdjustmentDirectionLabel(direction) { + return String(direction || "").toUpperCase() === "OUT" ? "特殊出库" : "特殊入库"; +} + +export function specialAdjustmentMeasureMode(warehouseType, row = {}) { + const normalizedType = normalizeWarehouseType(warehouseType || row.warehouse_type); + if (["RAW", "SCRAP"].includes(normalizedType)) { + return { mode: "WEIGHT", primaryLabel: "调整重量(kg)", showQty: false, showWeight: true }; + } + if (normalizedType === "AUX") { + return { mode: "QTY", primaryLabel: "调整数量", showQty: true, showWeight: false }; + } + if (["FINISHED", "RETURN"].includes(normalizedType)) { + return { mode: "QTY_WITH_WEIGHT", primaryLabel: "调整数量", showQty: true, showWeight: true }; + } + const hasQty = Number(row?.remaining_qty || row?.qty_available || 0) > 0; + const hasWeight = Number(row?.remaining_weight_kg || row?.weight_available_kg || 0) > 0; + if (hasQty && !hasWeight) { + return { mode: "QTY", primaryLabel: "调整数量", showQty: true, showWeight: false }; + } + if (hasWeight && !hasQty) { + return { mode: "WEIGHT", primaryLabel: "调整重量(kg)", showQty: false, showWeight: true }; + } + return { mode: "SEMI_SELECT", primaryLabel: "调整口径", showQty: true, showWeight: true }; +} + +export function specialAdjustmentOperationKeys() { + return { + raw: ["raw-special-in", "raw-special-out"], + semi: ["semi-special-in", "semi-special-out"], + finished: ["finished-special-in", "finished-special-out"], + auxiliary: ["aux-special-in", "aux-special-out"], + scrap: ["scrap-special-in", "scrap-special-out"], + return: ["return-special-in", "return-special-out"] + }; +} +``` + +- [ ] **Step 2: Add static UI test script** + +Create `frontend/scripts/test-special-adjustment-ui.mjs`: + +```javascript +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { specialAdjustmentMeasureMode, specialAdjustmentOperationKeys } from "../src/utils/specialAdjustmentRules.js"; + +const viewSource = readFileSync(new URL("../src/views/InventoryLedgerView.vue", import.meta.url), "utf8"); +const operationKeys = specialAdjustmentOperationKeys(); + +for (const [warehouseTab, keys] of Object.entries(operationKeys)) { + for (const key of keys) { + assert.ok(viewSource.includes(`makeOperation("${key}"`), `${warehouseTab} 缺少 ${key}`); + } +} + +assert.ok(viewSource.includes("特殊入库"), "仓库页面必须展示特殊入库"); +assert.ok(viewSource.includes("特殊出库"), "仓库页面必须展示特殊出库"); +assert.ok(viewSource.includes("/inventory/special-adjustments"), "仓库页面必须调用特殊调整接口"); + +assert.deepEqual(specialAdjustmentMeasureMode("RAW"), { + mode: "WEIGHT", + primaryLabel: "调整重量(kg)", + showQty: false, + showWeight: true +}); + +assert.deepEqual(specialAdjustmentMeasureMode("AUX"), { + mode: "QTY", + primaryLabel: "调整数量", + showQty: true, + showWeight: false +}); + +assert.equal(specialAdjustmentMeasureMode("FINISHED").mode, "QTY_WITH_WEIGHT"); +assert.equal(specialAdjustmentMeasureMode("SCRAP").mode, "WEIGHT"); +assert.equal(specialAdjustmentMeasureMode("SEMI", { remaining_qty: 10, remaining_weight_kg: 0 }).mode, "QTY"); +assert.equal(specialAdjustmentMeasureMode("SEMI", { remaining_qty: 0, remaining_weight_kg: 10 }).mode, "WEIGHT"); +``` + +- [ ] **Step 3: Run the failing frontend test** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node scripts/test-special-adjustment-ui.mjs +``` + +Expected: FAIL because `InventoryLedgerView.vue` does not yet include special adjustment operation keys or API call. + +- [ ] **Step 4: Commit failing frontend tests** + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +git add frontend/src/utils/specialAdjustmentRules.js frontend/scripts/test-special-adjustment-ui.mjs +git commit -m "test: cover special adjustment warehouse ui" +``` + +--- + +## Task 6: Frontend Operation Buttons And Drawer + +**Files:** + +- Modify: `frontend/src/views/InventoryLedgerView.vue` + +- [ ] **Step 1: Import frontend rules** + +In `frontend/src/views/InventoryLedgerView.vue`, add: + +```javascript +import { specialAdjustmentMeasureMode, specialAdjustmentDirectionLabel } from "../utils/specialAdjustmentRules.js"; +``` + +- [ ] **Step 2: Add special operation buttons to all six warehouses** + +Modify `operationMatrix` so each inbound list ends with `特殊入库` and each outbound list ends with `特殊出库`: + +```javascript +makeOperation("raw-special-in", "in", "SPECIAL_IN", "特殊入库", "强制填写说明后,直接调整原材料库库存明细。", "specialAdjustment") +makeOperation("raw-special-out", "out", "SPECIAL_OUT", "特殊出库", "强制填写说明后,直接扣减原材料库库存明细。", "specialAdjustment") +makeOperation("semi-special-in", "in", "SPECIAL_IN", "特殊入库", "强制填写说明后,直接调整半成品库库存明细。", "specialAdjustment") +makeOperation("semi-special-out", "out", "SPECIAL_OUT", "特殊出库", "强制填写说明后,直接扣减半成品库库存明细。", "specialAdjustment") +makeOperation("finished-special-in", "in", "SPECIAL_IN", "特殊入库", "强制填写说明后,直接调整成品库库存明细。", "specialAdjustment") +makeOperation("finished-special-out", "out", "SPECIAL_OUT", "特殊出库", "强制填写说明后,直接扣减成品库库存明细。", "specialAdjustment") +makeOperation("aux-special-in", "in", "SPECIAL_IN", "特殊入库", "强制填写说明后,直接调整辅料库库存明细。", "specialAdjustment") +makeOperation("aux-special-out", "out", "SPECIAL_OUT", "特殊出库", "强制填写说明后,直接扣减辅料库库存明细。", "specialAdjustment") +makeOperation("scrap-special-in", "in", "SPECIAL_IN", "特殊入库", "强制填写说明后,直接调整废料库库存明细。", "specialAdjustment") +makeOperation("scrap-special-out", "out", "SPECIAL_OUT", "特殊出库", "强制填写说明后,直接扣减废料库库存明细。", "specialAdjustment") +makeOperation("return-special-in", "in", "SPECIAL_IN", "特殊入库", "强制填写说明后,直接调整退货库库存明细。", "specialAdjustment") +makeOperation("return-special-out", "out", "SPECIAL_OUT", "特殊出库", "强制填写说明后,直接扣减退货库库存明细。", "specialAdjustment") +``` + +Use the same visual style as existing operation cards. Do not introduce a separate visual language. + +- [ ] **Step 3: Add drawer state** + +Add near other drawer state: + +```javascript +const specialAdjustmentDrawerOpen = ref(false); +const specialAdjustmentSubmitting = ref(false); +const specialAdjustmentForm = reactive({ + direction: "IN", + warehouse_id: "", + reason: "", + lines: [] +}); +``` + +Add reset helpers: + +```javascript +function resetSpecialAdjustmentForm() { + specialAdjustmentForm.direction = activeOperation.value?.direction === "out" ? "OUT" : "IN"; + specialAdjustmentForm.warehouse_id = activeWarehouses.value[0]?.id || ""; + specialAdjustmentForm.reason = ""; + specialAdjustmentForm.lines = []; +} + +function closeSpecialAdjustmentDrawer() { + specialAdjustmentDrawerOpen.value = false; +} +``` + +- [ ] **Step 4: Route special operations to the drawer** + +Modify `openOperationDrawer(operation)` before the generic drawer fallback: + +```javascript + if (operation.drawerType === "specialAdjustment") { + resetSpecialAdjustmentForm(); + specialAdjustmentDrawerOpen.value = true; + return; + } +``` + +- [ ] **Step 5: Add computed lot options and preview helpers** + +Add computed helpers: + +```javascript +const specialAdjustmentDirectionText = computed(() => specialAdjustmentDirectionLabel(specialAdjustmentForm.direction)); + +const specialAdjustmentLotOptions = computed(() => { + const warehouseId = Number(specialAdjustmentForm.warehouse_id || 0); + return stockLots.value + .filter((lot) => Number(lot.warehouse_id || 0) === warehouseId) + .sort((left, right) => { + const leftAvailable = Number(left.remaining_qty || 0) > 0 || Number(left.remaining_weight_kg || 0) > 0 ? 0 : 1; + const rightAvailable = Number(right.remaining_qty || 0) > 0 || Number(right.remaining_weight_kg || 0) > 0 ? 0 : 1; + return leftAvailable - rightAvailable || String(left.lot_no || "").localeCompare(String(right.lot_no || ""), "zh-CN", { numeric: true }); + }); +}); + +function buildSpecialAdjustmentEmptyLine() { + return { + lot_id: "", + item_id: "", + item_name: "", + lot_no: "", + adjust_qty: "", + adjust_weight_kg: "", + unit_cost: "", + line_reason: "" + }; +} + +function addSpecialAdjustmentLine() { + if (!specialAdjustmentForm.reason.trim()) { + errorMessage.value = `${specialAdjustmentDirectionText.value}必须先填写说明`; + return; + } + specialAdjustmentForm.lines.push(buildSpecialAdjustmentEmptyLine()); +} + +function removeSpecialAdjustmentLine(index) { + specialAdjustmentForm.lines.splice(index, 1); +} + +function applySpecialAdjustmentLot(line) { + const lot = specialAdjustmentLotOptions.value.find((item) => Number(item.lot_id) === Number(line.lot_id)); + if (!lot) { + return; + } + line.item_id = lot.item_id; + line.item_name = lot.item_name; + line.lot_no = lot.lot_no; + line.unit_cost = lot.unit_cost || 0; + const measure = specialAdjustmentMeasureMode(activeWarehouseType.value, lot); + line.measure_mode = measure.mode; +} + +function specialAdjustmentLinePreview(line) { + const lot = specialAdjustmentLotOptions.value.find((item) => Number(item.lot_id) === Number(line.lot_id)); + if (!lot) { + return "请选择库存明细"; + } + const directionMultiplier = specialAdjustmentForm.direction === "OUT" ? -1 : 1; + const qtyChange = Number(line.adjust_qty || 0) * directionMultiplier; + const weightChange = Number(line.adjust_weight_kg || 0) * directionMultiplier; + const afterQty = Number(lot.remaining_qty || 0) + qtyChange; + const afterWeight = Number(lot.remaining_weight_kg || 0) + weightChange; + return `调整前:${formatQty(lot.remaining_qty)} / ${formatWeight(lot.remaining_weight_kg)};调整后:${formatQty(afterQty)} / ${formatWeight(afterWeight)}`; +} +``` + +- [ ] **Step 6: Add drawer template** + +Add a `SideDrawer` after the generic drawer or near other operation drawers: + +```vue + +
+
+ 特殊调整会直接改变库存明细 + 请只在补录、纠错、线下已处理等特殊场景使用。保存后系统会写入库存流水和特殊调整记录。 +
+ + + + + + + +``` + +- [ ] **Step 8: Run frontend tests and build** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node scripts/test-production-work-order-inbound-ui.mjs +node scripts/test-inventory-ledger-options.mjs +npm run build +``` + +Expected: scripts PASS and build PASS with only existing Vite chunk-size warning. + +--- + +### Task 8: Update Miniapp Frontend Wording To “材料库存批次号” + +**Files:** + +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.wxml` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.js` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/records/records.wxml` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard/dashboard.wxml` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review/review.wxml` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js` + +- [ ] **Step 1: Add a text scan test** + +Run before edits: + +```bash +rg -n "工单号|原材料分批次号|请选择工单号|暂无可选工单号" \ + /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm \ + /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/records \ + /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard \ + /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review \ + /Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js +``` + +Expected: output includes old wording. + +- [ ] **Step 2: Replace visible wording** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.wxml`: + +```xml +材料库存批次号 +{{reportItem.rawMaterialBatchNo || '请选择材料库存批次号'}} +暂无可选材料库存批次号 +``` + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.js`, replace validation message: + +```js +title: `${blockDisplay(block)}请选择材料库存批次号`, +``` + +In records/dashboard/review WXML files, replace visible `原材料分批次号` with `材料库存批次号`. + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js`, keep payload field names unchanged but replace correction display label: + +```js +correctionPair(corrections, 'raw_material_batch_no', '材料库存批次号', row.raw_material_batch_no || '') +``` + +- [ ] **Step 3: Verify old wording is gone from user-facing miniapp files** + +Run: + +```bash +rg -n "请选择工单号|暂无可选工单号|原材料分批次号" \ + /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm \ + /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/records \ + /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard \ + /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review \ + /Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js +``` + +Expected: no output. + +--- + +### Task 9: Add SQL Execution And Deployment Checks + +**Files:** + +- Use: `backend/sql/add_raw_lot_work_order_inbound_patch.sql` +- No code files modified in this task. + +- [ ] **Step 1: Execute SQL patch against the target test database** + +Run from ERP backend: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python - <<'PY' +from pathlib import Path +import re +import pymysql + +env = {} +for line in Path(".env").read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + env[key.strip()] = value.strip().strip('"').strip("'") + +sql_path = Path("sql/add_raw_lot_work_order_inbound_patch.sql") +statements = [stmt.strip() for stmt in re.split(r";\s*(?:\n|$)", sql_path.read_text(encoding="utf-8")) if stmt.strip()] +conn = pymysql.connect( + host=env["MYSQL_HOST"], + port=int(env["MYSQL_PORT"]), + user=env["MYSQL_USER"], + password=env["MYSQL_PASSWORD"], + database=env["MYSQL_DATABASE"], + charset="utf8mb4", +) +try: + with conn.cursor() as cur: + for stmt in statements: + cur.execute(stmt) + conn.commit() +finally: + conn.close() +print(f"executed {len(statements)} statements") +PY +``` + +Expected: `executed 2 statements`. + +- [ ] **Step 2: Verify config table and seed row** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python - <<'PY' +from pathlib import Path +import pymysql + +env = {} +for line in Path(".env").read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + env[key.strip()] = value.strip().strip('"').strip("'") + +conn = pymysql.connect( + host=env["MYSQL_HOST"], + port=int(env["MYSQL_PORT"]), + user=env["MYSQL_USER"], + password=env["MYSQL_PASSWORD"], + database=env["MYSQL_DATABASE"], + charset="utf8mb4", +) +try: + with conn.cursor() as cur: + cur.execute("select config_code, config_value from sys_system_config where config_code='RAW_MATERIAL_LOT_PREFIX'") + print(cur.fetchall()) +finally: + conn.close() +PY +``` + +Expected: output contains `RAW_MATERIAL_LOT_PREFIX` and `YL`. + +--- + +### Task 10: Full Verification + +**Files:** + +- No new files. + +- [ ] **Step 1: Run backend full tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m pytest tests -q +``` + +Expected: all tests pass. + +- [ ] **Step 2: Run frontend checks** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node scripts/test-production-work-order-inbound-ui.mjs +node scripts/test-inventory-ledger-options.mjs +node scripts/test-warehouse-drawer-table-overflow.mjs +npm run build +``` + +Expected: script tests pass and build succeeds. Vite chunk-size warning is acceptable. + +- [ ] **Step 3: Run miniapp backend tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +python -m pytest tests/test_work_order_batch_options.py tests/test_metrics.py tests/test_review_corrections.py -q +``` + +Expected: tests pass. + +- [ ] **Step 4: Manual smoke test in browser** + +Start services: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +.venv/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` + +In another terminal: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +npm run dev -- --host 0.0.0.0 --port 5173 +``` + +Smoke flow: + +1. Open `http://127.0.0.1:5173`. +2. Go to 系统拓展 and verify 原材料库存批次号前缀 shows `YL`. +3. Go to 嘉恒仓库原材料库 and create a raw inbound test lot; verify lot number is `YL0001` or the next available `YL` sequence. +4. Use 原材料库 · 生产出库, select one material stock lot and one product, submit. +5. Verify 工单台账 has work order `YYYYMMDD-YLxxxx-产品名称`. +6. Submit another production outbound for the same day, same lot, same product. +7. Verify it merges into the same work order and adds another issue line. +8. Open 嘉恒仓库 · 生产工单入库, select that work order, enter finished qty, verify surplus/scrap defaults are filled. +9. Change surplus or scrap beyond 15%; verify deviation remark is required. +10. Check “本次入库后结单”, submit, confirm second prompt, verify work order status becomes 已结单. + +Expected: no red overlay, no uncaught console error, and warehouse流水 shows成品入库、生产余料入库、生产废料入库 records tied to the work order. + +--- + +## Self-Review + +Spec coverage: + +- `YL0001` prefix and `YL10000` expansion: Task 1 and Task 2. +- System拓展 prefix configuration: Task 1 and Task 7. +- Single-lot production outbound: Task 4 and Task 7. +- Same-day same-lot same-product merge: Task 4. +- Miniapp wording and lot-number selection: Task 5 and Task 8. +- Production work-order inbound entry and workflow: Task 6 and Task 7. +- 15% deviation remark: Task 6 and Task 7. +- No default settle and second confirmation: Task 6 and Task 7. +- SQL execution and verification: Task 9. +- Full test and smoke checks: Task 10. + +Placeholder scan: + +- No unfinished placeholder markers remain in the plan. +- Production work-order inbound now lists concrete helper functions and submit calls instead of leaving a comment-only implementation gap. + +Type consistency: + +- Backend schemas use `ProductionWorkOrderInboundPreviewRead`, `ProductionWorkOrderInboundCreate`, and `ProductionWorkOrderInboundRead`. +- Frontend routes use `/production/work-order-inbounds/preview` and `/production/work-order-inbounds`. +- Miniapp keeps the persisted field `raw_material_batch_no` but changes visible wording to “材料库存批次号”. + +Git note: + +- Current ERP directory has no `.git` folder. If implementation runs in this mounted directory, commit steps are replaced by checkpoint summaries after each task. If a git worktree is available at execution time, commit after each completed task with a focused message. diff --git a/docs/superpowers/plans/2026-06-08-permission-filtered-employee-selectors.md b/docs/superpowers/plans/2026-06-08-permission-filtered-employee-selectors.md new file mode 100644 index 0000000..30605f8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-permission-filtered-employee-selectors.md @@ -0,0 +1,1656 @@ +# Permission-Filtered Employee Selectors Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make every business form personnel selector show only people whose system account has permission to access the related page, add the missing sales-person selector to sales order creation, and keep admin organization/personnel maintenance selectors unrestricted. + +**Architecture:** Add one backend service for permission-filtered employee options and one backend validator for personnel fields on business saves. Business pages call a non-admin authenticated endpoint under `/master-data`, while the system permission management page keeps its admin-only full employee option endpoint. Frontend pages share one small employee-option service so later forms cannot accidentally fall back to the full employee list. + +**Tech Stack:** FastAPI, SQLAlchemy, Pydantic, Vue 3 Composition API, Vite, existing SQLite-compatible backend unit tests, existing static frontend assertion scripts. + +--- + +## File Structure + +**Backend service and schemas** + +- Modify `backend/app/schemas/system_permissions.py` + - Extend `EmployeeOption` so both admin and business selectors can display account/role context consistently. +- Modify `backend/app/services/system_permissions.py` + - Add permission-tree consistency for `MENU_EQUIPMENT_MANAGEMENT`. + - Add `list_employee_options(db, permission_code=None, limit=500)`. + - Add `employee_has_permission(...)` and `ensure_employee_has_permission(...)`. +- Modify `backend/app/api/routes/master_data.py` + - Add authenticated non-admin business endpoint `GET /master-data/employee-options`. +- Modify `backend/app/api/routes/system_permissions.py` + - Keep admin endpoint, but pass through the same service without a permission filter. +- Modify `backend/app/schemas/database.py` + - Add `employee_id` and `mobile` to login session. + - Add `sales_employee_id` and `sales_employee_name` to sales order read/create schemas. +- Modify `backend/app/services/auth.py` + - Return the current login employee ID and mobile in `LoginUser`. + +**Backend business validation** + +- Modify `backend/app/api/routes/sales.py` + - Sales order creation requires and validates `sales_employee_id` against `MENU_SALES_PLANNING`. +- Modify `backend/app/services/sales_planning.py` + - Sales order list joins sales employee and returns name. +- Modify `backend/app/api/routes/purchase.py` + - Validate `purchaser_employee_id` against `MENU_PURCHASE_ORDER`. + - Validate `receiver_employee_id` on purchase receipts against `MENU_PURCHASE_RECEIPT`. +- Modify `backend/app/api/routes/inventory.py` + - Validate warehouse form personnel fields against `MENU_INVENTORY_LEDGER`. +- Modify `backend/app/api/routes/production.py` + - Validate completion/production inbound receiver fields against `MENU_INVENTORY_LEDGER` when the UI flow belongs to 嘉恒仓库. +- Modify `backend/app/api/routes/sales.py` + - Validate delivery shipper fields against `MENU_DELIVERY_MANAGEMENT` for 发货概览 delivery endpoints. +- Modify `backend/app/api/routes/returns.py` + - Validate return handler fields against the page permission used by that route. Current return-management route redirects to 嘉恒仓库, so warehouse return flows use `MENU_INVENTORY_LEDGER`. +- Modify `backend/app/api/routes/equipment.py` + - Validate maintenance owner fields against `MENU_EQUIPMENT_MANAGEMENT`. + +**Frontend shared helper** + +- Create `frontend/src/services/employeeOptions.js` + - Centralize permission-code constants, option normalization, current-user default selection, and selected historical option merging. + +**Frontend pages** + +- Modify `frontend/src/views/SalesPlanningView.vue` + - Add `销售人员` selector to sales order drawer and send `sales_employee_id`. +- Modify `frontend/src/views/PurchaseOrderView.vue` + - Replace full employee list with purchase-order permission employees. +- Modify `frontend/src/views/PurchaseReceiptView.vue` + - Replace full employee list with purchase-receipt permission employees. +- Modify `frontend/src/views/InventoryLedgerView.vue` + - Replace full employee list with inventory-ledger permission employees for warehouse personnel fields. +- Modify `frontend/src/views/CompletionReceiptView.vue` + - Replace full employee list with inventory-ledger permission employees. +- Modify `frontend/src/views/ReturnManagementView.vue` + - Replace full employee list with inventory-ledger permission employees because this page currently redirects/aligns with warehouse return handling. +- Modify `frontend/src/views/EquipmentManagementView.vue` + - Replace full employee list with equipment-management permission employees. +- Do not modify `frontend/src/views/SystemPermissionView.vue` for filtering. It must continue to see full active personnel for organization binding and authorization. + +**Tests** + +- Modify `backend/tests/test_system_permission_management.py` + - Add service-level tests for filtered employee options and validator behavior. +- Modify `backend/tests/test_sales_order_delivery_trace.py` + - Add sales employee seed data to sales order creation calls. +- Create `frontend/scripts/test-permission-employee-selectors.mjs` + - Static checks that business views call the permission-filtered endpoint and sales order has a sales-person field. + +--- + +### Task 1: Backend Tests For Permission-Filtered Employee Options + +**Files:** +- Modify: `backend/tests/test_system_permission_management.py` + +- [ ] **Step 1: Add test helpers and failing tests** + +Append these methods inside `SystemPermissionManagementTest` before `if __name__ == "__main__":`. + +```python + def _seed_permission_employee_selector_data(self) -> dict[str, int]: + from app.services.auth import hash_password + + self._seed_role_permission_user_data() + now = datetime(2026, 6, 8, 9, 0, 0) + sales_permission = Permission( + permission_code="MENU_SALES_PLANNING", + permission_name="客户名录/销售订单菜单", + module_code="SALES_PLANNING", + action_code="VIEW", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + sales_role = Role( + role_code="SALES", + role_name="销售人员", + role_scope="SYSTEM", + status="ACTIVE", + remark=None, + created_at=now, + updated_at=now, + ) + sales_employee = Employee( + employee_code="EMP-SALES", + employee_name="赵销售", + dept_id=self.purchase_dept_id, + mobile="13800000021", + gender=None, + hire_date=None, + job_title="销售人员", + shift_code=None, + manager_employee_id=None, + is_operator=0, + is_workshop_staff=0, + status="ACTIVE", + remark=None, + created_at=now, + updated_at=now, + ) + inactive_employee = Employee( + employee_code="EMP-INACTIVE", + employee_name="停用员工", + dept_id=self.purchase_dept_id, + mobile="13800000022", + gender=None, + hire_date=None, + job_title="停用", + shift_code=None, + manager_employee_id=None, + is_operator=0, + is_workshop_staff=0, + status="INACTIVE", + remark=None, + created_at=now, + updated_at=now, + ) + self.db.add_all([sales_permission, sales_role, sales_employee, inactive_employee]) + self.db.flush() + self.db.add_all( + [ + RolePermission(role_id=self.purchaser_role_id, permission_id=self.purchase_permission_id, created_at=now), + RolePermission(role_id=sales_role.id, permission_id=sales_permission.id, created_at=now), + User( + username="13800000012", + password_hash=hash_password("secret123"), + employee_id=self.buyer_employee_id, + dept_id=self.purchase_dept_id, + nickname="李四", + email=None, + is_super_admin=0, + last_login_at=None, + status="ACTIVE", + created_at=now, + updated_at=now, + ), + User( + username="13800000021", + password_hash=hash_password("secret123"), + employee_id=sales_employee.id, + dept_id=self.purchase_dept_id, + nickname="赵销售", + email=None, + is_super_admin=0, + last_login_at=None, + status="ACTIVE", + created_at=now, + updated_at=now, + ), + User( + username="13800000022", + password_hash=hash_password("secret123"), + employee_id=inactive_employee.id, + dept_id=self.purchase_dept_id, + nickname="停用员工", + email=None, + is_super_admin=0, + last_login_at=None, + status="ACTIVE", + created_at=now, + updated_at=now, + ), + ] + ) + self.db.flush() + buyer_user = self.db.scalar(select(User).where(User.employee_id == self.buyer_employee_id)) + sales_user = self.db.scalar(select(User).where(User.employee_id == sales_employee.id)) + inactive_user = self.db.scalar(select(User).where(User.employee_id == inactive_employee.id)) + self.db.add_all( + [ + UserRole(user_id=buyer_user.id, role_id=self.purchaser_role_id, created_at=now), + UserRole(user_id=sales_user.id, role_id=sales_role.id, created_at=now), + UserRole(user_id=inactive_user.id, role_id=sales_role.id, created_at=now), + ] + ) + self.db.commit() + return { + "sales_employee_id": sales_employee.id, + "inactive_employee_id": inactive_employee.id, + } + + def test_list_employee_options_filters_by_permission_code(self) -> None: + from app.services.system_permissions import list_employee_options + + ids = self._seed_permission_employee_selector_data() + + purchase_options = list_employee_options(self.db, permission_code="MENU_PURCHASE_ORDER") + sales_options = list_employee_options(self.db, permission_code="MENU_SALES_PLANNING") + full_options = list_employee_options(self.db) + + self.assertEqual([option.employee_name for option in purchase_options], ["李四"]) + self.assertEqual([option.employee_name for option in sales_options], ["赵销售"]) + self.assertIn("销售人员", sales_options[0].role_names) + self.assertEqual(sales_options[0].username, "13800000021") + self.assertNotIn(ids["inactive_employee_id"], [option.employee_id for option in full_options]) + + def test_ensure_employee_has_permission_rejects_unqualified_employee(self) -> None: + from fastapi import HTTPException + from app.services.system_permissions import ensure_employee_has_permission + + ids = self._seed_permission_employee_selector_data() + + ensure_employee_has_permission( + self.db, + ids["sales_employee_id"], + "MENU_SALES_PLANNING", + "销售人员", + ) + with self.assertRaises(HTTPException) as ctx: + ensure_employee_has_permission( + self.db, + self.buyer_employee_id, + "MENU_SALES_PLANNING", + "销售人员", + ) + self.assertEqual(ctx.exception.status_code, 400) + self.assertIn("销售人员必须选择有“客户名录/销售订单菜单”权限的人员", str(ctx.exception.detail)) + + def test_login_session_exposes_employee_id_for_default_selectors(self) -> None: + from app.services.auth import get_auth_context, hash_password + + self._seed_permission_employee_selector_data() + context = get_auth_context(self.db, "13800000012") + + self.assertEqual(context.employee.id, self.buyer_employee_id) + self.assertEqual(context.employee.mobile, "13800000012") +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: + +```bash +cd backend +.venv/bin/python -m pytest tests/test_system_permission_management.py::SystemPermissionManagementTest::test_list_employee_options_filters_by_permission_code -q +.venv/bin/python -m pytest tests/test_system_permission_management.py::SystemPermissionManagementTest::test_ensure_employee_has_permission_rejects_unqualified_employee -q +``` + +Expected: + +```text +FAILED ... TypeError: list_employee_options() got an unexpected keyword argument 'permission_code' +FAILED ... ImportError or AttributeError for ensure_employee_has_permission +``` + +- [ ] **Step 3: Checkpoint** + +Do not commit if the current workspace is not a git repository. Record that the backend permission tests fail for the expected missing implementation. + +--- + +### Task 2: Backend Permission Option Service And Login Session Fields + +**Files:** +- Modify: `backend/app/schemas/system_permissions.py` +- Modify: `backend/app/services/system_permissions.py` +- Modify: `backend/app/api/routes/master_data.py` +- Modify: `backend/app/api/routes/system_permissions.py` +- Modify: `backend/app/schemas/database.py` +- Modify: `backend/app/services/auth.py` + +- [ ] **Step 1: Extend schemas** + +In `backend/app/schemas/system_permissions.py`, replace `EmployeeOption` with this shape: + +```python +class EmployeeOption(BaseModel): + employee_id: int + employee_name: str + dept_id: int + dept_name: str | None = None + mobile: str | None = None + job_title: str | None = None + username: str | None = None + role_names: list[str] = Field(default_factory=list) + permission_codes: list[str] = Field(default_factory=list) + has_system_user: bool = False +``` + +In `backend/app/schemas/database.py`, replace `LoginUser` with: + +```python +class LoginUser(BaseModel): + user_id: int + username: str + employee_id: int + mobile: str | None = None + display_name: str + title: str + dept_name: str | None = None + role_codes: list[str] + role_names: list[str] + permission_codes: list[str] +``` + +- [ ] **Step 2: Add service helpers** + +In `backend/app/services/system_permissions.py`, update imports: + +```python +from sqlalchemy import delete, exists, func, or_, select +``` + +Add `MENU_EQUIPMENT_MANAGEMENT` to `MENU_PERMISSION_TREE` under a suitable module. The current route treats it as a standalone page; place it near production/operations-facing pages: + +```python + ( + "MENU_PRODUCTION", + "生产执行", + [ + ("MENU_WORK_ORDER", "工单台账", []), + ("MENU_OPERATION_REPORT", "工序报工", []), + ("MENU_EQUIPMENT_MANAGEMENT", "设备管理", []), + ], + ), +``` + +Add these helpers near the existing employee/role helper functions: + +```python +def _permission_by_code(db: Session, permission_code: str) -> Permission: + permission = db.scalar( + select(Permission).where( + Permission.permission_code == permission_code, + Permission.status == "ACTIVE", + ) + ) + if not permission: + raise HTTPException(status_code=400, detail=f"权限码不存在或已停用:{permission_code}") + return permission + + +def _employee_role_permission_maps( + db: Session, + employee_ids: list[int], +) -> tuple[dict[int, User], dict[int, list[str]], dict[int, list[str]]]: + if not employee_ids: + return {}, {}, {} + users = db.scalars( + select(User).where( + User.employee_id.in_(employee_ids), + User.status == "ACTIVE", + ) + ).all() + user_by_employee_id = {user.employee_id: user for user in users} + if not users: + return {}, {}, {} + rows = db.execute( + select(User.employee_id, Role.role_name, Permission.permission_code) + .join(UserRole, UserRole.user_id == User.id) + .join(Role, Role.id == UserRole.role_id) + .outerjoin(RolePermission, RolePermission.role_id == Role.id) + .outerjoin(Permission, Permission.id == RolePermission.permission_id) + .where( + User.id.in_([user.id for user in users]), + Role.status == "ACTIVE", + ) + .order_by(User.employee_id, Role.id, Permission.permission_code) + ).all() + role_map: dict[int, list[str]] = {} + permission_map: dict[int, list[str]] = {} + for employee_id, role_name, permission_code in rows: + if role_name and role_name not in role_map.setdefault(employee_id, []): + role_map[employee_id].append(role_name) + if permission_code and permission_code not in permission_map.setdefault(employee_id, []): + permission_map[employee_id].append(permission_code) + return user_by_employee_id, role_map, permission_map + + +def employee_has_permission(db: Session, employee_id: int, permission_code: str) -> bool: + permission = _permission_by_code(db, permission_code) + user = db.scalar( + select(User).where( + User.employee_id == employee_id, + User.status == "ACTIVE", + ) + ) + if not user: + return False + employee = db.get(Employee, employee_id) + if not employee or employee.status != "ACTIVE": + return False + if bool(user.is_super_admin): + return True + return bool( + db.scalar( + select( + exists() + .where(UserRole.user_id == user.id) + .where(UserRole.role_id == Role.id) + .where(Role.status == "ACTIVE") + .where(RolePermission.role_id == Role.id) + .where(RolePermission.permission_id == permission.id) + ) + ) + ) + + +def ensure_employee_has_permission( + db: Session, + employee_id: int | None, + permission_code: str, + field_label: str, + *, + required: bool = False, +) -> None: + permission = _permission_by_code(db, permission_code) + if not employee_id: + if required: + raise HTTPException(status_code=400, detail=f"{field_label}必填") + return + employee = db.get(Employee, employee_id) + if not employee or employee.status != "ACTIVE": + raise HTTPException(status_code=400, detail=f"{field_label}不存在或已停用") + if not employee_has_permission(db, employee_id, permission_code): + raise HTTPException( + status_code=400, + detail=f"{field_label}必须选择有“{permission.permission_name}”权限的人员", + ) +``` + +Replace the existing `list_employee_options` with: + +```python +def list_employee_options( + db: Session, + permission_code: str | None = None, + limit: int = 500, +) -> list[EmployeeOption]: + permission = _permission_by_code(db, permission_code) if permission_code else None + stmt = select(Employee).where(Employee.status == "ACTIVE") + if permission: + permitted_user_exists = ( + exists() + .where(User.employee_id == Employee.id) + .where(User.status == "ACTIVE") + .where( + or_( + User.is_super_admin == 1, + exists() + .where(UserRole.user_id == User.id) + .where(UserRole.role_id == Role.id) + .where(Role.status == "ACTIVE") + .where(RolePermission.role_id == Role.id) + .where(RolePermission.permission_id == permission.id), + ) + ) + ) + stmt = stmt.where(permitted_user_exists) + employees = sorted( + db.scalars(stmt.order_by(Employee.id).limit(limit)).all(), + key=_employee_name_sort_key, + ) + dept_map = {row.id: row.dept_name for row in db.scalars(select(Department)).all()} + employee_ids = [employee.id for employee in employees] + user_map, role_map, permission_map = _employee_role_permission_maps(db, employee_ids) + return [ + EmployeeOption( + employee_id=employee.id, + employee_name=employee.employee_name, + dept_id=employee.dept_id, + dept_name=dept_map.get(employee.dept_id), + mobile=employee.mobile, + job_title=employee.job_title, + username=user_map.get(employee.id).username if user_map.get(employee.id) else None, + role_names=role_map.get(employee.id, []), + permission_codes=permission_map.get(employee.id, []), + has_system_user=employee.id in user_map, + ) + for employee in employees + ] +``` + +- [ ] **Step 3: Expose authenticated business endpoint** + +In `backend/app/api/routes/master_data.py`, add the schema import: + +```python +from app.schemas.system_permissions import EmployeeOption +from app.services.system_permissions import list_employee_options +``` + +Add this route near the existing `list_employees` route: + +```python +@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) +``` + +In `backend/app/api/routes/system_permissions.py`, update imports: + +```python +from fastapi import APIRouter, Depends, HTTPException, Query +``` + +Update the admin endpoint to keep full-list behavior: + +```python +@router.get("/employee-options", response_model=list[EmployeeOption]) +def get_employee_options( + limit: int = Query(default=500, ge=1, le=500), + db: Session = Depends(get_db), +) -> list[EmployeeOption]: + return list_employee_options(db, limit=limit) +``` + +- [ ] **Step 4: Return employee ID in login session** + +In `backend/app/services/auth.py`, update `build_login_user`: + +```python +def build_login_user(context: AuthContext) -> LoginUser: + title = ROLE_TITLE_MAP.get(context.role_codes[0], "工业风管理台") if context.role_codes else "工业风管理台" + return LoginUser( + user_id=context.user.id, + username=context.user.username, + employee_id=context.employee.id, + mobile=context.employee.mobile, + display_name=context.user.nickname or context.employee.employee_name, + title=title, + dept_name=context.department.dept_name if context.department else None, + role_codes=context.role_codes, + role_names=context.role_names, + permission_codes=context.permission_codes, + ) +``` + +- [ ] **Step 5: Run backend permission tests** + +Run: + +```bash +cd backend +.venv/bin/python -m pytest tests/test_system_permission_management.py::SystemPermissionManagementTest::test_list_employee_options_filters_by_permission_code -q +.venv/bin/python -m pytest tests/test_system_permission_management.py::SystemPermissionManagementTest::test_ensure_employee_has_permission_rejects_unqualified_employee -q +``` + +Expected: + +```text +2 passed +``` + +- [ ] **Step 6: Run broader system permission tests** + +Run: + +```bash +cd backend +.venv/bin/python -m pytest tests/test_system_permission_management.py -q +``` + +Expected: + +```text +passed +``` + +- [ ] **Step 7: Checkpoint** + +If git is available, commit: + +```bash +git add backend/app/schemas/system_permissions.py backend/app/services/system_permissions.py backend/app/api/routes/master_data.py backend/app/api/routes/system_permissions.py backend/app/schemas/database.py backend/app/services/auth.py backend/tests/test_system_permission_management.py +git commit -m "feat: add permission-filtered employee options" +``` + +If git is not available in this mounted workspace, report the verified files instead of committing. + +--- + +### Task 3: Sales Order Sales-Person Backend Support + +**Files:** +- Modify: `backend/app/schemas/database.py` +- Modify: `backend/app/services/sales_planning.py` +- Modify: `backend/app/api/routes/sales.py` +- Modify: `backend/tests/test_sales_order_delivery_trace.py` + +- [ ] **Step 1: Add failing backend test for sales employee persistence** + +In `backend/tests/test_sales_order_delivery_trace.py`, add a sales permission/user seed in the test setup. Reuse the existing department/user pattern and add this helper to the test class: + +```python + def _seed_sales_employee_permission(self) -> Employee: + from app.models.org import Permission, Role, RolePermission, UserRole + + now = datetime(2026, 6, 8, 9, 0, 0) + permission = Permission( + permission_code="MENU_SALES_PLANNING", + permission_name="客户名录/销售订单菜单", + module_code="SALES_PLANNING", + action_code="VIEW", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + role = Role( + role_code="SALES", + role_name="销售人员", + role_scope="SYSTEM", + status="ACTIVE", + remark=None, + created_at=now, + updated_at=now, + ) + self.db.add_all([permission, role]) + self.db.flush() + self.db.add(RolePermission(role_id=role.id, permission_id=permission.id, created_at=now)) + self.db.add(UserRole(user_id=self.user.id, role_id=role.id, created_at=now)) + self.db.commit() + return self.employee +``` + +Add a test: + +```python + def test_create_sales_order_persists_sales_employee(self) -> None: + sales_employee = self._seed_sales_employee_permission() + + order_result = create_sales_order( + SalesOrderCreate( + customer_id=self.customer.id, + sales_employee_id=sales_employee.id, + promised_date=date.today(), + delivery_address=self.customer.address, + tax_rate=0, + items=[ + SalesOrderItemCreate( + product_item_id=self.product.id, + order_qty=3, + unit_price=12.5, + customer_part_no="C-PART-001", + ) + ], + ), + db=self.db, + ) + + order = self.db.get(SalesOrder, order_result.order_id) + self.assertEqual(order.sales_employee_id, sales_employee.id) +``` + +- [ ] **Step 2: Run test and verify failure** + +Run: + +```bash +cd backend +.venv/bin/python -m pytest tests/test_sales_order_delivery_trace.py::SalesOrderDeliveryTraceTest::test_create_sales_order_persists_sales_employee -q +``` + +Expected: + +```text +FAILED ... SalesOrderCreate got an unexpected keyword argument 'sales_employee_id' +``` + +- [ ] **Step 3: Extend sales schemas** + +In `backend/app/schemas/database.py`, add fields to `SalesOrderRead`: + +```python + sales_employee_id: int | None = None + sales_employee_name: str | None = None +``` + +Add field to `SalesOrderCreate`: + +```python + sales_employee_id: int | None = None +``` + +- [ ] **Step 4: Return sales employee in sales order query** + +In `backend/app/services/sales_planning.py`, import `Employee`: + +```python +from app.models.org import Employee +``` + +Inside `get_sales_order_query`, add an alias and select fields: + +```python + sales_employee = aliased(Employee) +``` + +Add these selected labels: + +```python + SalesOrder.sales_employee_id.label("sales_employee_id"), + sales_employee.employee_name.label("sales_employee_name"), +``` + +Add this join: + +```python + .outerjoin(sales_employee, sales_employee.id == SalesOrder.sales_employee_id) +``` + +Ensure `aliased` is imported: + +```python +from sqlalchemy.orm import aliased +``` + +- [ ] **Step 5: Validate and persist sales employee** + +In `backend/app/api/routes/sales.py`, import: + +```python +from app.services.system_permissions import ensure_employee_has_permission +``` + +In `create_sales_order`, after customer validation and before creating `SalesOrder`, add: + +```python + ensure_employee_has_permission( + db, + payload.sales_employee_id, + "MENU_SALES_PLANNING", + "销售人员", + required=True, + ) +``` + +Set the field on `SalesOrder`: + +```python + sales_employee_id=payload.sales_employee_id, +``` + +- [ ] **Step 6: Update existing sales-order backend tests** + +Every existing `SalesOrderCreate(...)` in `backend/tests/test_sales_order_delivery_trace.py` must pass: + +```python +sales_employee_id=self._seed_sales_employee_permission().id, +``` + +If multiple tests need it, call `_seed_sales_employee_permission()` once per test before creating the order: + +```python +sales_employee = self._seed_sales_employee_permission() +``` + +Then use: + +```python +sales_employee_id=sales_employee.id, +``` + +- [ ] **Step 7: Run sales tests** + +Run: + +```bash +cd backend +.venv/bin/python -m pytest tests/test_sales_order_delivery_trace.py -q +``` + +Expected: + +```text +passed +``` + +- [ ] **Step 8: Checkpoint** + +If git is available, commit: + +```bash +git add backend/app/schemas/database.py backend/app/services/sales_planning.py backend/app/api/routes/sales.py backend/tests/test_sales_order_delivery_trace.py +git commit -m "feat: record sales person on sales orders" +``` + +If git is not available, report the verified files. + +--- + +### Task 4: Backend Business Personnel Validation + +**Files:** +- Modify: `backend/app/api/routes/purchase.py` +- Modify: `backend/app/api/routes/inventory.py` +- Modify: `backend/app/api/routes/production.py` +- Modify: `backend/app/api/routes/sales.py` +- Modify: `backend/app/api/routes/returns.py` +- Modify: `backend/app/api/routes/equipment.py` +- Test: `backend/tests/test_system_permission_management.py` + +- [ ] **Step 1: Add validator unit test for optional fields** + +In `backend/tests/test_system_permission_management.py`, add: + +```python + def test_optional_personnel_validator_allows_empty_value(self) -> None: + from app.services.system_permissions import ensure_employee_has_permission + + self._seed_permission_employee_selector_data() + + ensure_employee_has_permission( + self.db, + None, + "MENU_PURCHASE_ORDER", + "采购员", + required=False, + ) +``` + +- [ ] **Step 2: Run test** + +Run: + +```bash +cd backend +.venv/bin/python -m pytest tests/test_system_permission_management.py::SystemPermissionManagementTest::test_optional_personnel_validator_allows_empty_value -q +``` + +Expected: + +```text +passed +``` + +- [ ] **Step 3: Validate purchase personnel** + +In `backend/app/api/routes/purchase.py`, import: + +```python +from app.services.system_permissions import ensure_employee_has_permission +``` + +In purchase order create and update paths, before saving `purchaser_employee_id`, add: + +```python + ensure_employee_has_permission( + db, + payload.purchaser_employee_id, + "MENU_PURCHASE_ORDER", + "采购员", + ) +``` + +In purchase receipt create path, before saving `receiver_employee_id`, add: + +```python + ensure_employee_has_permission( + db, + payload.receiver_employee_id, + "MENU_PURCHASE_RECEIPT", + "接收人", + ) +``` + +- [ ] **Step 4: Validate warehouse personnel** + +In `backend/app/api/routes/inventory.py`, import: + +```python +from app.services.system_permissions import ensure_employee_has_permission +``` + +For warehouse return inbound handlers: + +```python + ensure_employee_has_permission( + db, + payload.handler_employee_id, + "MENU_INVENTORY_LEDGER", + "经办人", + ) +``` + +For warehouse completion receiver: + +```python + ensure_employee_has_permission( + db, + payload.receiver_employee_id, + "MENU_INVENTORY_LEDGER", + "接收人", + ) +``` + +For warehouse sales-out shipper: + +```python + ensure_employee_has_permission( + db, + payload.shipper_employee_id, + "MENU_INVENTORY_LEDGER", + "发货人", + ) +``` + +- [ ] **Step 5: Validate production completion receiver** + +In `backend/app/api/routes/production.py`, import: + +```python +from app.services.system_permissions import ensure_employee_has_permission +``` + +Before creating completion/production inbound records with `receiver_employee_id`, add: + +```python + ensure_employee_has_permission( + db, + payload.receiver_employee_id, + "MENU_INVENTORY_LEDGER", + "接收人", + ) +``` + +- [ ] **Step 6: Validate delivery overview personnel** + +In `backend/app/api/routes/sales.py`, before creating delivery with `shipper_employee_id`, add: + +```python + ensure_employee_has_permission( + db, + payload.shipper_employee_id, + "MENU_DELIVERY_MANAGEMENT", + "发货人", + ) +``` + +- [ ] **Step 7: Validate return handler** + +In `backend/app/api/routes/returns.py`, import: + +```python +from app.services.system_permissions import ensure_employee_has_permission +``` + +For return handler fields that are currently operated through warehouse return flows, add: + +```python + ensure_employee_has_permission( + db, + payload.handler_employee_id, + "MENU_INVENTORY_LEDGER", + "经办人", + ) +``` + +- [ ] **Step 8: Validate equipment owner** + +In `backend/app/api/routes/equipment.py`, import: + +```python +from app.services.system_permissions import ensure_employee_has_permission +``` + +Before saving `owner_employee_id`, add: + +```python + ensure_employee_has_permission( + db, + payload.owner_employee_id, + "MENU_EQUIPMENT_MANAGEMENT", + "负责人", + ) +``` + +- [ ] **Step 9: Run backend regression tests** + +Run: + +```bash +cd backend +.venv/bin/python -m pytest tests/test_system_permission_management.py -q +.venv/bin/python -m pytest tests/test_sales_order_delivery_trace.py -q +``` + +Expected: + +```text +passed +``` + +- [ ] **Step 10: Checkpoint** + +If git is available, commit: + +```bash +git add backend/app/api/routes/purchase.py backend/app/api/routes/inventory.py backend/app/api/routes/production.py backend/app/api/routes/sales.py backend/app/api/routes/returns.py backend/app/api/routes/equipment.py backend/tests/test_system_permission_management.py +git commit -m "feat: validate personnel fields by page permission" +``` + +If git is not available, report the verified files. + +--- + +### Task 5: Frontend Shared Employee Option Service + +**Files:** +- Create: `frontend/src/services/employeeOptions.js` +- Create: `frontend/scripts/test-permission-employee-selectors.mjs` + +- [ ] **Step 1: Write failing frontend selector test** + +Create `frontend/scripts/test-permission-employee-selectors.mjs`: + +```javascript +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +function read(file) { + return fs.readFileSync(path.resolve(file), "utf8"); +} + +const service = read("src/services/employeeOptions.js"); +const sales = read("src/views/SalesPlanningView.vue"); +const purchaseOrder = read("src/views/PurchaseOrderView.vue"); +const purchaseReceipt = read("src/views/PurchaseReceiptView.vue"); +const inventory = read("src/views/InventoryLedgerView.vue"); +const completion = read("src/views/CompletionReceiptView.vue"); +const returns = read("src/views/ReturnManagementView.vue"); +const equipment = read("src/views/EquipmentManagementView.vue"); + +assert.match(service, /PERSONNEL_PERMISSION_CODES/, "employee option service should define permission constants"); +assert.match(service, /\/master-data\/employee-options\?/, "business selectors should use authenticated master-data option endpoint"); +assert.match(sales, />销售人员 String(employee.employee_id) === String(currentEmployeeId)); + return matched ? matched.employee_id : ""; +} + +export function employeeOptionLabel(employee) { + const extra = [employee.dept_name, employee.role_names?.join("、")].filter(Boolean).join(" · "); + return extra ? `${employee.employee_name} · ${extra}` : employee.employee_name; +} +``` + +- [ ] **Step 4: Run script and verify remaining failures** + +Run: + +```bash +cd frontend +node scripts/test-permission-employee-selectors.mjs +``` + +Expected: + +```text +AssertionError ... sales order drawer should contain a sales person field +``` + +- [ ] **Step 5: Checkpoint** + +If git is available, commit: + +```bash +git add frontend/src/services/employeeOptions.js frontend/scripts/test-permission-employee-selectors.mjs +git commit -m "feat: add permission employee option frontend service" +``` + +If git is not available, report the verified files. + +--- + +### Task 6: Frontend Sales Order Personnel Selector + +**Files:** +- Modify: `frontend/src/views/SalesPlanningView.vue` + +- [ ] **Step 1: Import helper and add state** + +In the script section, import: + +```javascript +import { + PERSONNEL_PERMISSION_CODES, + defaultEmployeeIdFromSession, + employeeOptionLabel, + fetchPermissionEmployeeOptions +} from "../services/employeeOptions"; +``` + +Add state near other refs: + +```javascript +const salesEmployeeOptions = ref([]); +``` + +Add `sales_employee_id` to `buildEmptySalesOrderForm()`: + +```javascript + sales_employee_id: "", +``` + +- [ ] **Step 2: Load sales employee options** + +In the existing data-loading function that fetches customers/products/orders, add: + +```javascript +const salesEmployeeRows = await fetchPermissionEmployeeOptions(PERSONNEL_PERMISSION_CODES.SALES_ORDER); +salesEmployeeOptions.value = salesEmployeeRows; +if (!salesOrderForm.sales_employee_id) { + salesOrderForm.sales_employee_id = defaultEmployeeIdFromSession(salesEmployeeRows); +} +``` + +If the load function already uses `Promise.all`, add `fetchPermissionEmployeeOptions(PERSONNEL_PERMISSION_CODES.SALES_ORDER)` as one promise and assign it beside the other rows. + +- [ ] **Step 3: Add sales-person field to drawer** + +In the sales order drawer header fields, change the first `triple-field` to support one more selector by either using the existing responsive grid class or splitting into two rows. Add: + +```vue + +``` + +- [ ] **Step 4: Submit sales employee ID** + +In `submitSalesOrder`, add to the payload: + +```javascript +sales_employee_id: salesOrderForm.sales_employee_id ? Number(salesOrderForm.sales_employee_id) : null, +``` + +- [ ] **Step 5: Show sales employee in sales order list** + +Add a `销售人员` column after `客户`: + +```vue +
+``` + +Add the cell: + +```vue + +``` + +Increase the empty-row colspan by 1. + +- [ ] **Step 6: Run frontend selector script** + +Run: + +```bash +cd frontend +node scripts/test-permission-employee-selectors.mjs +``` + +Expected: + +```text +AssertionError ... PurchaseOrderView should not fetch the unfiltered full employee list +``` + +- [ ] **Step 7: Checkpoint** + +If git is available, commit: + +```bash +git add frontend/src/views/SalesPlanningView.vue +git commit -m "feat: add sales person selector to sales orders" +``` + +If git is not available, report the verified file. + +--- + +### Task 7: Frontend Business Selector Migration + +**Files:** +- Modify: `frontend/src/views/PurchaseOrderView.vue` +- Modify: `frontend/src/views/PurchaseReceiptView.vue` +- Modify: `frontend/src/views/InventoryLedgerView.vue` +- Modify: `frontend/src/views/CompletionReceiptView.vue` +- Modify: `frontend/src/views/ReturnManagementView.vue` +- Modify: `frontend/src/views/EquipmentManagementView.vue` + +- [ ] **Step 1: Migrate purchase order purchaser** + +In `frontend/src/views/PurchaseOrderView.vue`, import: + +```javascript +import { + PERSONNEL_PERMISSION_CODES, + employeeOptionLabel, + fetchPermissionEmployeeOptions +} from "../services/employeeOptions"; +``` + +Replace the employee fetch: + +```javascript +fetchResource("/master-data/employees?limit=200", []) +``` + +with: + +```javascript +fetchPermissionEmployeeOptions(PERSONNEL_PERMISSION_CODES.PURCHASE_ORDER, 200) +``` + +Replace the option display: + +```vue +{{ employee.employee_name }} +``` + +with: + +```vue +{{ employeeOptionLabel(employee) }} +``` + +- [ ] **Step 2: Migrate purchase receipt receiver** + +In `frontend/src/views/PurchaseReceiptView.vue`, import: + +```javascript +import { + PERSONNEL_PERMISSION_CODES, + employeeOptionLabel, + fetchPermissionEmployeeOptions +} from "../services/employeeOptions"; +``` + +Replace: + +```javascript +fetchResource("/master-data/employees", []) +``` + +with: + +```javascript +fetchPermissionEmployeeOptions(PERSONNEL_PERMISSION_CODES.PURCHASE_RECEIPT) +``` + +Replace receiver option label with: + +```vue +{{ employeeOptionLabel(employee) }} +``` + +- [ ] **Step 3: Migrate inventory ledger personnel fields** + +In `frontend/src/views/InventoryLedgerView.vue`, import: + +```javascript +import { + PERSONNEL_PERMISSION_CODES, + employeeOptionLabel, + fetchPermissionEmployeeOptions +} from "../services/employeeOptions"; +``` + +Replace: + +```javascript +fetchResource("/master-data/employees?limit=500", []) +``` + +with: + +```javascript +fetchPermissionEmployeeOptions(PERSONNEL_PERMISSION_CODES.INVENTORY_LEDGER, 500) +``` + +Replace every business personnel option label in this file: + +```vue +{{ employee.employee_name }} +``` + +with: + +```vue +{{ employeeOptionLabel(employee) }} +``` + +Only replace labels inside the existing personnel selects using `employees`. Do not change material/product/lot option labels. + +- [ ] **Step 4: Migrate completion receipt receiver** + +In `frontend/src/views/CompletionReceiptView.vue`, import: + +```javascript +import { + PERSONNEL_PERMISSION_CODES, + employeeOptionLabel, + fetchPermissionEmployeeOptions +} from "../services/employeeOptions"; +``` + +Replace: + +```javascript +fetchResource("/master-data/employees", []) +``` + +with: + +```javascript +fetchPermissionEmployeeOptions(PERSONNEL_PERMISSION_CODES.INVENTORY_LEDGER) +``` + +Replace receiver option label with: + +```vue +{{ employeeOptionLabel(employee) }} +``` + +- [ ] **Step 5: Migrate return handler** + +In `frontend/src/views/ReturnManagementView.vue`, import: + +```javascript +import { + PERSONNEL_PERMISSION_CODES, + employeeOptionLabel, + fetchPermissionEmployeeOptions +} from "../services/employeeOptions"; +``` + +Replace: + +```javascript +fetchResource("/master-data/employees", []) +``` + +with: + +```javascript +fetchPermissionEmployeeOptions(PERSONNEL_PERMISSION_CODES.INVENTORY_LEDGER) +``` + +Replace handler option label with: + +```vue +{{ employeeOptionLabel(employee) }} +``` + +- [ ] **Step 6: Migrate equipment owner** + +In `frontend/src/views/EquipmentManagementView.vue`, import: + +```javascript +import { + PERSONNEL_PERMISSION_CODES, + employeeOptionLabel, + fetchPermissionEmployeeOptions +} from "../services/employeeOptions"; +``` + +Replace: + +```javascript +fetchResource("/master-data/employees", []) +``` + +with: + +```javascript +fetchPermissionEmployeeOptions(PERSONNEL_PERMISSION_CODES.EQUIPMENT_MANAGEMENT) +``` + +Replace owner option label with: + +```vue +{{ employeeOptionLabel(employee) }} +``` + +- [ ] **Step 7: Run frontend selector script** + +Run: + +```bash +cd frontend +node scripts/test-permission-employee-selectors.mjs +``` + +Expected: + +```text +permission-filtered employee selector checks passed +``` + +- [ ] **Step 8: Check system permission page remains unrestricted** + +Run: + +```bash +cd frontend +node scripts/test-system-permission-management-ui.mjs +``` + +Expected: + +```text +system permission management ui checks passed +``` + +- [ ] **Step 9: Checkpoint** + +If git is available, commit: + +```bash +git add frontend/src/views/PurchaseOrderView.vue frontend/src/views/PurchaseReceiptView.vue frontend/src/views/InventoryLedgerView.vue frontend/src/views/CompletionReceiptView.vue frontend/src/views/ReturnManagementView.vue frontend/src/views/EquipmentManagementView.vue frontend/scripts/test-permission-employee-selectors.mjs +git commit -m "feat: filter business personnel selectors by page permission" +``` + +If git is not available, report the verified files. + +--- + +### Task 8: Final Verification And Manual QA + +**Files:** +- No planned code edits. + +- [ ] **Step 1: Run backend focused tests** + +Run: + +```bash +cd backend +.venv/bin/python -m pytest tests/test_system_permission_management.py tests/test_sales_order_delivery_trace.py -q +``` + +Expected: + +```text +passed +``` + +- [ ] **Step 2: Run frontend static checks** + +Run: + +```bash +cd frontend +node scripts/test-permission-employee-selectors.mjs +node scripts/test-system-permission-management-ui.mjs +``` + +Expected: + +```text +permission-filtered employee selector checks passed +system permission management ui checks passed +``` + +- [ ] **Step 3: Build frontend** + +Run: + +```bash +cd frontend +npm run build +``` + +Expected: + +```text +✓ built +``` + +- [ ] **Step 4: Manual browser QA** + +Start or use the existing local app, log in as an admin, and verify: + +```text +1. 系统权限管理 -> 角色维护:设备管理权限能看到并勾选。 +2. 系统权限管理 -> 组织架构:新增人员、绑定主管仍然能看到基础资料人员全集。 +3. 销售订单新增:能看到“销售人员”,选项只包含有客户名录/销售订单权限的人。 +4. 采购订单新增:采购员只包含有采购订单权限的人。 +5. 到货入库:接收人只包含有到货入库权限的人。 +6. 嘉恒仓库内接收人/经办人/发货人只包含有嘉恒仓库权限的人。 +7. 设备管理负责人只包含有设备管理权限的人。 +8. 尝试用接口提交一个无权限人员 ID,后端返回中文错误,不保存单据。 +``` + +- [ ] **Step 5: Final checkpoint** + +If git is available, commit any remaining test updates: + +```bash +git status --short +git add backend frontend +git commit -m "test: verify permission-filtered personnel selectors" +``` + +If git is not available, report: + +```text +当前挂载目录不是 git 仓库,已跳过提交。已完成测试命令和结果见本次执行记录。 +``` + +--- + +## Self-Review + +**Spec coverage** + +- Business personnel selectors are filtered by page permission: Tasks 2, 5, 6, 7. +- Sales order creation gets a sales-person dropdown: Tasks 3 and 6. +- Purchase, warehouse, receipt, delivery, return, equipment personnel fields are included: Tasks 4 and 7. +- System permission management keeps full personnel list: Tasks 2 and 8. +- Backend does not rely on frontend-only filtering: Tasks 2, 3, and 4. +- Equipment permission inconsistency is handled: Task 2. + +**Placeholder scan** + +- The plan contains no deferred behavior. Each business page and backend route has an explicit permission mapping. + +**Type consistency** + +- Backend employee option uses `employee_id`, `employee_name`, `role_names`, and `permission_codes`. +- Frontend normalizes options to both `id` and `employee_id`, so existing ` +``` + +with: + +```vue + +``` + +Import: + +```js +import StatusBadge from "../components/StatusBadge.vue"; +``` + +- [ ] **Step 6: Verify** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +npm run build +``` + +Expected: + +```text +✓ built +``` + +Browser checks: + +```text +Open /purchase-orders and confirm English statuses are not visible. +Open /production-ledger and confirm 锁单 uses a warning/amber badge consistently. +Open /delivery-management and confirm 已过账 uses a success badge consistently. +Open /system-permissions and confirm 启用/停用 have different colors. +``` + +- [ ] **Step 7: Commit** + +```bash +git add frontend/src/utils/statusToneMap.js frontend/src/components/StatusBadge.vue frontend/src/utils/statusBadges.js frontend/src/styles/main.css frontend/src/views/ProductionLedgerView.vue frontend/src/views/DeliveryManagementView.vue frontend/src/views/QualityInspectionView.vue frontend/src/views/SystemPermissionView.vue +git commit -m "style: unify Chinese status badges" +``` + +--- + +### Task 6: Data Table System Upgrade + +**Files:** +- Modify: `frontend/src/components/TableControls.vue` +- Modify: `frontend/src/components/DrawerDataTable.vue` +- Modify: `frontend/src/styles/main.css` +- Modify: high-frequency views using `.data-table` + +- [ ] **Step 1: Replace raw sort arrow text** + +In `frontend/src/components/TableControls.vue`, update `refreshHeaderStates()`: + +```js +if (arrow) { + arrow.innerHTML = isActive + ? (props.sortDirection === "asc" ? "↑" : "↓") + : ""; + arrow.setAttribute("aria-label", isActive ? (props.sortDirection === "asc" ? "升序" : "降序") : "可排序"); + arrow.classList.toggle("sort-arrow-active", isActive); +} +``` + +When creating the arrow, use: + +```js +const arrow = document.createElement("span"); +arrow.className = "sort-arrow"; +arrow.setAttribute("aria-hidden", "true"); +arrow.textContent = ""; +``` + +- [ ] **Step 2: Add professional table CSS** + +Append to `frontend/src/styles/main.css`: + +```css +.table-wrap, +.drawer-data-table-wrap { + position: relative; + overflow: auto; + border: 1px solid var(--ui-line); + border-radius: var(--ui-radius-lg); + background: var(--ui-bg-panel-solid); + box-shadow: var(--ui-shadow-card); +} + +.table-wrap::after, +.drawer-data-table-wrap::after { + content: ""; + position: sticky; + right: 0; + top: 0; + float: right; + width: 28px; + height: 1px; + pointer-events: none; + box-shadow: -18px 0 24px rgba(15, 23, 42, 0.08); +} + +.data-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + table-layout: auto; + color: var(--ui-ink); +} + +.data-table th { + position: sticky; + top: 0; + z-index: 2; + height: 42px; + padding: 0 14px; + color: var(--ui-ink-soft); + background: linear-gradient(180deg, #f8fafc, #eef3f8); + border-bottom: 1px solid var(--ui-line-strong); + font-size: 12px; + font-weight: 900; + text-align: left; + white-space: nowrap; +} + +.data-table td { + height: 44px; + padding: 0 14px; + border-bottom: 1px solid rgba(58, 82, 105, 0.08); + color: var(--ui-ink); + font-size: 13px; + vertical-align: middle; +} + +.data-table tbody tr { + transition: background var(--ui-transition-fast), box-shadow var(--ui-transition-fast); +} + +.data-table tbody tr:hover td { + background: rgba(37, 99, 235, 0.045); +} + +.sortable-th-button { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + border: 0; + padding: 0; + color: inherit; + background: transparent; + font-weight: inherit; + cursor: pointer; +} + +.sort-arrow { + display: inline-grid; + place-items: center; + width: 16px; + height: 16px; + border-radius: 6px; + color: var(--ui-ink-muted); + background: rgba(100, 116, 139, 0.08); +} + +.sort-arrow:empty::before { + content: ""; + width: 8px; + height: 8px; + border: 1.5px solid currentColor; + border-left: 0; + border-bottom: 0; + transform: rotate(135deg); + opacity: 0.42; +} + +.sortable-th-active .sort-arrow { + color: #fff; + background: var(--ui-steel); +} +``` + +- [ ] **Step 3: Add table density rules** + +Append: + +```css +.compact-table th { + height: 38px; +} + +.compact-table td { + height: 40px; + font-size: 12.5px; +} + +.data-table .link-button, +.data-table .strong-link { + color: var(--ui-steel); + font-weight: 800; + text-decoration: none; +} + +.data-table .link-button:hover, +.data-table .strong-link:hover { + text-decoration: underline; + text-underline-offset: 3px; +} + +.empty-row { + height: 96px; + color: var(--ui-ink-muted); + text-align: center; + background: linear-gradient(135deg, rgba(248, 250, 252, 0.9), rgba(241, 245, 249, 0.9)); +} +``` + +- [ ] **Step 4: Improve drawer table compact labels** + +Modify `frontend/src/components/DrawerDataTable.vue` header cell: + +```vue + +``` + +Ensure CSS: + +```css +.drawer-table-head-label { + display: inline-block; + max-width: 140px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: bottom; +} + +.drawer-cell-ellipsis { + display: inline-block; + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: bottom; +} +``` + +- [ ] **Step 5: Verify** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +npm run build +``` + +Expected: + +```text +✓ built +``` + +Browser checks: + +```text +Open / and confirm no visible ↕ remains in table headers. +Open /purchase-orders and confirm table remains readable. +Open /finance-reports and confirm large table still scrolls horizontally but has cleaner header treatment. +Open a detail drawer with drawer table and confirm headers do not turn vertical or wrap awkwardly. +``` + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/components/TableControls.vue frontend/src/components/DrawerDataTable.vue frontend/src/styles/main.css +git commit -m "style: upgrade data table readability" +``` + +--- + +### Task 7: Smart Drawer And Form Section System + +**Files:** +- Create: `frontend/src/components/SmartDrawer.vue` +- Create: `frontend/src/components/FormSection.vue` +- Create: `frontend/src/components/MetricStrip.vue` +- Modify: `frontend/src/components/FormDrawer.vue` +- Modify: `frontend/src/styles/main.css` + +- [ ] **Step 1: Create SmartDrawer** + +Create `frontend/src/components/SmartDrawer.vue`: + +```vue + + + +``` + +- [ ] **Step 2: Create FormSection** + +Create `frontend/src/components/FormSection.vue`: + +```vue + + + +``` + +- [ ] **Step 3: Create MetricStrip** + +Create `frontend/src/components/MetricStrip.vue`: + +```vue + + + +``` + +- [ ] **Step 4: Add drawer and form CSS** + +Append to `frontend/src/styles/main.css`: + +```css +.smart-drawer-mask { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + justify-content: flex-end; + padding: 14px; + background: rgba(15, 23, 42, 0.34); + backdrop-filter: blur(8px); +} + +.smart-drawer { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + width: min(1080px, calc(100vw - 28px)); + height: calc(100vh - 28px); + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.66); + border-radius: 24px; + background: rgba(248, 250, 252, 0.96); + box-shadow: var(--ui-shadow-float); + animation: smartDrawerIn var(--ui-transition-normal); +} + +.smart-drawer-xl { + width: min(1280px, calc(100vw - 28px)); +} + +.smart-drawer-fullscreen { + width: calc(100vw - 28px); +} + +@keyframes smartDrawerIn { + from { + opacity: 0; + transform: translateX(18px) scale(0.99); + } + to { + opacity: 1; + transform: translateX(0) scale(1); + } +} + +.smart-drawer-head, +.smart-drawer-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px 22px; + background: rgba(255, 255, 255, 0.86); + border-bottom: 1px solid var(--ui-line); +} + +.smart-drawer-footer { + justify-content: flex-end; + border-top: 1px solid var(--ui-line); + border-bottom: 0; + box-shadow: 0 -12px 28px rgba(15, 23, 42, 0.06); +} + +.smart-drawer-body { + min-height: 0; + overflow: auto; + padding: 20px 22px; +} + +.smart-drawer-title-block h3 { + margin: 0; + color: var(--ui-ink); + font-size: 22px; + letter-spacing: -0.02em; +} + +.smart-drawer-description { + max-width: 760px; + margin: 6px 0 0; + color: var(--ui-ink-muted); + font-size: 13px; + line-height: 1.5; +} + +.form-section { + padding: 18px; + border: 1px solid var(--ui-line); + border-radius: var(--ui-radius-lg); + background: rgba(255, 255, 255, 0.9); + box-shadow: 0 10px 28px rgba(15, 23, 42, 0.05); +} + +.form-section + .form-section { + margin-top: 16px; +} + +.form-section-head { + display: flex; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; +} + +.form-section-head h4 { + margin: 0; + font-size: 15px; + color: var(--ui-ink); +} + +.form-section-head p { + margin: 4px 0 0; + color: var(--ui-ink-muted); + font-size: 12px; +} + +.form-section-grid { + display: grid; + gap: 14px; +} + +.form-section-grid-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.form-section-grid-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.form-section label { + display: grid; + gap: 7px; + color: var(--ui-ink-soft); + font-size: 12px; + font-weight: 800; +} + +.form-section input, +.form-section select, +.form-section textarea { + min-height: 38px; + width: 100%; + border: 1px solid var(--ui-line); + border-radius: 12px; + padding: 0 12px; + color: var(--ui-ink); + background: rgba(255, 255, 255, 0.96); +} + +.form-section textarea { + min-height: 84px; + padding: 10px 12px; + resize: vertical; +} + +.metric-strip { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 10px; +} + +.metric-strip-item { + padding: 12px; + border: 1px solid var(--ui-line); + border-radius: 14px; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.94), rgba(241, 245, 249, 0.9)); +} + +.metric-strip-item span, +.metric-strip-item small { + display: block; + color: var(--ui-ink-muted); + font-size: 12px; +} + +.metric-strip-item strong { + display: block; + margin-top: 4px; + color: var(--ui-ink); + font-size: 18px; +} + +@media (max-width: 768px) { + .smart-drawer-mask { + padding: 0; + } + + .smart-drawer { + width: 100vw; + height: 100vh; + border-radius: 0; + } + + .form-section-grid-2, + .form-section-grid-3 { + grid-template-columns: 1fr; + } +} +``` + +- [ ] **Step 5: Keep `FormDrawer` backward compatible** + +Do not replace all drawers at once. Update `FormDrawer.vue` only with improved header and body classes. Future page tasks will migrate complex drawers to `SmartDrawer`. + +- [ ] **Step 6: Verify** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +npm run build +``` + +Expected: + +```text +✓ built +``` + +Browser checks: + +```text +Open existing drawers and confirm no drawer became invisible. +At 390x844, drawer becomes full-screen instead of a narrow broken side panel. +At 1440x900, drawer has stable width and fixed action area. +``` + +- [ ] **Step 7: Commit** + +```bash +git add frontend/src/components/SmartDrawer.vue frontend/src/components/FormSection.vue frontend/src/components/MetricStrip.vue frontend/src/components/FormDrawer.vue frontend/src/styles/main.css +git commit -m "style: add smart drawer form system" +``` + +--- + +### Task 8: Searchable Select And Long Option Upgrade + +**Files:** +- Modify: `frontend/src/components/GlobalSearchableSelect.vue` +- Modify: `frontend/src/components/StockLotTagSelect.vue` +- Modify: `frontend/src/styles/main.css` + +- [ ] **Step 1: Parse option metadata for display** + +In `frontend/src/components/GlobalSearchableSelect.vue`, replace `collectOptions` with metadata-aware parsing: + +```js +function splitOptionLabel(label) { + const parts = cleanOptionLabel(label).split(" · ").filter(Boolean); + return { + title: parts[0] || label, + subtitle: parts.slice(1, 3).join(" · "), + meta: parts.slice(3).join(" · ") + }; +} + +function collectOptions(selectEl) { + return Array.from(selectEl.options || []).map((option, index) => { + const label = cleanOptionLabel(option.textContent); + return { + index, + value: option.value, + label, + display: splitOptionLabel(label), + disabled: Boolean(option.disabled || option.parentElement?.disabled), + selected: index === selectEl.selectedIndex + }; + }); +} +``` + +- [ ] **Step 2: Update option template** + +Replace the option button content with: + +```vue +{{ option.display.title || option.label || option.value || "空选项" }} +{{ option.display.subtitle }} +{{ option.display.meta }} +``` + +- [ ] **Step 3: Add select panel styles** + +Append to `frontend/src/styles/main.css`: + +```css +.global-select-layer { + z-index: 2000; +} + +.global-select-input-box { + border-radius: 12px; + box-shadow: var(--ui-shadow-card); +} + +.global-select-search-input { + border: 1px solid var(--ui-line-strong); + border-radius: 12px; + padding: 0 36px 0 12px; + color: var(--ui-ink); + background: #fff; +} + +.global-select-panel { + overflow: hidden; + border: 1px solid var(--ui-line-strong); + border-radius: 16px; + background: rgba(255, 255, 255, 0.98); + box-shadow: var(--ui-shadow-float); +} + +.global-select-option-list { + max-height: inherit; + overflow: auto; + padding: 6px; +} + +.global-select-option { + display: grid; + gap: 3px; + width: 100%; + border: 0; + border-radius: 12px; + padding: 10px 12px; + color: var(--ui-ink); + background: transparent; + text-align: left; +} + +.global-select-option:hover, +.global-select-option-active { + background: rgba(37, 99, 235, 0.08); +} + +.global-select-option-selected { + color: var(--ui-steel); + background: rgba(37, 99, 235, 0.12); +} + +.global-select-option-main { + font-weight: 900; +} + +.global-select-option-sub, +.global-select-option-meta { + color: var(--ui-ink-muted); + font-size: 12px; + font-style: normal; + line-height: 1.35; +} + +.global-select-option-disabled { + opacity: 0.45; + cursor: not-allowed; +} +``` + +- [ ] **Step 4: Align `StockLotTagSelect` dropdown behavior** + +Modify `frontend/src/components/StockLotTagSelect.vue` so every selected lot closes the dropdown and requires the user to click the input again to add another lot: + +```js +function selectLot(lot) { + if (!lot || isSelected(lot) || isDisabled(lot)) { + return; + } + emit("update:modelValue", [...props.modelValue, lot.lot_no]); + closeDropdown(); +} +``` + +Keep searchable behavior and selected tags. + +- [ ] **Step 5: Verify** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +npm run build +``` + +Expected: + +```text +✓ built +``` + +Browser checks: + +```text +Open purchase order drawer and click 原材料 select. +Confirm long options display as title/subtitle/meta instead of one huge line. +Open a warehouse stock lot multi-select. +Select one lot and confirm dropdown closes. +Click input again and confirm dropdown reopens for next selection. +``` + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/components/GlobalSearchableSelect.vue frontend/src/components/StockLotTagSelect.vue frontend/src/styles/main.css +git commit -m "style: improve searchable select option readability" +``` + +--- + +### Task 9: Warehouse Page Visual Upgrade + +**Files:** +- Modify: `frontend/src/views/InventoryLedgerView.vue` +- Modify: `frontend/src/styles/main.css` +- Use: `AppIcon.vue`, `StatusBadge.vue`, `SmartDrawer.vue`, `MetricStrip.vue` where practical + +- [ ] **Step 1: Rebuild warehouse selector as cards** + +In `frontend/src/views/InventoryLedgerView.vue`, update the warehouse selector markup to this shape while preserving existing click handlers and active warehouse state: + +```vue +
+ +
+``` + +If current data uses another variable name, map to that variable without changing its business value. + +- [ ] **Step 2: Rebuild operation zone** + +Use this structure for 入库/出库/工具 buttons: + +```vue +
+
+ 入库 + +
+
+ 出库 + +
+
+ + +
+
+``` + +- [ ] **Step 3: Add warehouse CSS** + +Append to `frontend/src/styles/main.css`: + +```css +.warehouse-switchboard { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 10px; + margin: 14px 0; +} + +.warehouse-tab-card { + display: grid; + grid-template-columns: 24px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 52px; + border: 1px solid var(--ui-line); + border-radius: 16px; + padding: 0 14px; + color: var(--ui-ink-soft); + background: rgba(255, 255, 255, 0.78); + box-shadow: 0 10px 28px rgba(15, 23, 42, 0.04); +} + +.warehouse-tab-card-active { + color: var(--ui-steel); + border-color: rgba(37, 99, 235, 0.28); + background: linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(219, 234, 254, 0.62)); + box-shadow: 0 16px 36px rgba(37, 99, 235, 0.13); +} + +.warehouse-tab-card span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 900; +} + +.warehouse-tab-card strong { + color: inherit; + font-size: 12px; +} + +.warehouse-operation-zone { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; + gap: 12px; + margin-bottom: 14px; + padding: 14px; + border: 1px solid var(--ui-line); + border-radius: var(--ui-radius-lg); + background: rgba(255, 255, 255, 0.76); + box-shadow: var(--ui-shadow-card); +} + +.warehouse-operation-group { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + min-width: 0; +} + +.warehouse-operation-title { + flex: 0 0 auto; + min-width: 42px; + color: var(--ui-ink-muted); + font-size: 12px; + font-weight: 900; +} + +.warehouse-operation-button, +.warehouse-tool-button { + display: inline-flex; + align-items: center; + gap: 7px; + min-height: 34px; + border: 1px solid var(--ui-line); + border-radius: 999px; + padding: 0 12px; + color: var(--ui-ink-soft); + background: rgba(255, 255, 255, 0.92); + font-size: 12px; + font-weight: 900; +} + +.warehouse-operation-in .warehouse-operation-button { + color: var(--ui-green); + border-color: rgba(22, 163, 74, 0.18); + background: rgba(22, 163, 74, 0.08); +} + +.warehouse-operation-out .warehouse-operation-button { + color: var(--ui-red); + border-color: rgba(220, 38, 38, 0.18); + background: rgba(220, 38, 38, 0.07); +} + +.warehouse-tool-primary { + color: #fff; + background: linear-gradient(135deg, var(--ui-steel), var(--ui-cyan)); + border-color: transparent; +} + +@media (max-width: 1080px) { + .warehouse-operation-zone { + grid-template-columns: 1fr; + } +} +``` + +- [ ] **Step 4: Reduce permanent explanatory text** + +In `InventoryLedgerView.vue`, replace long visible operation descriptions with compact title attributes or `FieldHint`. Keep operation label visible. Example: + +```vue + +``` + +- [ ] **Step 5: Verify** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +npm run build +``` + +Expected: + +```text +✓ built +``` + +Browser checks: + +```text +Open /inventory-ledger. +Confirm warehouse cards are readable and active warehouse is obvious. +Confirm 入库 and 出库 operations are visually separated. +Confirm 流水 and 生产台账入库 are visible near operation tools. +Confirm no operation label contains the word bar. +Confirm table remains usable at 1440x900 and 390x844. +``` + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/views/InventoryLedgerView.vue frontend/src/styles/main.css +git commit -m "style: refine warehouse operation experience" +``` + +--- + +### Task 10: Procurement, Sales, Production, Delivery Page Pass + +**Files:** +- Modify: `frontend/src/views/PurchaseOrderView.vue` +- Modify: `frontend/src/views/PurchaseReceiptView.vue` +- Modify: `frontend/src/views/QualityInspectionView.vue` +- Modify: `frontend/src/views/SalesPlanningView.vue` +- Modify: `frontend/src/views/ProductionLedgerView.vue` +- Modify: `frontend/src/views/OperationReportView.vue` +- Modify: `frontend/src/views/DeliveryManagementView.vue` +- Modify: `frontend/src/styles/main.css` + +- [ ] **Step 1: Standardize page header rhythm** + +For each listed view, ensure the top panel follows this shape: + +```vue +
+
+

{{ pageEyebrow }}

+

{{ pageTitle }}

+

{{ pageSubtitle }}

+
+
+ + +
+
+``` + +Use existing variables and handlers. Do not add new business actions. + +- [ ] **Step 2: Add page hero CSS** + +Append to `frontend/src/styles/main.css`: + +```css +.page-hero-card { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + margin-bottom: 14px; + padding: 18px 20px; + border: 1px solid var(--ui-line); + border-radius: var(--ui-radius-xl); + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.94), rgba(241, 245, 249, 0.88)), + radial-gradient(circle at 100% 0%, rgba(37, 99, 235, 0.12), transparent 34%); + box-shadow: var(--ui-shadow-card); +} + +.page-hero-card h2 { + margin: 0; + color: var(--ui-ink); + font-size: 22px; + letter-spacing: -0.02em; +} + +.page-hero-subtitle { + margin: 6px 0 0; + color: var(--ui-ink-muted); + font-size: 13px; +} + +.page-hero-actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 10px; +} + +@media (max-width: 768px) { + .page-hero-card { + display: grid; + } + + .page-hero-actions { + justify-content: flex-start; + } +} +``` + +- [ ] **Step 3: Migrate create/edit drawers gradually** + +For each create/edit drawer in the listed views, keep the existing form fields and handlers but wrap field clusters in `FormSection` where it improves readability. Example for purchase order: + +```vue + + + + + +``` + +Import: + +```js +import FormSection from "../components/FormSection.vue"; +``` + +- [ ] **Step 4: Convert status cells** + +For each listed page, replace status plain text with `StatusBadge`. Example: + +```vue + +``` + +- [ ] **Step 5: Convert noisy helper blocks** + +Replace visible helper paragraphs longer than 48 characters with one of: + +```vue + +``` + +or a compact title: + +```vue +规则说明 +``` + +Do not delete the meaning. Only move it out of permanent page flow. + +- [ ] **Step 6: Verify** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +npm run build +``` + +Expected: + +```text +✓ built +``` + +Browser checks: + +```text +Open /purchase-orders and create drawer. Confirm all existing fields still exist. +Open /order-list and create drawer. Confirm all existing fields still exist. +Open /production-ledger and confirm status colors are clear. +Open /delivery-management and confirm grouped delivery table remains readable. +Open /operation-reports and confirm date filters look integrated, not bolted on. +``` + +- [ ] **Step 7: Commit** + +```bash +git add frontend/src/views/PurchaseOrderView.vue frontend/src/views/PurchaseReceiptView.vue frontend/src/views/QualityInspectionView.vue frontend/src/views/SalesPlanningView.vue frontend/src/views/ProductionLedgerView.vue frontend/src/views/OperationReportView.vue frontend/src/views/DeliveryManagementView.vue frontend/src/styles/main.css +git commit -m "style: polish high frequency business pages" +``` + +--- + +### Task 11: Finance Large Table And System Permission Polish + +**Files:** +- Modify: `frontend/src/views/FinanceReportView.vue` +- Modify: `frontend/src/views/SystemPermissionView.vue` +- Modify: `frontend/src/components/OrgMindMap.vue` +- Modify: `frontend/src/components/OrgPermissionTree.vue` +- Modify: `frontend/src/styles/main.css` + +- [ ] **Step 1: Add financial spreadsheet mode styles** + +Append to `frontend/src/styles/main.css`: + +```css +.finance-sheet-table { + border-collapse: separate; + border-spacing: 0; + background: #fff; +} + +.finance-sheet-table th { + position: sticky; + top: 0; + z-index: 3; + background: linear-gradient(180deg, #f8fafc, #e2e8f0); + color: var(--ui-ink-soft); + white-space: nowrap; + font-size: 12px; + font-weight: 900; +} + +.finance-sheet-table td { + color: var(--ui-ink); + white-space: nowrap; +} + +.finance-sticky-cell { + position: sticky; + left: 0; + z-index: 2; + background: #fff; + box-shadow: 10px 0 18px rgba(15, 23, 42, 0.06); +} + +.finance-board-toolbar { + border: 1px solid var(--ui-line); + border-radius: var(--ui-radius-lg); + background: rgba(255, 255, 255, 0.86); + box-shadow: var(--ui-shadow-card); +} +``` + +- [ ] **Step 2: Improve finance filter row** + +In `FinanceReportView.vue`, wrap date filters and refresh actions in a compact filter bar: + +```vue +
+ + + + +
+``` + +- [ ] **Step 3: Add filter ribbon CSS** + +Append: + +```css +.filter-ribbon { + display: flex; + align-items: end; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 14px; + padding: 14px; + border: 1px solid var(--ui-line); + border-radius: var(--ui-radius-lg); + background: rgba(255, 255, 255, 0.86); + box-shadow: var(--ui-shadow-card); +} + +.filter-ribbon label { + display: grid; + gap: 6px; + min-width: 180px; + color: var(--ui-ink-soft); + font-size: 12px; + font-weight: 900; +} + +.filter-ribbon input, +.filter-ribbon select { + min-height: 38px; + border: 1px solid var(--ui-line); + border-radius: 12px; + padding: 0 12px; + background: #fff; +} +``` + +- [ ] **Step 4: Polish system permission tabs** + +In `SystemPermissionView.vue`, keep the existing two tab model but style as segmented tabs: + +```vue +
+ + +
+``` + +- [ ] **Step 5: Add admin tab CSS** + +Append: + +```css +.admin-tab-switcher { + display: inline-flex; + gap: 6px; + padding: 6px; + border: 1px solid var(--ui-line); + border-radius: 16px; + background: rgba(255, 255, 255, 0.76); +} + +.admin-tab-switcher button { + min-height: 34px; + border: 0; + border-radius: 12px; + padding: 0 14px; + color: var(--ui-ink-soft); + background: transparent; + font-weight: 900; +} + +.admin-tab-switcher button.active { + color: #fff; + background: linear-gradient(135deg, var(--ui-steel), var(--ui-cyan)); + box-shadow: 0 10px 24px rgba(37, 99, 235, 0.18); +} +``` + +- [ ] **Step 6: Verify** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +npm run build +``` + +Expected: + +```text +✓ built +``` + +Browser checks: + +```text +Open /finance-reports and confirm filters are not stretched to huge widths. +Confirm financial table remains horizontally scrollable and key columns stay readable. +Open /system-permissions and confirm tabs do not crowd the brain map or tree. +Confirm org map controls are visually consistent with other buttons. +``` + +- [ ] **Step 7: Commit** + +```bash +git add frontend/src/views/FinanceReportView.vue frontend/src/views/SystemPermissionView.vue frontend/src/components/OrgMindMap.vue frontend/src/components/OrgPermissionTree.vue frontend/src/styles/main.css +git commit -m "style: polish finance and permission interfaces" +``` + +--- + +### Task 12: Final UI Verification And Regression Sweep + +**Files:** +- Modify only if verification finds issues. +- Test: frontend build and browser screenshots. + +- [ ] **Step 1: Build** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +npm run build +``` + +Expected: + +```text +✓ built +``` + +- [ ] **Step 2: Desktop browser sweep** + +Use browser plugin at `1440x900`: + +```text +http://127.0.0.1:5173/ +http://127.0.0.1:5173/customer-management +http://127.0.0.1:5173/supplier-management +http://127.0.0.1:5173/supplier-material-lite +http://127.0.0.1:5173/product-spec-lite +http://127.0.0.1:5173/order-list +http://127.0.0.1:5173/purchase-orders +http://127.0.0.1:5173/purchase-receipts +http://127.0.0.1:5173/quality-inspection +http://127.0.0.1:5173/inventory-ledger +http://127.0.0.1:5173/production-ledger +http://127.0.0.1:5173/operation-reports +http://127.0.0.1:5173/delivery-management +http://127.0.0.1:5173/finance-reports +http://127.0.0.1:5173/system-permissions +http://127.0.0.1:5173/system-extension +``` + +Expected: + +```text +No page is squeezed by sidebar. +No obvious raw English status is visible. +No table header shows raw ↕ text. +No drawer close/check button is clipped. +No form field is hidden by footer. +No long helper paragraph dominates the page. +``` + +- [ ] **Step 3: Narrow browser sweep** + +Use browser plugin at `390x844`: + +```text +http://127.0.0.1:5173/ +http://127.0.0.1:5173/inventory-ledger +http://127.0.0.1:5173/purchase-orders +http://127.0.0.1:5173/order-list +http://127.0.0.1:5173/system-permissions +``` + +Expected: + +```text +Sidebar collapses. +Main content remains usable. +Tables scroll inside their wrappers. +Drawers are full-screen or near full-screen. +Primary action buttons remain visible. +``` + +- [ ] **Step 4: Interaction checks** + +Perform these checks without saving destructive data: + +```text +Open purchase order create drawer. +Open sales order create drawer. +Open warehouse 客料入库 drawer. +Open warehouse stock lot multi-select and select one item. +Open finance expense drawer. +Switch system permission tabs. +Switch org brain map/tree mode. +``` + +Expected: + +```text +Existing fields are still present. +Dropdowns are searchable. +Multi-select closes after one selection and reopens on next click. +Status badges remain colored. +Buttons have consistent height and feedback. +``` + +- [ ] **Step 5: Run UI audit helper in browser console** + +On `/inventory-ledger`, `/purchase-orders`, and `/finance-reports`, run: + +```js +import("/src/utils/uiAudit.js").then((m) => m.printUiAuditSnapshot()); +``` + +Expected: + +```text +visibleEnglishStatuses is empty or contains only internal hidden values. +tables do not report hasRawSortText as true. +longHelperBlocks contains no large permanent business explanations. +``` + +- [ ] **Step 6: Final commit** + +```bash +git add frontend/src +git commit -m "style: complete ERP frontend visual upgrade" +``` + +If no `.git` directory exists, record: + +```text +Final commit skipped because current workspace is not a git repository. +``` + +--- + +## Self-Review + +- **Spec coverage:** The plan covers global visual direction, five requested design skills, shell, icons, buttons, helper text, status colors, tables, drawers, searchable selects, warehouse page, high-frequency pages, finance, system permissions, desktop/narrow verification. +- **Scope boundary:** The plan keeps business fields, workflows, API payloads, and backend behavior unchanged. +- **No placeholder scan:** The plan contains concrete file paths, code snippets, verification commands, and expected results. +- **Type consistency:** New components use stable props: `AppIcon.name`, `StatusBadge.value`, `SmartDrawer.open/title/size`, `FormSection.title/columns`, `MetricStrip.metrics`. +- **Execution recommendation:** Use subagent-driven development. Split by primitive/component tasks first, then page passes, because the table/status/drawer work can be reviewed independently before touching high-frequency business pages. diff --git a/docs/superpowers/plans/2026-06-11-all-warehouse-document-archive.md b/docs/superpowers/plans/2026-06-11-all-warehouse-document-archive.md new file mode 100644 index 0000000..0c05244 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-all-warehouse-document-archive.md @@ -0,0 +1,1697 @@ +# 全仓库出入库单据纸质化、PDF归档与流水导出 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让嘉恒仓库 6 大库所有出入库填单都采用正式纸质单据风格,保存后生成 PDF 留档,并支持在各库流水中预览、下载、重新生成 PDF,以及按当前筛选时间范围导出流水 Excel 清单。 + +**Architecture:** 采用“双轨归档”:生产主链路继续使用 `生产领料出库单`、`生产入库结算单`,其它仓库出入库统一使用新增 `仓库出入库单`。后端以库存流水 `wh_inventory_txn` 为核心收集归档上下文和导出 Excel;前端抽取通用纸质单据底座,在仓库流水抽屉复用现有 `DocumentArchiveActions`。 + +**Tech Stack:** FastAPI, SQLAlchemy, Pydantic, ReportLab, openpyxl, Vue 3, Vite, Node test runner. + +--- + +## 设计文件 + +- Spec: `docs/superpowers/specs/2026-06-11-all-warehouse-document-archive-design.md` +- Related spec: `docs/superpowers/specs/2026-06-11-production-warehouse-document-archive-design.md` +- Related plan: `docs/superpowers/plans/2026-06-11-production-warehouse-document-archive.md` +- Related plan: `docs/superpowers/plans/2026-06-11-production-inbound-archive-entrypoints.md` + +## File Structure + +### Backend + +- Modify: `backend/app/services/document_archives.py` + - Add `DOCUMENT_TYPE_WAREHOUSE_OPERATION = "仓库出入库单"`. + - Add generic warehouse archive context collector. + - Route generic warehouse archive dispatch through `collect_archive_context`. + - Keep production-specific archive logic unchanged. + +- Modify: `backend/app/api/routes/document_archives.py` + - Add route key `warehouse-operation`. + - Include `仓库出入库单` in normalized key validation. + +- Modify: `backend/app/services/operations.py` + - Extend `get_inventory_txn_ledger_query()` to return archive fields. + - Resolve archive type/business ID per row: + - production material out -> `生产领料出库单`, `source_doc_id` + - production inbound settlement -> `生产入库结算单`, `inventory_txn_id` + - other warehouse operations -> `仓库出入库单`, `inventory_txn_id` + +- Modify: `backend/app/schemas/operations.py` + - Add archive fields to `InventoryTxnLedgerRowRead`. + +- Modify: `backend/app/api/routes/inventory.py` + - Add `/inventory/transaction-ledger/export`. + - Reuse `get_inventory_txn_ledger_query()` filters. + - Generate Chinese-header `.xlsx` with full values. + - Ensure export is based on current filters, especially start/end date. + +- Create: `backend/tests/test_warehouse_operation_document_archive.py` + - Test generic warehouse archive context and PDF generation. + +- Modify: `backend/tests/test_document_archive_routes.py` + - Test `warehouse-operation` route key. + +- Create: `backend/tests/test_inventory_ledger_export.py` + - Test ledger archive fields and Excel export filters. + +### Frontend + +- Create: `frontend/src/components/documentForms/WarehouseDocumentFormShell.vue` + - Shared paper-form shell for warehouse in/out forms. + +- Create: `frontend/src/components/documentForms/WarehouseDocumentSection.vue` + - Reusable section block for paper-form grouped fields. + +- Create: `frontend/src/components/documentForms/WarehouseDocumentActionBar.vue` + - Fixed bottom action area: save, save status, archive shortcuts. + +- Modify: `frontend/src/views/InventoryLedgerView.vue` + - Import and use paper-form shell. + - Keep `原材料库 · 客料入库` as first migrated sample and move it onto reusable shell. + - Add `DocumentArchiveActions` to warehouse ledger table. + - Add `导出Excel` button beside `查询`. + - Add export loading state to prevent repeated clicks. + - Add preview/download/regenerate handlers for `warehouse-operation`, `production-material-out`, and `production-inbound-settlement`. + +- Modify: `frontend/src/styles/main.css` + - Add paper-form shared styles only if component scoped styles are insufficient. + - Add warehouse ledger archive/export button styles. + +- Modify: `frontend/src/views/InventoryLedgerView.test.js` + - Add static tests for warehouse archive actions, Excel export button, and route keys. + +--- + +## Task 1: Add Warehouse Operation Archive Type + +**Files:** +- Modify: `backend/app/services/document_archives.py` +- Modify: `backend/app/api/routes/document_archives.py` +- Modify: `backend/tests/test_document_archive_routes.py` + +- [ ] **Step 1: Write failing route-key test** + +Add this assertion to `test_normalize_document_type_key_supports_route_keys` in `backend/tests/test_document_archive_routes.py`: + +```python +self.assertEqual(normalize_document_type_key("warehouse-operation"), "仓库出入库单") +``` + +Also add a Chinese direct type assertion: + +```python +self.assertEqual(normalize_document_type_key("仓库出入库单"), "仓库出入库单") +``` + +- [ ] **Step 2: Run route test and verify failure** + +Run: + +```bash +cd backend +python -m pytest tests/test_document_archive_routes.py::DocumentArchiveRouteTest::test_normalize_document_type_key_supports_route_keys -q +``` + +Expected: FAIL because `warehouse-operation` is not in `DOCUMENT_TYPE_KEY_MAP`. + +- [ ] **Step 3: Add archive type constant** + +In `backend/app/services/document_archives.py`, add the constant next to existing `DOCUMENT_TYPE_*` constants: + +```python +DOCUMENT_TYPE_WAREHOUSE_OPERATION = "仓库出入库单" +``` + +- [ ] **Step 4: Add route map entry** + +In `backend/app/api/routes/document_archives.py`, import the new constant: + +```python +DOCUMENT_TYPE_WAREHOUSE_OPERATION, +``` + +Add these entries to `DOCUMENT_TYPE_KEY_MAP`: + +```python +"warehouse-operation": DOCUMENT_TYPE_WAREHOUSE_OPERATION, +DOCUMENT_TYPE_WAREHOUSE_OPERATION: DOCUMENT_TYPE_WAREHOUSE_OPERATION, +``` + +Update the validation message in `normalize_document_type_key()` so it includes `仓库出入库单`: + +```python +raise HTTPException( + status_code=400, + detail="单据类型只能选择销售订单、采购订单、到货入库单、质量校验单、生产领料出库单、生产入库结算单或仓库出入库单", +) +``` + +- [ ] **Step 5: Run route test and verify pass** + +Run: + +```bash +cd backend +python -m pytest tests/test_document_archive_routes.py -q +``` + +Expected: PASS. + +--- + +## Task 2: Build Generic Warehouse Archive Context + +**Files:** +- Create: `backend/tests/test_warehouse_operation_document_archive.py` +- Modify: `backend/app/services/document_archives.py` + +- [ ] **Step 1: Write failing service test** + +Create `backend/tests/test_warehouse_operation_document_archive.py` with this structure: + +```python +from __future__ import annotations + +import unittest +from decimal import Decimal +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from sqlalchemy import BigInteger, create_engine +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +import app.models.document_archive # noqa: F401 +import app.models.master_data # noqa: F401 +import app.models.miniapp # noqa: F401 +import app.models.operations # noqa: F401 +import app.models.org # noqa: F401 +import app.models.planning # noqa: F401 +import app.models.sales # noqa: F401 +from app.models.base import Base +from app.models.master_data import Item, Warehouse, WarehouseLocation +from app.models.operations import InventoryTxn, StockLot +from app.services.document_archives import ( + ARCHIVE_STATUS_READY, + DOCUMENT_TYPE_WAREHOUSE_OPERATION, + collect_warehouse_operation_archive_context, + generate_document_archive, +) + + +@compiles(BigInteger, "sqlite") +def _compile_big_integer_for_sqlite(type_, compiler, **kw) -> str: + return "INTEGER" + + +class WarehouseOperationDocumentArchiveTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + future=True, + ) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db = self.SessionLocal() + + def tearDown(self) -> None: + self.db.close() + + def _seed_customer_supplied_txn(self) -> InventoryTxn: + warehouse = Warehouse(warehouse_code="RAW", warehouse_name="原材料库", warehouse_type="RAW") + location = WarehouseLocation(location_code="RAW-A", location_name="原材料暂存位", warehouse=warehouse) + item = Item(item_code="RM00001", item_name="冷轧钢板", item_type="RAW", unit="kg", specification="1.2mm") + lot = StockLot( + lot_no="YL0001", + item=item, + warehouse=warehouse, + location=location, + qty_on_hand=Decimal("0"), + weight_on_hand_kg=Decimal("100"), + qty_available=Decimal("0"), + weight_available_kg=Decimal("100"), + unit_cost=Decimal("0"), + status="AVAILABLE", + ) + txn = InventoryTxn( + txn_no="TXN-CUSTOMER-001", + txn_type="CUSTOMER_SUPPLIED", + item=item, + warehouse=warehouse, + location=location, + lot=lot, + qty_change=Decimal("0"), + weight_change_kg=Decimal("100"), + unit_cost=Decimal("0"), + amount=Decimal("0"), + source_doc_type="客料入库", + source_doc_id=1, + logistics_waybill_no="SF123", + logistics_freight_amount=Decimal("12.5"), + logistics_photo_url="/uploads/demo.jpg", + remark="客户来料测试", + ) + self.db.add_all([warehouse, location, item, lot, txn]) + self.db.commit() + self.db.refresh(txn) + return txn + + def test_collects_customer_supplied_warehouse_context(self) -> None: + txn = self._seed_customer_supplied_txn() + + context = collect_warehouse_operation_archive_context(self.db, txn.id) + + self.assertEqual(context.document_type, DOCUMENT_TYPE_WAREHOUSE_OPERATION) + self.assertEqual(context.business_id, txn.id) + self.assertIn("客料入库", context.title) + self.assertEqual(context.partner_label, "仓库") + self.assertEqual(context.partner_name, "原材料库") + self.assertEqual(len(context.lines), 1) + self.assertEqual(context.lines[0].item_name, "冷轧钢板") + self.assertIn("库存批次号:YL0001", context.lines[0].remark) + self.assertIn("运单号:SF123", context.remark) + + def test_generates_pdf_archive_for_warehouse_operation(self) -> None: + txn = self._seed_customer_supplied_txn() + + with TemporaryDirectory() as tmp_dir, patch("app.services.document_archives.default_archive_root", return_value=tmp_dir): + result = generate_document_archive(self.db, DOCUMENT_TYPE_WAREHOUSE_OPERATION, txn.id) + + self.assertEqual(result.archive_status, ARCHIVE_STATUS_READY) + self.assertEqual(result.document_type, DOCUMENT_TYPE_WAREHOUSE_OPERATION) + self.assertIn("仓库出入库单", result.document_type) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run service test and verify failure** + +Run: + +```bash +cd backend +python -m pytest tests/test_warehouse_operation_document_archive.py -q +``` + +Expected: FAIL because `collect_warehouse_operation_archive_context` does not exist. + +- [ ] **Step 3: Implement context collector** + +In `backend/app/services/document_archives.py`, add this helper near production collectors: + +```python +WAREHOUSE_OPERATION_TITLE_LABELS = { + "OPENING": "期初入库单", + "CUSTOMER_SUPPLIED": "客料入库单", + "PRODUCTION_SURPLUS": "生产余料入库单", + "PRODUCTION_SURPLUS_IN": "生产余料入库单", + "OUTSOURCING_SURPLUS": "委外余料入库单", + "WIP_IN": "产中入库单", + "WIP_OUT": "产中出库单", + "OUTSOURCING_IN": "委外入库单", + "OUTSOURCING_OUT": "委外出库单", + "PURCHASE_RETURN_OUT": "退货出库单", + "SCRAP_OUT": "报废出库单", + "REWORK_FINISHED_IN": "返工入库单", + "SALES_OUT": "销售出库单", + "PRODUCTION_OUT": "生产出库单", + "PRODUCTION_SCRAP_IN": "生产废料入库单", + "REWORK_SCRAP_IN": "返工废料入库单", + "OUTSOURCING_SCRAP_IN": "委外废料入库单", + "RETURN_SCRAP_IN": "退货废料入库单", + "SCRAP_SALE_OUT": "售卖出库单", + "RETURN_IN": "退货入库单", + "RETURN_OUT": "返工出库单", + "SPECIAL_IN": "特殊入库单", + "SPECIAL_OUT": "特殊出库单", +} + + +def warehouse_operation_title(txn_type: str | None) -> str: + normalized = str(txn_type or "").strip().upper() + return WAREHOUSE_OPERATION_TITLE_LABELS.get(normalized, f"{archive_status_text(txn_type)}单") +``` + +Then add: + +```python +def collect_warehouse_operation_archive_context(db: Session, inventory_txn_id: int) -> ArchiveContext: + source_material_lot = aliased(StockLot) + row = db.execute( + select(InventoryTxn, Item, Warehouse, WarehouseLocation, StockLot, source_material_lot) + .join(Item, Item.id == InventoryTxn.item_id) + .join(Warehouse, Warehouse.id == InventoryTxn.warehouse_id) + .outerjoin(WarehouseLocation, WarehouseLocation.id == InventoryTxn.location_id) + .outerjoin(StockLot, StockLot.id == InventoryTxn.lot_id) + .outerjoin(source_material_lot, source_material_lot.id == StockLot.source_material_lot_id) + .where(InventoryTxn.id == inventory_txn_id) + ).one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="库存流水不存在") + + txn, item, warehouse, location, lot, source_lot = row + title = warehouse_operation_title(txn.txn_type) + lot_no = lot.lot_no if lot else "" + source_lot_no = source_lot.lot_no if source_lot else (lot.source_material_sub_batch_no if lot else "") + weight_value = Decimal(str(txn.weight_change_kg or 0)) + qty_value = Decimal(str(txn.qty_change or 0)) + total_amount = Decimal(str(txn.amount or 0)) + line_remark_parts = [ + f"库存流水号:{txn.txn_no}", + f"库存批次号:{lot_no or '-'}", + ] + if source_lot_no: + line_remark_parts.append(f"来源库存批次号:{source_lot_no}") + if location: + line_remark_parts.append(f"库位:{location.location_name}") + if txn.remark: + line_remark_parts.append(str(txn.remark)) + + remark_parts = [ + f"业务类型:{title.replace('单', '')}", + f"来源单据:{archive_status_text(txn.source_doc_type)} #{txn.source_doc_id}", + ] + if txn.logistics_waybill_no: + remark_parts.append(f"运单号:{txn.logistics_waybill_no}") + if txn.logistics_freight_amount is not None: + remark_parts.append(f"运费:{decimal_text(txn.logistics_freight_amount, 2)}") + if txn.logistics_photo_url: + remark_parts.append("辅助照片:已上传") + if txn.remark: + remark_parts.append(str(txn.remark)) + + return ArchiveContext( + document_type=DOCUMENT_TYPE_WAREHOUSE_OPERATION, + business_id=txn.id, + document_no=f"{title.replace('单', '')}-{txn.txn_no}", + title=f"{title}归档", + partner_label="仓库", + partner_name=warehouse.warehouse_name, + document_date=txn.biz_time, + due_date_label="库存流水", + due_date=txn.txn_no, + status=title.replace("单", ""), + tax_rate=0, + total_amount=total_amount, + address=location.location_name if location else warehouse.warehouse_name, + remark=";".join(remark_parts), + lines=[ + ArchiveLine( + line_no=1, + item_code=item.item_code, + item_name=item.item_name, + specification=item.specification, + quantity=qty_value, + delivered_or_received_quantity=weight_value, + unit_price=txn.unit_cost, + line_amount=txn.amount, + promised_or_expected_date=txn.biz_time, + remark=";".join(line_remark_parts), + ) + ], + ) +``` + +- [ ] **Step 4: Wire collector into dispatch** + +In `collect_archive_context()` add: + +```python +if document_type == DOCUMENT_TYPE_WAREHOUSE_OPERATION: + return collect_warehouse_operation_archive_context(db, business_id) +``` + +- [ ] **Step 5: Run warehouse archive tests** + +Run: + +```bash +cd backend +python -m pytest tests/test_warehouse_operation_document_archive.py tests/test_document_archive_service.py -q +``` + +Expected: PASS. + +--- + +## Task 3: Return Archive Fields in Warehouse Ledger + +**Files:** +- Modify: `backend/app/services/operations.py` +- Modify: `backend/app/schemas/operations.py` +- Create: `backend/tests/test_inventory_ledger_export.py` + +- [ ] **Step 1: Write failing ledger archive-field test** + +In `backend/tests/test_inventory_ledger_export.py`, create a SQLite test that seeds a warehouse transaction and a latest `DocumentArchive`, then asserts `get_inventory_txn_ledger_query()` returns archive fields: + +```python +def test_inventory_ledger_returns_warehouse_archive_fields(self) -> None: + txn = self._seed_raw_inventory_txn(txn_type="CUSTOMER_SUPPLIED") + self.db.add( + DocumentArchive( + document_type="仓库出入库单", + business_id=txn.id, + document_no="客料入库-TXN-001", + archive_version=1, + template_version="单据纸面V1", + file_format="PDF", + file_name="客料入库.pdf", + file_path="/tmp/客料入库.pdf", + file_hash="abc", + status="已归档", + ) + ) + self.db.commit() + + row = self.db.execute(get_inventory_txn_ledger_query(warehouse_type="RAW")).mappings().one() + + self.assertEqual(row["archive_status"], "已归档") + self.assertEqual(row["archive_business_id"], txn.id) + self.assertEqual(row["archive_document_type"], "仓库出入库单") + self.assertIsNone(row["archive_error_message"]) +``` + +Also add one production material out assertion: + +```python +def test_inventory_ledger_points_production_out_to_material_out_archive(self) -> None: + txn = self._seed_raw_inventory_txn(txn_type="PRODUCTION_OUT", source_doc_type="生产台账", source_doc_id=88) + + row = self.db.execute(get_inventory_txn_ledger_query(warehouse_type="RAW")).mappings().one() + + self.assertEqual(row["archive_document_type"], "生产领料出库单") + self.assertEqual(row["archive_business_id"], 88) +``` + +- [ ] **Step 2: Run test and verify failure** + +Run: + +```bash +cd backend +python -m pytest tests/test_inventory_ledger_export.py::InventoryLedgerExportTest::test_inventory_ledger_returns_warehouse_archive_fields -q +``` + +Expected: FAIL because ledger rows do not expose archive fields. + +- [ ] **Step 3: Add schema fields** + +In `backend/app/schemas/operations.py`, extend `InventoryTxnLedgerRowRead`: + +```python +archive_status: str = "未生成" +archive_business_id: int | None = None +archive_document_type: str | None = None +archive_error_message: str | None = None +``` + +- [ ] **Step 4: Extend ledger query with archive resolution** + +In `backend/app/services/operations.py`, import `DocumentArchive` and the document type constants: + +```python +from app.models.document_archive import DocumentArchive +from app.services.document_archives import ( + ARCHIVE_STATUS_MISSING, + DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, + DOCUMENT_TYPE_WAREHOUSE_OPERATION, +) +``` + +Inside `get_inventory_txn_ledger_query()`, build these expressions before the select: + +```python +archive_document_type_expr = case( + ( + (InventoryTxn.source_doc_type == "生产台账") & (InventoryTxn.txn_type == "PRODUCTION_OUT"), + DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, + ), + ( + InventoryTxn.txn_type.in_(("FG_IN", "PRODUCTION_FINISHED_IN", "PRODUCTION_SURPLUS_IN", "PRODUCTION_SCRAP_IN")), + DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + ), + else_=DOCUMENT_TYPE_WAREHOUSE_OPERATION, +).label("archive_document_type") + +archive_business_id_expr = case( + ( + (InventoryTxn.source_doc_type == "生产台账") & (InventoryTxn.txn_type == "PRODUCTION_OUT"), + InventoryTxn.source_doc_id, + ), + else_=InventoryTxn.id, +).label("archive_business_id") + +latest_archive = ( + select( + DocumentArchive.document_type.label("document_type"), + DocumentArchive.business_id.label("business_id"), + DocumentArchive.status.label("status"), + DocumentArchive.error_message.label("error_message"), + func.row_number() + .over( + partition_by=(DocumentArchive.document_type, DocumentArchive.business_id), + order_by=(DocumentArchive.archive_version.desc(), DocumentArchive.id.desc()), + ) + .label("row_no"), + ) + .where(DocumentArchive.file_format == "PDF") + .subquery() +) +``` + +Join the latest archive subquery after existing joins: + +```python +.outerjoin( + latest_archive, + (latest_archive.c.document_type == archive_document_type_expr) + & (latest_archive.c.business_id == archive_business_id_expr) + & (latest_archive.c.row_no == 1), +) +``` + +Add fields to the select: + +```python +archive_document_type_expr, +archive_business_id_expr, +func.coalesce(latest_archive.c.status, ARCHIVE_STATUS_MISSING).label("archive_status"), +latest_archive.c.error_message.label("archive_error_message"), +``` + +- [ ] **Step 5: Run ledger archive-field tests** + +Run: + +```bash +cd backend +python -m pytest tests/test_inventory_ledger_export.py -q +``` + +Expected: ledger archive-field tests PASS. + +--- + +## Task 4: Add Warehouse Ledger Excel Export Endpoint + +**Files:** +- Modify: `backend/app/api/routes/inventory.py` +- Create: `backend/tests/test_inventory_ledger_export.py` + +- [ ] **Step 1: Write failing export endpoint test** + +Add this test to `backend/tests/test_inventory_ledger_export.py` using a FastAPI `TestClient` with the inventory router: + +```python +def test_transaction_ledger_export_uses_date_filter_and_chinese_headers(self) -> None: + self._seed_raw_inventory_txn(txn_type="CUSTOMER_SUPPLIED", txn_no="TXN-IN-RANGE", biz_date="2026-06-10") + self._seed_raw_inventory_txn(txn_type="CUSTOMER_SUPPLIED", txn_no="TXN-OUT-RANGE", biz_date="2026-05-01") + + response = self.client.get( + "/inventory/transaction-ledger/export", + params={ + "warehouse_type": "RAW", + "start_date": "2026-06-01", + "end_date": "2026-06-30", + "direction": "IN", + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertIn("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", response.headers["content-type"]) + + workbook = load_workbook(BytesIO(response.content)) + sheet = workbook.active + headers = [cell.value for cell in sheet[1]] + self.assertIn("业务时间", headers) + self.assertIn("PDF归档状态", headers) + rows = [[cell.value for cell in row] for row in sheet.iter_rows(min_row=2)] + flattened = "\n".join(str(value) for row in rows for value in row) + self.assertIn("TXN-IN-RANGE", flattened) + self.assertNotIn("TXN-OUT-RANGE", flattened) +``` + +- [ ] **Step 2: Run export test and verify failure** + +Run: + +```bash +cd backend +python -m pytest tests/test_inventory_ledger_export.py::InventoryLedgerExportTest::test_transaction_ledger_export_uses_date_filter_and_chinese_headers -q +``` + +Expected: FAIL because `/inventory/transaction-ledger/export` does not exist. + +- [ ] **Step 3: Add export helpers** + +In `backend/app/api/routes/inventory.py`, add helpers near `_excel_response()`: + +```python +WAREHOUSE_LEDGER_EXPORT_HEADERS = [ + ("biz_time", "业务时间"), + ("warehouse_name", "仓库"), + ("location_name", "库位"), + ("direction_label", "方向"), + ("txn_type_label", "流水类型"), + ("txn_no", "流水号"), + ("item_code", "物料/产品编码"), + ("item_name", "物料/产品名称"), + ("lot_no", "库存批次号"), + ("source_material_lot_no", "来源库存批次号"), + ("qty_change", "数量变化"), + ("weight_change_kg", "重量变化(kg)"), + ("unit_cost", "单价"), + ("amount", "金额"), + ("source_doc_label", "来源单据"), + ("operator_name", "经办人"), + ("logistics_waybill_no", "运单号"), + ("logistics_freight_amount", "运费"), + ("logistics_photo_label", "辅助照片"), + ("remark", "备注"), + ("archive_status", "PDF归档状态"), + ("archive_business_id", "PDF归档业务ID"), +] + + +def _ledger_direction_label(value: str | None) -> str: + labels = {"IN": "入库", "OUT": "出库", "ADJUST": "调整", "ALL": "全部"} + return labels.get(str(value or "").upper(), str(value or "")) + + +def _ledger_export_value(row: dict[str, object], key: str) -> object: + if key == "direction_label": + return _ledger_direction_label(row.get("direction")) + if key == "txn_type_label": + return _inventory_txn_type_label(str(row.get("txn_type") or "")) + if key == "source_doc_label": + source_type = _source_doc_type_label(str(row.get("source_doc_type") or "")) + source_id = row.get("source_doc_id") + return f"{source_type} #{source_id}" if source_id else source_type + if key == "logistics_photo_label": + return "已上传" if row.get("logistics_photo_url") else "" + return row.get(key) or "" +``` + +If `_inventory_txn_type_label` and `_source_doc_type_label` do not exist in the route module, add explicit local dictionaries with Chinese labels instead of returning English codes. + +- [ ] **Step 4: Add export endpoint** + +Add this route after `list_inventory_transaction_ledger()`: + +```python +@router.get("/transaction-ledger/export") +def export_inventory_transaction_ledger( + warehouse_type: str = Query(...), + warehouse_id: int | None = None, + keyword: str | None = None, + direction: str = "ALL", + txn_type: str | None = None, + item_id: int | None = None, + lot_no: str | None = None, + start_date: date | None = None, + end_date: date | None = None, + sort_key: str = "biz_time", + sort_direction: str = "desc", + db: Session = Depends(get_db), +) -> Response: + normalized_warehouse_type = str(warehouse_type or "").upper() + if normalized_warehouse_type not in WAREHOUSE_LEDGER_TYPES: + raise HTTPException(status_code=400, detail="未知仓库类型,无法导出出入库流水") + normalized_direction = str(direction or "ALL").upper() + if normalized_direction not in {"ALL", "IN", "OUT", "ADJUST"}: + raise HTTPException(status_code=400, detail="流水方向只能是 ALL、IN、OUT、ADJUST") + + stmt = get_inventory_txn_ledger_query( + warehouse_type=normalized_warehouse_type, + warehouse_id=warehouse_id, + keyword=keyword, + direction=normalized_direction, + txn_type=txn_type, + item_id=item_id, + lot_no=lot_no, + start_date=start_date, + end_date=end_date, + sort_key=sort_key, + sort_direction=sort_direction, + ) + rows = [dict(row) for row in db.execute(stmt).mappings().all()] + + workbook = Workbook() + sheet = workbook.active + sheet.title = "出入库流水" + sheet.append([label for _, label in WAREHOUSE_LEDGER_EXPORT_HEADERS]) + for row in rows: + sheet.append([_ledger_export_value(row, key) for key, _ in WAREHOUSE_LEDGER_EXPORT_HEADERS]) + sheet.freeze_panes = "A2" + sheet.auto_filter.ref = sheet.dimensions + for column_cells in sheet.columns: + header = str(column_cells[0].value or "") + max_length = max(len(str(cell.value or "")) for cell in column_cells) + sheet.column_dimensions[column_cells[0].column_letter].width = min(max(max_length + 2, len(header) + 2), 42) + + date_range = f"{start_date or '全部'}_{end_date or '全部'}" + warehouse_label = WAREHOUSE_TYPE_LABELS.get(normalized_warehouse_type, normalized_warehouse_type) + return _excel_response(workbook, f"{warehouse_label}出入库流水_{date_range}.xlsx") +``` + +- [ ] **Step 5: Run export tests** + +Run: + +```bash +cd backend +python -m pytest tests/test_inventory_ledger_export.py -q +``` + +Expected: PASS. + +--- + +## Task 5: Generate Generic Archive After Non-Production Warehouse Saves + +**Files:** +- Modify: `backend/app/api/routes/inventory.py` +- Modify: `backend/app/api/routes/sales.py` +- Modify: `backend/app/api/routes/returns.py` +- Modify: `backend/app/services/special_inventory_adjustment.py` +- Modify: `backend/app/services/stocktake.py` +- Test: `backend/tests/test_warehouse_operation_document_archive.py` + +- [ ] **Step 1: Add helper for safe archive generation** + +In `backend/app/api/routes/inventory.py`, add: + +```python +def _generate_warehouse_operation_archive_safely(db: Session, inventory_txn_id: int, created_by: int | None = None) -> DocumentArchiveGenerateResult: + try: + return generate_document_archive( + db, + DOCUMENT_TYPE_WAREHOUSE_OPERATION, + inventory_txn_id, + created_by=created_by, + ) + except HTTPException: + raise + except Exception as exc: + return DocumentArchiveGenerateResult( + business_id=inventory_txn_id, + document_type=DOCUMENT_TYPE_WAREHOUSE_OPERATION, + document_no=f"仓库出入库单-{inventory_txn_id}", + archive_status="归档失败", + archive_version=None, + archive_error_message=str(exc), + ) +``` + +If the same helper is needed outside `inventory.py`, move it to `backend/app/services/document_archives.py` as `generate_warehouse_operation_archive_safely()` and import it from each route/service. Prefer the shared service form if more than two files need it. + +- [ ] **Step 2: Return archive result for `客料入库` first** + +Find the `CUSTOMER_SUPPLIED` save path in `backend/app/api/routes/inventory.py`. After the transaction is committed and refreshed, call: + +```python +archive_result = _generate_warehouse_operation_archive_safely(db, txn.id, created_by=_extract_user_id(context)) +``` + +Return the existing payload plus: + +```python +"archive_business_id": archive_result.business_id, +"archive_document_type": archive_result.document_type, +"archive_status": archive_result.archive_status, +"archive_error_message": archive_result.archive_error_message, +``` + +- [ ] **Step 3: Add coverage for remaining non-production inventory routes** + +Apply the same safe archive call to plain warehouse operations that create one inventory transaction: + +```text +期初入库 +委外余料入库 +委外出库 +报废出库 +半成品产中入库 +半成品产中出库 +半成品委外入库 +半成品委外出库 +成品委外入库 +成品销售出库 +辅料生产出库 +废料售卖出库 +退货入库 +退货返工出库 +退货废料入库 +返工废料入库 +特殊入库 +特殊出库 +``` + +Do not wrap production material out or production ledger inbound into `仓库出入库单`; those keep production-specific archive types. + +- [ ] **Step 4: Run targeted backend tests** + +Run: + +```bash +cd backend +python -m pytest tests/test_warehouse_operation_document_archive.py tests/test_production_warehouse_document_archive.py tests/test_special_warehouse_adjustment.py -q +``` + +Expected: PASS. + +--- + +## Task 6: Add PDF Archive Actions to Warehouse Ledger + +**Files:** +- Modify: `frontend/src/views/InventoryLedgerView.vue` +- Modify: `frontend/src/views/InventoryLedgerView.test.js` + +- [ ] **Step 1: Write failing frontend static test** + +In `frontend/src/views/InventoryLedgerView.test.js`, add: + +```js +it("shows document archive actions in warehouse ledger rows", () => { + assert.match(source, /DocumentArchiveActions/); + assert.match(source, /warehouse-operation/); + assert.match(source, /previewWarehouseLedgerArchive/); + assert.match(source, /downloadWarehouseLedgerArchive/); + assert.match(source, /regenerateWarehouseLedgerArchive/); +}); +``` + +- [ ] **Step 2: Run frontend test and verify failure** + +Run: + +```bash +cd frontend +npm test -- InventoryLedgerView.test.js +``` + +Expected: FAIL until archive actions are added. + +- [ ] **Step 3: Import archive component** + +In `frontend/src/views/InventoryLedgerView.vue`, add: + +```js +import DocumentArchiveActions from "../components/documentForms/DocumentArchiveActions.vue"; +``` + +- [ ] **Step 4: Add ledger table column** + +In the warehouse ledger table header, add: + +```vue +
+``` + +In each row, add: + +```vue + +``` + +Update empty-state colspan so it includes the new archive column. + +- [ ] **Step 5: Add archive handlers** + +Add these functions in the script section: + +```js +function warehouseLedgerArchiveTypeKey(row) { + const documentType = String(row?.archive_document_type || ""); + if (documentType === "生产领料出库单") return "production-material-out"; + if (documentType === "生产入库结算单") return "production-inbound-settlement"; + return "warehouse-operation"; +} + +function warehouseLedgerArchiveBusinessId(row) { + return row?.archive_business_id || row?.inventory_txn_id; +} + +async function previewWarehouseLedgerArchive(row) { + const businessId = warehouseLedgerArchiveBusinessId(row); + if (!businessId) { + errorMessage.value = "该流水缺少归档业务ID,无法预览PDF"; + return; + } + await openResource(`/document-archives/${warehouseLedgerArchiveTypeKey(row)}/${businessId}/latest/preview`); +} + +async function downloadWarehouseLedgerArchive(row) { + const businessId = warehouseLedgerArchiveBusinessId(row); + if (!businessId) { + errorMessage.value = "该流水缺少归档业务ID,无法下载PDF"; + return; + } + const filename = `${row?.txn_no || row?.archive_document_type || "仓库出入库单"}归档.pdf`; + await downloadResource(`/document-archives/${warehouseLedgerArchiveTypeKey(row)}/${businessId}/latest/download`, filename); +} + +async function regenerateWarehouseLedgerArchive(row) { + const businessId = warehouseLedgerArchiveBusinessId(row); + if (!businessId) { + errorMessage.value = "该流水缺少归档业务ID,无法重新生成PDF"; + return; + } + await postResource(`/document-archives/${warehouseLedgerArchiveTypeKey(row)}/${businessId}/generate`, {}); + await loadWarehouseLedger(); +} +``` + +- [ ] **Step 6: Run frontend test** + +Run: + +```bash +cd frontend +npm test -- InventoryLedgerView.test.js +``` + +Expected: PASS. + +--- + +## Task 7: Add Warehouse Ledger Excel Export Button + +**Files:** +- Modify: `frontend/src/views/InventoryLedgerView.vue` +- Modify: `frontend/src/views/InventoryLedgerView.test.js` + +- [ ] **Step 1: Write failing frontend export test** + +In `frontend/src/views/InventoryLedgerView.test.js`, add: + +```js +it("supports exporting warehouse ledger excel with current filters", () => { + assert.match(source, /导出Excel/); + assert.match(source, /exportWarehouseLedgerExcel/); + assert.match(source, /\\/inventory\\/transaction-ledger\\/export/); + assert.match(source, /warehouseLedgerExporting/); +}); +``` + +- [ ] **Step 2: Run frontend test and verify failure** + +Run: + +```bash +cd frontend +npm test -- InventoryLedgerView.test.js +``` + +Expected: FAIL until export UI is added. + +- [ ] **Step 3: Add export loading state** + +Near `warehouseLedgerLoading`, add: + +```js +const warehouseLedgerExporting = ref(false); +``` + +- [ ] **Step 4: Extract shared ledger params builder** + +Replace duplicate URLSearchParams construction with: + +```js +function buildWarehouseLedgerParams({ includePagination = true } = {}) { + const params = new URLSearchParams({ + warehouse_type: activeWarehouseType.value, + direction: warehouseLedgerFilters.direction || "ALL", + sort_key: warehouseLedgerFilters.sort_key || "biz_time", + sort_direction: warehouseLedgerFilters.sort_direction || "desc" + }); + if (includePagination) { + params.set("page", String(warehouseLedgerPage.value)); + params.set("page_size", String(warehouseLedgerPageSize.value)); + } + if (warehouseLedgerFilters.keyword) params.set("keyword", warehouseLedgerFilters.keyword); + if (warehouseLedgerFilters.txn_type) params.set("txn_type", warehouseLedgerFilters.txn_type); + if (warehouseLedgerFilters.start_date) params.set("start_date", warehouseLedgerFilters.start_date); + if (warehouseLedgerFilters.end_date) params.set("end_date", warehouseLedgerFilters.end_date); + return params; +} +``` + +Update `loadWarehouseLedger()` to call: + +```js +const params = buildWarehouseLedgerParams(); +``` + +- [ ] **Step 5: Add export function** + +Add: + +```js +function warehouseLedgerExportFilename() { + const start = warehouseLedgerFilters.start_date || "全部"; + const end = warehouseLedgerFilters.end_date || "全部"; + return `${activeInventoryLabel.value}出入库流水_${start}_${end}.xlsx`; +} + +async function exportWarehouseLedgerExcel() { + if (!warehouseLedgerFilters.start_date || !warehouseLedgerFilters.end_date) { + feedbackMessage.value = "当前未选择完整时间范围,将导出全部匹配流水"; + } + warehouseLedgerExporting.value = true; + try { + const params = buildWarehouseLedgerParams({ includePagination: false }); + await downloadResource(`/inventory/transaction-ledger/export?${params.toString()}`, warehouseLedgerExportFilename()); + } catch (error) { + errorMessage.value = error.message || "流水Excel导出失败"; + } finally { + warehouseLedgerExporting.value = false; + } +} +``` + +- [ ] **Step 6: Add export button beside query** + +In the warehouse ledger filter actions, add: + +```vue + +``` + +- [ ] **Step 7: Run frontend export test** + +Run: + +```bash +cd frontend +npm test -- InventoryLedgerView.test.js +``` + +Expected: PASS. + +--- + +## Task 8: Extract Warehouse Paper Form Shell + +**Files:** +- Create: `frontend/src/components/documentForms/WarehouseDocumentFormShell.vue` +- Create: `frontend/src/components/documentForms/WarehouseDocumentSection.vue` +- Create: `frontend/src/components/documentForms/WarehouseDocumentActionBar.vue` +- Modify: `frontend/src/views/InventoryLedgerView.vue` +- Modify: `frontend/src/styles/main.css` + +- [ ] **Step 1: Create shell component** + +Create `frontend/src/components/documentForms/WarehouseDocumentFormShell.vue`: + +```vue + + + +``` + +- [ ] **Step 2: Create section component** + +Create `frontend/src/components/documentForms/WarehouseDocumentSection.vue`: + +```vue + + + +``` + +- [ ] **Step 3: Create fixed action bar component** + +Create `frontend/src/components/documentForms/WarehouseDocumentActionBar.vue`: + +```vue + +``` + +- [ ] **Step 4: Add shared styles** + +Add styles in `frontend/src/styles/main.css`: + +```css +.warehouse-document-form { + position: relative; + background: + linear-gradient(90deg, rgba(31, 41, 55, 0.045) 1px, transparent 1px), + linear-gradient(180deg, rgba(31, 41, 55, 0.04) 1px, transparent 1px), + #fffaf0; + background-size: 28px 28px; + border: 1px solid rgba(120, 83, 38, 0.26); + border-radius: 22px; + box-shadow: 0 22px 60px rgba(69, 50, 26, 0.16); + padding: 28px; +} + +.warehouse-document-head { + display: flex; + justify-content: space-between; + gap: 24px; + border-bottom: 2px solid rgba(80, 56, 26, 0.36); + padding-bottom: 18px; +} + +.warehouse-document-head h2 { + margin: 4px 0 0; + font-size: 28px; + letter-spacing: 0.16em; + color: #3d2b16; +} + +.warehouse-document-kicker { + font-size: 12px; + letter-spacing: 0.22em; + color: #9a6a28; +} + +.warehouse-document-head dl { + display: grid; + grid-template-columns: repeat(2, minmax(120px, 1fr)); + gap: 10px 18px; + margin: 0; +} + +.warehouse-document-head dt, +.warehouse-document-head dd { + margin: 0; +} + +.warehouse-document-head dt { + font-size: 12px; + color: #8a6a3f; +} + +.warehouse-document-head dd { + font-weight: 700; + color: #3d2b16; +} + +.warehouse-document-body { + display: grid; + gap: 18px; + padding: 22px 0; +} + +.warehouse-document-section { + border: 1px solid rgba(120, 83, 38, 0.22); + border-radius: 16px; + background: rgba(255, 255, 255, 0.72); + padding: 18px; +} + +.warehouse-document-section > header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; + color: #4b3215; +} + +.warehouse-document-section > header span { + display: inline-grid; + place-items: center; + width: 26px; + height: 26px; + border-radius: 999px; + background: #c8892f; + color: #fff; + font-size: 12px; +} + +.warehouse-document-section-grid { + display: grid; + grid-template-columns: repeat(3, minmax(180px, 1fr)); + gap: 14px; +} + +.warehouse-document-signature { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + border-top: 1px dashed rgba(80, 56, 26, 0.38); + padding-top: 18px; + color: #7a5a30; +} + +.warehouse-document-action-bar { + position: sticky; + bottom: 0; + z-index: 8; + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; + margin-top: 18px; + padding: 14px 0 0; + background: linear-gradient(180deg, rgba(255, 250, 240, 0), #fffaf0 32%); +} + +@media (max-width: 900px) { + .warehouse-document-head, + .warehouse-document-action-bar { + flex-direction: column; + align-items: stretch; + } + + .warehouse-document-section-grid { + grid-template-columns: 1fr; + } +} +``` + +- [ ] **Step 5: Migrate `原材料库 · 客料入库` to shell** + +In `frontend/src/views/InventoryLedgerView.vue`, replace the current paper-style customer supplied block with: + +```vue + + + + + + + + + + + + + + + +``` + +Do not delete any current `客料入库` business field. Move fields into sections. + +- [ ] **Step 6: Run frontend build** + +Run: + +```bash +cd frontend +npm run build +``` + +Expected: PASS. + +--- + +## Task 9: Add Save-Success Archive Shortcuts + +**Files:** +- Modify: `frontend/src/views/InventoryLedgerView.vue` + +- [ ] **Step 1: Add last archive result state** + +Add: + +```js +const lastWarehouseArchiveResult = ref(null); +``` + +- [ ] **Step 2: Capture backend archive fields after save** + +Where a warehouse operation save response is handled, set: + +```js +lastWarehouseArchiveResult.value = result?.archive_business_id + ? { + businessId: result.archive_business_id, + documentType: result.archive_document_type || "仓库出入库单", + status: result.archive_status || "未生成", + errorMessage: result.archive_error_message || "" + } + : null; +``` + +- [ ] **Step 3: Show shortcut actions after successful save** + +In the operation drawer success area, render: + +```vue + +``` + +- [ ] **Step 4: Verify save feedback does not block inventory save** + +Run the relevant backend and frontend tests: + +```bash +cd backend +python -m pytest tests/test_warehouse_operation_document_archive.py -q +cd ../frontend +npm test -- InventoryLedgerView.test.js +``` + +Expected: PASS. + +--- + +## Task 10: Migrate Remaining Warehouse Forms to Paper Shell + +**Files:** +- Modify: `frontend/src/views/InventoryLedgerView.vue` + +- [ ] **Step 1: Create operation-to-document title map** + +Add: + +```js +const warehouseOperationDocumentTitles = { + OPENING: "期初入库单", + CUSTOMER_SUPPLIED: "客料入库单", + PRODUCTION_SURPLUS: "生产余料入库单", + OUTSOURCING_SURPLUS: "委外余料入库单", + PRODUCTION_OUT: "生产出库单", + OUTSOURCING_OUT: "委外出库单", + PURCHASE_RETURN_OUT: "退货出库单", + SCRAP_OUT: "报废出库单", + WIP_IN: "产中入库单", + WIP_OUT: "产中出库单", + OUTSOURCING_IN: "委外入库单", + PRODUCTION_FINISHED: "生产入库单", + REWORK_FINISHED_IN: "返工入库单", + SALES_OUT: "销售出库单", + PRODUCTION_SCRAP_IN: "生产废料入库单", + REWORK_SCRAP_IN: "返工废料入库单", + OUTSOURCING_SCRAP_IN: "委外废料入库单", + RETURN_SCRAP_IN: "退货废料入库单", + SCRAP_SALE_OUT: "售卖出库单", + RETURN_IN: "退货入库单", + RETURN_OUT: "返工出库单", + SPECIAL_IN: "特殊入库单", + SPECIAL_OUT: "特殊出库单" +}; +``` + +- [ ] **Step 2: Wrap generic operation drawer content** + +In the generic operation drawer, wrap current form fields in: + +```vue + + + + + + + + + + + + + + +``` + +- [ ] **Step 3: Keep production-specific forms on paper shell but production archive keys** + +For these entries, keep the same paper shell visual, but do not call `warehouse-operation` in save-success PDF actions: + +```text +原材料库 · 生产出库 -> production-material-out +成品库 · 生产入库 -> production-inbound-settlement +原材料库 · 生产余料入库 -> production-inbound-settlement when tied to production inbound settlement +废料库 · 生产废料入库 -> production-inbound-settlement when tied to production inbound settlement +生产台账入库 -> production-inbound-settlement +``` + +- [ ] **Step 4: Browser verify all operation drawers open** + +Use the in-app browser at `http://127.0.0.1:5173/inventory-ledger`. + +Verify one operation per warehouse: + +```text +原材料库:客料入库、生产出库、特殊出库 +半成品库:产中入库、委外出库 +成品库:生产入库、销售出库 +辅料库:生产出库 +废料库:生产废料入库、售卖出库 +退货库:退货入库、返工出库 +``` + +Expected: each opens as a paper-style form; no old large explanation card dominates the form. + +--- + +## Task 11: Add Archive Actions to Inventory Detail Drawer Ledger + +**Files:** +- Modify: `frontend/src/views/InventoryLedgerView.vue` +- Modify: `backend/app/services/operations.py` + +- [ ] **Step 1: Find detail drawer transaction source** + +Search: + +```bash +rg -n "get_inventory_txn_query|inventory_txn_id|库存流水|detail" frontend/src/views/InventoryLedgerView.vue backend/app/services/operations.py backend/app/api/routes/inventory.py +``` + +Use the same archive field strategy as `get_inventory_txn_ledger_query()`. + +- [ ] **Step 2: Add backend archive fields to detail transaction query** + +If the detail drawer uses `get_inventory_txn_query()`, extend that select with: + +```python +archive_document_type_expr, +archive_business_id_expr, +func.coalesce(latest_archive.c.status, ARCHIVE_STATUS_MISSING).label("archive_status"), +latest_archive.c.error_message.label("archive_error_message"), +``` + +- [ ] **Step 3: Add `单据留档` column in detail drawer** + +Render: + +```vue + +``` + +- [ ] **Step 4: Build and smoke test** + +Run: + +```bash +cd frontend +npm run build +``` + +Expected: PASS. + +--- + +## Task 12: Verification + +**Files:** +- Verify: backend tests +- Verify: frontend tests +- Verify: browser workflows + +- [ ] **Step 1: Run backend document tests** + +Run: + +```bash +cd backend +python -m pytest \ + tests/test_document_archive_routes.py \ + tests/test_document_archive_service.py \ + tests/test_warehouse_operation_document_archive.py \ + tests/test_inventory_ledger_export.py \ + tests/test_production_warehouse_document_archive.py \ + -q +``` + +Expected: PASS. + +- [ ] **Step 2: Run frontend tests** + +Run: + +```bash +cd frontend +npm test -- InventoryLedgerView.test.js ProductionLedgerView.test.js +``` + +Expected: PASS. + +- [ ] **Step 3: Run frontend build** + +Run: + +```bash +cd frontend +npm run build +``` + +Expected: PASS. + +- [ ] **Step 4: Browser verification checklist** + +In `http://127.0.0.1:5173/inventory-ledger`, verify: + +```text +1. 原材料库 · 客料入库保存后显示 PDF 状态和预览/下载/重新生成。 +2. 工具 -> 流水中每行有“单据留档”。 +3. 已归档流水可预览 PDF。 +4. 未归档流水点“重新生成”后刷新状态。 +5. 流水筛选开始日期、结束日期后,点“导出Excel”下载 xlsx。 +6. 未选时间范围点“导出Excel”时给轻提示,并导出当前筛选全部流水。 +7. Excel 表头为中文,备注、批次号、来源单据完整,不出现前端省略号。 +8. 生产台账入库形成的多条流水指向同一张“生产入库结算单”。 +9. 生产出库流水指向“生产领料出库单”,不是“仓库出入库单”。 +``` + +--- + +## Self-Review + +### Spec Coverage + +- 6 大库所有出入库纸质化:Task 8, Task 10. +- 客料入库补 PDF:Task 2, Task 5, Task 9. +- 通用 `仓库出入库单`:Task 1, Task 2. +- 生产专用 PDF 不重复生成通用仓库单:Task 3, Task 10. +- 仓库流水 PDF 预览/下载/重新生成:Task 3, Task 6. +- 库存明细流水 PDF 入口:Task 11. +- 各库流水按时间筛选导出 Excel:Task 4, Task 7. +- PDF/Excel 不省略、不英文状态:Task 2, Task 4, Task 12. + +### Placeholder Scan + +- No `TBD`. +- No vague "handle edge cases" step. +- Every code-changing task includes the exact file and representative code or command. + +### Type Consistency + +- Archive route key: `warehouse-operation`. +- Archive document type: `仓库出入库单`. +- Ledger fields: + - `archive_status` + - `archive_business_id` + - `archive_document_type` + - `archive_error_message` +- Existing production route keys preserved: + - `production-material-out` + - `production-inbound-settlement` + diff --git a/docs/superpowers/plans/2026-06-11-miniapp-operation-report-sync.md b/docs/superpowers/plans/2026-06-11-miniapp-operation-report-sync.md new file mode 100644 index 0000000..dfa4146 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-miniapp-operation-report-sync.md @@ -0,0 +1,668 @@ +# Miniapp Operation Report Sync Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make 工序报工 page refresh fast by separating read-only list refresh from explicit miniapp-to-ERP synchronization, with sync range fixed to automatic catch-up from the last successful sync time. + +**Architecture:** Keep the existing list endpoint for display, but make the frontend call it with `sync_to_erp=false`. Add a dedicated backend sync endpoint that reads and locks `MINIAPP_OPERATION_REPORT_LAST_SYNCED_AT` in `sys_system_config`, syncs all miniapp report items changed since that time, and only updates the timestamp after the transaction succeeds. The frontend adds a disabled loading state for the sync button and refreshes the list after sync. + +**Tech Stack:** FastAPI, SQLAlchemy, Pydantic, MySQL-compatible row locking, Vue 3 Composition API, Vite, Python `unittest`, Node static UI checks. + +--- + +## File Structure + +- Modify: `backend/app/services/system_config.py` + - Add constants and helpers for `MINIAPP_OPERATION_REPORT_LAST_SYNCED_AT`. + - Add parse/format helpers for sync timestamps. + - Add helpers to initialize and lock the sync config row, so first sync also has a database lock target. +- Modify: `backend/app/schemas/operations.py` + - Add `MiniAppOperationReportSyncRead`. +- Modify: `backend/app/api/routes/production.py` + - Add helper query for miniapp rows changed since the last successful sync. + - Add `POST /production/miniapp-operation-reports/sync`. + - Keep list endpoint behavior backward-compatible, but frontend will call it as read-only. +- Modify: `backend/tests/test_miniapp_operation_report_date_filter.py` + - Add tests for read-only list refresh and explicit automatic catch-up sync. +- Modify: `frontend/src/views/OperationReportView.vue` + - Change list refresh to `sync_to_erp=false`. + - Add sync button, loading state, last-sync display, and result feedback. +- Modify: `frontend/scripts/test-operation-report-date-filter.mjs` + - Update static checks for read-only refresh. +- Create: `frontend/scripts/test-operation-report-sync.mjs` + - Static checks for sync button, dedicated endpoint, disabled loading state, and auto-refresh after sync. + +--- + +## Task 1: Backend Sync Timestamp Helpers + +**Files:** +- Modify: `backend/app/services/system_config.py` +- Test: `backend/tests/test_miniapp_operation_report_date_filter.py` + +- [ ] **Step 1: Add failing helper tests** + +Append these imports in `backend/tests/test_miniapp_operation_report_date_filter.py`: + +```python +from app.services.system_config import ( # noqa: E402 + MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE, + format_miniapp_operation_report_last_sync, + get_miniapp_operation_report_last_sync, + parse_miniapp_operation_report_last_sync, + upsert_miniapp_operation_report_last_sync, +) +from app.models.org import SystemConfig # noqa: E402 +from sqlalchemy import select # noqa: E402 +``` + +Add this test method to `MiniAppOperationReportDateFilterTest`: + +```python + def test_miniapp_operation_report_last_sync_config_round_trips_datetime(self) -> None: + timestamp = datetime(2026, 6, 11, 10, 30, 15) + + row = upsert_miniapp_operation_report_last_sync(self.db, timestamp, updated_by=1) + self.db.commit() + + persisted = self.db.scalar( + select(SystemConfig).where(SystemConfig.config_code == MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE) + ) + self.assertIsNotNone(persisted) + self.assertEqual(row.config_value, "2026-06-11 10:30:15") + self.assertEqual(format_miniapp_operation_report_last_sync(timestamp), "2026-06-11 10:30:15") + self.assertEqual(parse_miniapp_operation_report_last_sync("2026-06-11 10:30:15"), timestamp) + self.assertEqual(get_miniapp_operation_report_last_sync(self.db), timestamp) +``` + +- [ ] **Step 2: Run the failing backend test** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +python -m pytest tests/test_miniapp_operation_report_date_filter.py::MiniAppOperationReportDateFilterTest::test_miniapp_operation_report_last_sync_config_round_trips_datetime -q +``` + +Expected: FAIL because the sync config helper functions do not exist. + +- [ ] **Step 3: Implement timestamp helpers** + +In `backend/app/services/system_config.py`, add the import: + +```python +from datetime import datetime +``` + +The file already imports `datetime`; keep one import only. + +Add these constants near existing config constants: + +```python +MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE = "MINIAPP_OPERATION_REPORT_LAST_SYNCED_AT" +MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_NAME = "小程序报工上次同步成功时间" +MINIAPP_OPERATION_REPORT_LAST_SYNC_FORMAT = "%Y-%m-%d %H:%M:%S" +``` + +Add these helpers after `get_smart_operation_report_enabled`: + +```python +def format_miniapp_operation_report_last_sync(value: datetime) -> str: + return value.strftime(MINIAPP_OPERATION_REPORT_LAST_SYNC_FORMAT) + + +def parse_miniapp_operation_report_last_sync(value: str | None) -> datetime | None: + normalized = str(value or "").strip() + if not normalized: + return None + return datetime.strptime(normalized, MINIAPP_OPERATION_REPORT_LAST_SYNC_FORMAT) + + +def get_miniapp_operation_report_last_sync(db: Session) -> datetime | None: + value = get_system_config_value(db, MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE, "") + return parse_miniapp_operation_report_last_sync(value) + + +def upsert_miniapp_operation_report_last_sync( + db: Session, + timestamp: datetime, + *, + updated_by: int | None = None, +) -> SystemConfig: + return upsert_system_config( + db, + config_code=MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE, + config_name=MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_NAME, + config_value=format_miniapp_operation_report_last_sync(timestamp), + remark="工序报工小程序同步 ERP 的上次成功截止时间", + updated_by=updated_by, + now=timestamp, + ) + + +def ensure_miniapp_operation_report_last_sync_config( + db: Session, + *, + updated_by: int | None = None, +) -> SystemConfig: + row = db.scalar(select(SystemConfig).where(SystemConfig.config_code == MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE)) + if row: + return row + timestamp = datetime.now() + row = SystemConfig( + config_code=MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE, + config_name=MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_NAME, + config_value="", + remark="工序报工小程序同步 ERP 的上次成功截止时间", + status="ACTIVE", + created_by=updated_by, + updated_by=updated_by, + created_at=timestamp, + updated_at=timestamp, + ) + db.add(row) + db.flush() + return row + + +def lock_miniapp_operation_report_last_sync(db: Session) -> SystemConfig | None: + ensure_miniapp_operation_report_last_sync_config(db) + return db.scalar( + select(SystemConfig) + .where( + SystemConfig.config_code == MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE, + SystemConfig.status == "ACTIVE", + ) + .with_for_update() + ) +``` + +- [ ] **Step 4: Run the helper test** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +python -m pytest tests/test_miniapp_operation_report_date_filter.py::MiniAppOperationReportDateFilterTest::test_miniapp_operation_report_last_sync_config_round_trips_datetime -q +``` + +Expected: PASS. + +--- + +## Task 2: Backend Read-Only List And Explicit Sync Endpoint + +**Files:** +- Modify: `backend/app/schemas/operations.py` +- Modify: `backend/app/api/routes/production.py` +- Modify: `backend/tests/test_miniapp_operation_report_date_filter.py` + +- [ ] **Step 1: Add failing tests for read-only refresh and automatic catch-up sync** + +Add imports in `backend/tests/test_miniapp_operation_report_date_filter.py`: + +```python +from app.api.routes import production as production_routes # noqa: E402 +from app.api.routes.production import sync_miniapp_operation_reports # noqa: E402 +from app.models.operations import OperationReport # noqa: E402 +``` + +Add this test method: + +```python + def test_list_reports_with_sync_disabled_does_not_call_sync(self) -> None: + calls: list[int] = [] + original_sync = production_routes._sync_miniapp_item_to_erp + + def fake_sync(db, source_item_id, **kwargs): + calls.append(source_item_id) + return None + + production_routes._sync_miniapp_item_to_erp = fake_sync + try: + rows = list_miniapp_operation_reports( + limit=100, + sync_to_erp=False, + start_date=date(2026, 5, 18), + end_date=date(2026, 6, 1), + db=self.db, + ) + finally: + production_routes._sync_miniapp_item_to_erp = original_sync + + self.assertEqual(len(rows), 2) + self.assertEqual(calls, []) + self.assertEqual(self.db.query(OperationReport).count(), 0) +``` + +Add this test method: + +```python + def test_sync_reports_uses_last_success_time_not_page_filter(self) -> None: + upsert_miniapp_operation_report_last_sync(self.db, datetime(2026, 5, 19, 0, 0, 0), updated_by=1) + self.db.commit() + calls: list[int] = [] + original_sync = production_routes._sync_miniapp_item_to_erp + original_now = production_routes.datetime + + class FixedDateTime(datetime): + @classmethod + def now(cls, tz=None): + return cls(2026, 6, 11, 12, 0, 0) + + def fake_sync(db, source_item_id, **kwargs): + calls.append(source_item_id) + return None + + production_routes._sync_miniapp_item_to_erp = fake_sync + production_routes.datetime = FixedDateTime + try: + result = sync_miniapp_operation_reports(db=self.db) + finally: + production_routes._sync_miniapp_item_to_erp = original_sync + production_routes.datetime = original_now + + self.assertEqual(calls, [2, 3]) + self.assertEqual(result.total, 2) + self.assertEqual(result.sync_from, datetime(2026, 5, 19, 0, 0, 0)) + self.assertEqual(result.sync_to, datetime(2026, 6, 11, 12, 0, 0)) + self.assertEqual(get_miniapp_operation_report_last_sync(self.db), datetime(2026, 6, 11, 12, 0, 0)) +``` + +- [ ] **Step 2: Run the failing tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +python -m pytest tests/test_miniapp_operation_report_date_filter.py -q +``` + +Expected: FAIL because `sync_miniapp_operation_reports` and response schema do not exist yet. + +- [ ] **Step 3: Add sync response schema** + +In `backend/app/schemas/operations.py`, add after `MiniAppOperationReportRead`: + +```python +class MiniAppOperationReportSyncRead(BaseModel): + sync_from: datetime | None = None + sync_to: datetime + last_synced_at: datetime + total: int = 0 + synced: int = 0 + skipped: int = 0 + failed: int = 0 +``` + +- [ ] **Step 4: Import sync helpers and schema** + +In `backend/app/api/routes/production.py`, extend the operations schema import to include: + +```python +MiniAppOperationReportSyncRead, +``` + +Extend the system config imports to include: + +```python +get_miniapp_operation_report_last_sync, +lock_miniapp_operation_report_last_sync, +upsert_miniapp_operation_report_last_sync, +``` + +- [ ] **Step 5: Add changed-since miniapp row helper** + +In `backend/app/api/routes/production.py`, add this helper near `_miniapp_report_item_rows`: + +```python +def _miniapp_report_item_rows_changed_since( + db: Session, + *, + sync_from: datetime | None, + sync_to: datetime, + limit: int = 800, +) -> list[dict]: + stmt = ( + select( + MiniAppProductionReport.id.label("source_report_id"), + MiniAppProductionReportItem.id.label("source_item_id"), + func.coalesce( + MiniAppProductionReportItem.attendance_point_name, + MiniAppProductionReport.attendance_point_name, + ).label("attendance_point_name"), + MiniAppProductionReport.employee_phone.label("employee_phone"), + MiniAppPersonnel.name.label("employee_name"), + MiniAppProductionReport.report_date.label("report_date"), + MiniAppProductionReport.start_at.label("start_time"), + MiniAppProductionReport.end_at.label("end_time"), + MiniAppProductionReport.duration_minutes.label("duration_minutes"), + MiniAppProductionReport.effective_minutes.label("effective_minutes"), + MiniAppProductionReport.status.label("status"), + MiniAppProductionReport.reviewer_phone.label("reviewer_phone"), + MiniAppProductionReport.reviewed_at.label("reviewed_at"), + MiniAppProductionReport.reject_reason.label("reject_reason"), + MiniAppProductionReportItem.device_no.label("device_no"), + MiniAppProductionReportItem.project_no.label("project_no"), + MiniAppProductionReportItem.product_name.label("product_name"), + MiniAppProductionReportItem.material_code.label("material_code"), + MiniAppProductionReportItem.material_name.label("material_name"), + MiniAppProductionReportItem.raw_material_batch_no.label("raw_material_batch_no"), + MiniAppProductionReportItem.process_name.label("process_name"), + MiniAppProductionReportItem.stamping_method.label("stamping_method"), + MiniAppProductionReportItem.standard_beat.label("standard_beat"), + MiniAppProductionReportItem.standard_workload.label("standard_workload"), + MiniAppProductionReportItem.good_qty.label("good_qty"), + MiniAppProductionReportItem.defect_qty.label("defect_qty"), + MiniAppProductionReportItem.scrap_qty.label("miniapp_scrap_qty"), + MiniAppProductionReportItem.allocated_minutes.label("allocated_minutes"), + MiniAppProductionReportItem.started_at.label("started_at"), + ) + .join(MiniAppProductionReport, MiniAppProductionReport.id == MiniAppProductionReportItem.report_id) + .outerjoin(MiniAppPersonnel, MiniAppPersonnel.phone == MiniAppProductionReport.employee_phone) + .where(MiniAppProductionReport.updated_at <= sync_to) + .order_by(MiniAppProductionReport.updated_at.asc(), MiniAppProductionReportItem.id.asc()) + .limit(limit) + ) + if sync_from is not None: + stmt = stmt.where(MiniAppProductionReport.updated_at > sync_from) + rows = db.execute(stmt).mappings().all() + return [dict(row) for row in rows] +``` + +- [ ] **Step 6: Add explicit sync endpoint** + +In `backend/app/api/routes/production.py`, add before the existing `@router.get("/miniapp-operation-reports"` route: + +```python +@router.post("/miniapp-operation-reports/sync", response_model=MiniAppOperationReportSyncRead) +def sync_miniapp_operation_reports( + limit: int = Query(default=800, ge=1, le=800), + db: Session = Depends(get_db), +) -> MiniAppOperationReportSyncRead: + lock_miniapp_operation_report_last_sync(db) + sync_from = get_miniapp_operation_report_last_sync(db) + sync_to = datetime.now() + rows = _miniapp_report_item_rows_changed_since(db, sync_from=sync_from, sync_to=sync_to, limit=limit) + cache = _new_miniapp_resolve_cache() + report_nos = [_miniapp_report_no(int(row["source_report_id"]), int(row["source_item_id"])) for row in rows] + if report_nos: + existing_reports = db.scalars(select(OperationReport).where(OperationReport.report_no.in_(report_nos))).all() + cache["report"].update({report.report_no: report for report in existing_reports}) + + touched_work_order_ids: set[int] = set() + synced = 0 + skipped = 0 + for row in rows: + synced_report = _sync_miniapp_item_to_erp( + db, + int(row["source_item_id"]), + required=False, + row=row, + cache=cache, + sync_status=False, + ) + if synced_report: + synced += 1 + if synced_report.work_order_id: + touched_work_order_ids.add(int(synced_report.work_order_id)) + else: + skipped += 1 + + for work_order_id in touched_work_order_ids: + sync_work_order_status(db, work_order_id) + row = upsert_miniapp_operation_report_last_sync(db, sync_to) + db.commit() + db.refresh(row) + return MiniAppOperationReportSyncRead( + sync_from=sync_from, + sync_to=sync_to, + last_synced_at=sync_to, + total=len(rows), + synced=synced, + skipped=skipped, + failed=0, + ) +``` + +- [ ] **Step 7: Run backend tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +python -m pytest tests/test_miniapp_operation_report_date_filter.py -q +``` + +Expected: PASS. + +--- + +## Task 3: Frontend Read-Only Refresh And Sync Button + +**Files:** +- Modify: `frontend/src/views/OperationReportView.vue` +- Modify: `frontend/scripts/test-operation-report-date-filter.mjs` +- Create: `frontend/scripts/test-operation-report-sync.mjs` + +- [ ] **Step 1: Add failing frontend static test** + +Create `frontend/scripts/test-operation-report-sync.mjs`: + +```javascript +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +const root = process.cwd(); +const source = readFileSync(path.join(root, "src/views/OperationReportView.vue"), "utf8"); + +assert.match(source, /syncReportsToErp/, "operation report page should define an explicit ERP sync action"); +assert.match(source, /postResource\("\/production\/miniapp-operation-reports\/sync"/, "sync action should call the dedicated sync endpoint"); +assert.match(source, /syncingReports\.value\s*=\s*true/, "sync action should enable loading state"); +assert.match(source, /:disabled="syncingReports"/, "sync button should be disabled while syncing"); +assert.match(source, /同步中/, "sync button should show a syncing label"); +assert.match(source, /lastSyncedAt/, "page should display the last successful sync time"); +assert.match(source, /await loadReports\(\)/, "page should refresh the list after sync succeeds"); +assert.doesNotMatch(source, /sync_to_erp:\s*"true"/, "list refresh should not sync ERP implicitly"); +assert.match(source, /sync_to_erp:\s*"false"/, "list refresh should explicitly request read-only data"); + +console.log("operation report sync checks passed"); +``` + +- [ ] **Step 2: Run failing frontend test** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node scripts/test-operation-report-sync.mjs +``` + +Expected: FAIL because the sync action and button do not exist. + +- [ ] **Step 3: Update imports and state** + +In `frontend/src/views/OperationReportView.vue`, change: + +```javascript +import { fetchResource } from "../services/api"; +``` + +to: + +```javascript +import { fetchResource, postResource } from "../services/api"; +``` + +Add after `const reports = ref([]);`: + +```javascript +const syncingReports = ref(false); +const lastSyncedAt = ref(""); +``` + +- [ ] **Step 4: Make list refresh read-only** + +In `loadReports()`, change: + +```javascript +const params = new URLSearchParams({ + limit: "800", + sync_to_erp: "true" +}); +``` + +to: + +```javascript +const params = new URLSearchParams({ + limit: "800", + sync_to_erp: "false" +}); +``` + +Change success text from: + +```javascript +feedbackMessage.value = "小程序报工数据已刷新并同步 ERP 生产核算"; +``` + +to: + +```javascript +feedbackMessage.value = "小程序报工列表已刷新"; +``` + +- [ ] **Step 5: Add explicit sync action** + +Add after `loadReports()`: + +```javascript +async function syncReportsToErp() { + if (syncingReports.value) { + return; + } + feedbackMessage.value = ""; + errorMessage.value = ""; + syncingReports.value = true; + try { + const result = await postResource("/production/miniapp-operation-reports/sync", {}); + lastSyncedAt.value = result.last_synced_at || result.sync_to || ""; + feedbackMessage.value = [ + `同步完成:共 ${result.total || 0} 条`, + `成功 ${result.synced || 0} 条`, + `跳过 ${result.skipped || 0} 条`, + `失败 ${result.failed || 0} 条`, + `同步范围:${result.sync_from || "首次同步"} 至 ${result.sync_to || "-"}` + ].join(","); + await loadReports(); + } catch (error) { + errorMessage.value = error.message || "同步小程序报工到 ERP 失败,上次同步成功时间未更新,请稍后重试"; + } finally { + syncingReports.value = false; + } +} +``` + +- [ ] **Step 6: Add sync UI** + +In the template, inside `.operation-filter-ribbon` after the refresh button, add: + +```vue + +上次同步:{{ lastSyncedAt || "暂无" }} +``` + +- [ ] **Step 7: Update existing date-filter static test** + +In `frontend/scripts/test-operation-report-date-filter.mjs`, replace the old negative check: + +```javascript +assert.doesNotMatch( + source, + /fetchResource\("\/production\/miniapp-operation-reports\?limit=800&sync_to_erp=true"/, + "operation report page must not use the old all-row request" +); +``` + +with: + +```javascript +assert.match(source, /sync_to_erp:\s*"false"/, "operation report refresh should be read-only by default"); +assert.doesNotMatch(source, /sync_to_erp:\s*"true"/, "operation report refresh must not sync ERP implicitly"); +``` + +- [ ] **Step 8: Run frontend static tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node scripts/test-operation-report-date-filter.mjs +node scripts/test-operation-report-sync.mjs +``` + +Expected: both PASS. + +--- + +## Task 4: Final Verification + +**Files:** +- Backend and frontend files from prior tasks. + +- [ ] **Step 1: Run backend focused tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +python -m pytest tests/test_miniapp_operation_report_date_filter.py -q +``` + +Expected: PASS. + +- [ ] **Step 2: Run frontend static checks** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node scripts/test-operation-report-date-filter.mjs +node scripts/test-operation-report-sync.mjs +``` + +Expected: PASS. + +- [ ] **Step 3: Run frontend build** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +npm run build +``` + +Expected: Vite build succeeds. Chunk-size warning is acceptable if it matches the existing project warning. + +- [ ] **Step 4: Manual browser verification** + +Open `http://127.0.0.1:5173/operation-reports` and verify: + +- Page loads the list without waiting for ERP sync. +- `刷新` only refreshes the current visible list. +- `同步到 ERP` changes to `同步中...` and cannot be clicked again while syncing. +- Sync success message shows total, success, skipped, failed, and sync range. +- The list refreshes after sync completes. +- Page date filters do not affect the backend sync range. + +--- + +## Self-Review + +- Spec coverage: The plan covers read-only refresh, automatic catch-up sync, no user sync date range, loading button, last-sync persistence, idempotent existing sync reuse, and verification. +- Placeholder scan: No unfinished placeholders or vague “add tests” instructions remain. +- Type consistency: The plan consistently uses `MiniAppOperationReportSyncRead`, `sync_miniapp_operation_reports`, `MINIAPP_OPERATION_REPORT_LAST_SYNCED_AT`, `syncingReports`, and `lastSyncedAt`. diff --git a/docs/superpowers/plans/2026-06-11-production-inbound-archive-entrypoints.md b/docs/superpowers/plans/2026-06-11-production-inbound-archive-entrypoints.md new file mode 100644 index 0000000..2502e7a --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-production-inbound-archive-entrypoints.md @@ -0,0 +1,852 @@ +# Production Inbound Archive Entrypoints Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Expose preview/download/regenerate entrypoints for `生产入库结算单` in the places users naturally look after production ledger inbound creates multiple warehouse transactions. + +**Architecture:** Keep `生产台账入库` as one business settlement action and keep all generated warehouse transactions linked to the same `生产入库结算单` archive. The backend warehouse transaction ledger will return archive metadata for production settlement transactions; the frontend will reuse `DocumentArchiveActions` in the warehouse ledger and show a post-save shortcut when `生产台账入库` succeeds. + +**Tech Stack:** FastAPI, SQLAlchemy, Pydantic, existing `document_archives` service/routes, Vue 3, existing `DocumentArchiveActions`, existing `openResource`/`downloadResource`/`postResource`. + +--- + +## File Structure + +### Backend + +- Modify: `backend/app/schemas/operations.py` + - Add archive metadata fields to `InventoryTxnRead`, inherited by `InventoryTxnLedgerRowRead`. +- Modify: `backend/app/services/operations.py` + - Add `DocumentArchive` and production archive constants imports if missing. + - Add helper functions for production settlement archive grouping on warehouse inventory transactions. + - Extend `get_inventory_txn_ledger_query` to select archive metadata for production settlement rows. +- Test: `backend/tests/test_inventory_transaction_ledger_archive_fields.py` + - Add a focused test that proves finished, surplus, and scrap rows from the same production settlement batch all expose the same archive business ID and status. + +### Frontend + +- Modify: `frontend/src/views/InventoryLedgerView.vue` + - Import `DocumentArchiveActions`. + - Import `downloadResource` and `openResource` if not already imported. + - Add a `单据留档` column to the warehouse流水 drawer. + - Render `DocumentArchiveActions` for rows with `archive_business_id`. + - Add archive preview/download/regenerate handlers. + - Add a post-save shortcut panel after `生产台账入库` succeeds. +- Modify: `frontend/src/views/InventoryLedgerView.test.js` + - Add static assertions for the new warehouse ledger archive column, document type key, and post-save shortcut state. +- Modify: `frontend/src/styles/main.css` + - Add compact archive action styling for table cells and feedback shortcut panels. + +--- + +## Current Reality To Preserve + +- `生产台账入库` already calls `POST /production/production-ledger-inbounds`. +- That endpoint already returns: + - `archive_status` + - `archive_business_id` + - `archive_document_type` + - `archive_error_message` +- `ProductionLedgerView.vue` already uses `DocumentArchiveActions` for `production-material-out` and `production-inbound-settlement`. +- `InventoryLedgerView.vue` warehouse ledger currently has no archive column. +- `GET /inventory/transaction-ledger` currently returns `InventoryTxnLedgerRowRead`, which does not yet include archive metadata. + +--- + +### Task 1: Backend Test For Warehouse Ledger Archive Metadata + +**Files:** +- Create: `backend/tests/test_inventory_transaction_ledger_archive_fields.py` + +- [ ] **Step 1: Write the failing test** + +Create `backend/tests/test_inventory_transaction_ledger_archive_fields.py`: + +```python +from __future__ import annotations + +import unittest +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import BigInteger, create_engine +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_big_integer_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.document_archive # noqa: E402,F401 +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.document_archive import DocumentArchive # noqa: E402 +from app.models.master_data import Item, Warehouse # noqa: E402 +from app.models.operations import InventoryTxn, StockLot # noqa: E402 +from app.services.document_archives import DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT # noqa: E402 +from app.services.operations import get_inventory_txn_ledger_query # noqa: E402 + + +class InventoryTransactionLedgerArchiveFieldsTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self.now = datetime(2026, 6, 11, 14, 0, 0) + self._seed_data() + + def tearDown(self) -> None: + self.db.close() + + def _seed_data(self) -> None: + finished_wh = Warehouse( + id=1, + warehouse_code="WH-FIN", + warehouse_name="成品库", + warehouse_type="FINISHED", + status="ACTIVE", + created_at=self.now, + updated_at=self.now, + ) + raw_wh = Warehouse( + id=2, + warehouse_code="WH-RAW", + warehouse_name="原材料库", + warehouse_type="RAW", + status="ACTIVE", + created_at=self.now, + updated_at=self.now, + ) + scrap_wh = Warehouse( + id=3, + warehouse_code="WH-SCRAP", + warehouse_name="废料库", + warehouse_type="SCRAP", + status="ACTIVE", + created_at=self.now, + updated_at=self.now, + ) + material = Item( + id=10, + item_code="RM-010", + item_name="测试原材料", + item_type="RAW_MATERIAL", + unit_weight_kg=Decimal("0"), + status="ACTIVE", + created_at=self.now, + updated_at=self.now, + ) + product = Item( + id=20, + item_code="FG-020", + item_name="测试成品", + item_type="FINISHED", + unit_weight_kg=Decimal("0.5"), + status="ACTIVE", + created_at=self.now, + updated_at=self.now, + ) + source_lot = StockLot( + id=100, + lot_no="YL0001", + item_id=material.id, + warehouse_id=raw_wh.id, + location_id=None, + inbound_qty=0, + inbound_weight_kg=500, + remaining_qty=0, + remaining_weight_kg=200, + locked_qty=0, + locked_weight_kg=0, + unit_cost=Decimal("6.5"), + quality_status="PASS", + status="AVAILABLE", + created_at=self.now, + updated_at=self.now, + ) + finished_lot = StockLot( + id=101, + lot_no="FGL0001", + parent_lot_id=source_lot.id, + item_id=product.id, + warehouse_id=finished_wh.id, + location_id=None, + source_material_lot_id=source_lot.id, + source_material_sub_batch_no="YL0001", + inbound_qty=100, + inbound_weight_kg=50, + remaining_qty=100, + remaining_weight_kg=50, + locked_qty=0, + locked_weight_kg=0, + unit_cost=Decimal("3.25"), + quality_status="PASS", + status="AVAILABLE", + created_at=self.now, + updated_at=self.now, + ) + scrap_lot = StockLot( + id=102, + lot_no="SCRAPI0001", + parent_lot_id=source_lot.id, + item_id=material.id, + warehouse_id=scrap_wh.id, + location_id=None, + source_material_lot_id=source_lot.id, + source_material_sub_batch_no="YL0001", + inbound_qty=0, + inbound_weight_kg=5, + remaining_qty=0, + remaining_weight_kg=5, + locked_qty=0, + locked_weight_kg=0, + unit_cost=Decimal("6.5"), + quality_status="SCRAP", + status="AVAILABLE", + created_at=self.now, + updated_at=self.now, + ) + batch_no = "生产结算20260611140000000001" + self.finished_txn = InventoryTxn( + id=1000, + txn_no="TXN-FIN-001", + txn_type="FG_IN", + item_id=product.id, + warehouse_id=finished_wh.id, + location_id=None, + lot_id=finished_lot.id, + qty_change=Decimal("100"), + weight_change_kg=Decimal("50"), + unit_cost=Decimal("3.25"), + amount=Decimal("325"), + source_doc_type="生产台账", + source_doc_id=2000, + source_line_id=finished_lot.id, + biz_time=self.now, + remark=f"生产台账入库;成品入库;单据批次号:{batch_no}", + created_at=self.now, + updated_at=self.now, + ) + self.surplus_txn = InventoryTxn( + id=1001, + txn_no="TXN-SURPLUS-001", + txn_type="PRODUCTION_SURPLUS_IN", + item_id=material.id, + warehouse_id=raw_wh.id, + location_id=None, + lot_id=source_lot.id, + qty_change=Decimal("0"), + weight_change_kg=Decimal("20"), + unit_cost=Decimal("6.5"), + amount=Decimal("130"), + source_doc_type="生产台账", + source_doc_id=2000, + source_line_id=source_lot.id, + biz_time=self.now, + remark=f"生产台账入库;生产余料入库;单据批次号:{batch_no}", + created_at=self.now, + updated_at=self.now, + ) + self.scrap_txn = InventoryTxn( + id=1002, + txn_no="TXN-SCRAP-001", + txn_type="PRODUCTION_SCRAP_IN", + item_id=material.id, + warehouse_id=scrap_wh.id, + location_id=None, + lot_id=scrap_lot.id, + qty_change=Decimal("0"), + weight_change_kg=Decimal("5"), + unit_cost=Decimal("6.5"), + amount=Decimal("32.5"), + source_doc_type="生产台账", + source_doc_id=2000, + source_line_id=scrap_lot.id, + biz_time=self.now, + remark=f"生产台账入库;生产废料入库;单据批次号:{batch_no}", + created_at=self.now, + updated_at=self.now, + ) + archive = DocumentArchive( + document_type=DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + business_id=self.finished_txn.id, + document_no="生产入库结算-TXN-FIN-001", + archive_version=1, + file_format="PDF", + file_name="生产入库结算-TXN-FIN-001_V1.pdf", + file_path="/tmp/生产入库结算-TXN-FIN-001_V1.pdf", + status="已归档", + error_message=None, + created_by=None, + created_at=self.now, + ) + self.db.add_all([ + finished_wh, + raw_wh, + scrap_wh, + material, + product, + source_lot, + finished_lot, + scrap_lot, + self.finished_txn, + self.surplus_txn, + self.scrap_txn, + archive, + ]) + self.db.commit() + + def test_finished_warehouse_row_exposes_own_settlement_archive(self) -> None: + rows = self.db.execute(get_inventory_txn_ledger_query(warehouse_type="FINISHED")).mappings().all() + + self.assertEqual(len(rows), 1) + row = dict(rows[0]) + self.assertEqual(row["inventory_txn_id"], self.finished_txn.id) + self.assertEqual(row["archive_business_id"], self.finished_txn.id) + self.assertEqual(row["archive_document_type"], DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT) + self.assertEqual(row["archive_status"], "已归档") + + def test_surplus_warehouse_row_exposes_same_settlement_archive(self) -> None: + rows = self.db.execute(get_inventory_txn_ledger_query(warehouse_type="RAW")).mappings().all() + + self.assertEqual(len(rows), 1) + row = dict(rows[0]) + self.assertEqual(row["inventory_txn_id"], self.surplus_txn.id) + self.assertEqual(row["archive_business_id"], self.finished_txn.id) + self.assertEqual(row["archive_document_type"], DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT) + self.assertEqual(row["archive_status"], "已归档") + + def test_scrap_warehouse_row_exposes_same_settlement_archive(self) -> None: + rows = self.db.execute(get_inventory_txn_ledger_query(warehouse_type="SCRAP")).mappings().all() + + self.assertEqual(len(rows), 1) + row = dict(rows[0]) + self.assertEqual(row["inventory_txn_id"], self.scrap_txn.id) + self.assertEqual(row["archive_business_id"], self.finished_txn.id) + self.assertEqual(row["archive_document_type"], DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT) + self.assertEqual(row["archive_status"], "已归档") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +python -m unittest backend.tests.test_inventory_transaction_ledger_archive_fields -v +``` + +Expected: fail with missing `archive_business_id`/`archive_document_type`/`archive_status` columns on the warehouse transaction ledger query. + +--- + +### Task 2: Add Archive Metadata To Warehouse Transaction Ledger + +**Files:** +- Modify: `backend/app/schemas/operations.py` +- Modify: `backend/app/services/operations.py` + +- [ ] **Step 1: Extend the response schema** + +In `backend/app/schemas/operations.py`, add these fields to `InventoryTxnRead` after `remark`: + +```python + archive_status: str | None = None + archive_business_id: int | None = None + archive_document_type: str | None = None + archive_error_message: str | None = None +``` + +- [ ] **Step 2: Import production archive constants** + +In `backend/app/services/operations.py`, add this import near the other service imports: + +```python +from app.services.document_archives import DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, production_document_batch_no_from_text +``` + +- [ ] **Step 3: Add production settlement helper constants** + +In `backend/app/services/operations.py`, near `WAREHOUSE_LEDGER_SORT_COLUMNS`, add: + +```python +PRODUCTION_SETTLEMENT_INVENTORY_TXN_TYPES = { + "FG_IN", + "PRODUCTION_SURPLUS_IN", + "PRODUCTION_SCRAP_IN", +} + +PRODUCTION_SETTLEMENT_MAIN_TXN_PRIORITY = { + "FG_IN": 1, + "PRODUCTION_SURPLUS_IN": 2, + "PRODUCTION_SCRAP_IN": 3, +} +``` + +- [ ] **Step 4: Add a settlement main transaction subquery helper** + +In `backend/app/services/operations.py`, add this helper before `get_inventory_txn_ledger_query`: + +```python +def _production_settlement_archive_subquery(): + settlement_txn = aliased(InventoryTxn) + settlement_batch_no = func.coalesce( + production_document_batch_no_from_text(settlement_txn.remark), + func.date_format(settlement_txn.biz_time, "%Y-%m-%d %H:%i:%s.%f"), + ).label("settlement_group_key") + priority_expr = case( + *[ + (settlement_txn.txn_type == txn_type, priority) + for txn_type, priority in PRODUCTION_SETTLEMENT_MAIN_TXN_PRIORITY.items() + ], + else_=99, + ) + grouped = ( + select( + settlement_txn.source_doc_id.label("production_ledger_id"), + settlement_batch_no, + func.min( + case( + *[ + (settlement_txn.txn_type == txn_type, priority) + for txn_type, priority in PRODUCTION_SETTLEMENT_MAIN_TXN_PRIORITY.items() + ], + else_=99, + ) + * 1_000_000_000 + + settlement_txn.id + ).label("main_rank"), + ) + .where( + settlement_txn.source_doc_type == "生产台账", + settlement_txn.source_doc_id.is_not(None), + settlement_txn.txn_type.in_(PRODUCTION_SETTLEMENT_INVENTORY_TXN_TYPES), + ) + .group_by(settlement_txn.source_doc_id, settlement_batch_no) + .subquery() + ) + return grouped +``` + +If MySQL-specific `date_format` breaks SQLite tests, replace the fallback group key with `func.coalesce(production_document_batch_no_from_text(settlement_txn.remark), settlement_txn.biz_time)`. The seed data uses `单据批次号` in all remarks, so the SQLite tests still exercise the intended path. + +- [ ] **Step 5: Join archive metadata in `get_inventory_txn_ledger_query`** + +In `get_inventory_txn_ledger_query`, add aliases before building `stmt`: + +```python + settlement_grouped = _production_settlement_archive_subquery() + settlement_main_txn = aliased(InventoryTxn) + settlement_archive = aliased(DocumentArchive) + current_batch_no = func.coalesce( + production_document_batch_no_from_text(InventoryTxn.remark), + func.date_format(InventoryTxn.biz_time, "%Y-%m-%d %H:%i:%s.%f"), + ) +``` + +Add these selected columns after `InventoryTxn.remark.label("remark")`: + +```python + settlement_archive.status.label("archive_status"), + settlement_main_txn.id.label("archive_business_id"), + literal(DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT).label("archive_document_type"), + settlement_archive.error_message.label("archive_error_message"), +``` + +Add these joins after the operator join: + +```python + .outerjoin( + settlement_grouped, + and_( + InventoryTxn.source_doc_type == "生产台账", + InventoryTxn.source_doc_id == settlement_grouped.c.production_ledger_id, + current_batch_no == settlement_grouped.c.settlement_group_key, + InventoryTxn.txn_type.in_(PRODUCTION_SETTLEMENT_INVENTORY_TXN_TYPES), + ), + ) + .outerjoin( + settlement_main_txn, + settlement_main_txn.id == settlement_grouped.c.main_rank % 1_000_000_000, + ) + .outerjoin( + settlement_archive, + and_( + settlement_archive.document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + settlement_archive.business_id == settlement_main_txn.id, + settlement_archive.file_format == "PDF", + settlement_archive.archive_version + == select(func.max(DocumentArchive.archive_version)) + .where( + DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + DocumentArchive.business_id == settlement_main_txn.id, + DocumentArchive.file_format == "PDF", + ) + .scalar_subquery(), + ), + ) +``` + +- [ ] **Step 6: Run the backend test** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +python -m unittest backend.tests.test_inventory_transaction_ledger_archive_fields -v +``` + +Expected: all 3 tests pass. + +--- + +### Task 3: Frontend Static Tests For Warehouse Ledger Archive Entrypoints + +**Files:** +- Modify: `frontend/src/views/InventoryLedgerView.test.js` + +- [ ] **Step 1: Add static assertions** + +Append this `describe` block to `frontend/src/views/InventoryLedgerView.test.js`: + +```javascript +describe("InventoryLedgerView document archive entrypoints", () => { + it("renders a warehouse ledger archive column using production settlement archive actions", () => { + assert.match(source, / +``` + +In the row body, add the matching cell before `备注`: + +```vue + +``` + +Update the empty-row colspan expression by adding 1: + +```vue +:colspan="activeWeightOnly || activeQuantityOnly ? 13 : 14" +``` + +- [ ] **Step 5: Add archive action handlers** + +Add these functions near `openLogisticsPhoto`: + +```javascript +function buildWarehouseLedgerArchiveFilename(row) { + const lotNo = row.source_material_lot_no || row.lot_no || activeInventoryLabel.value || "生产入库结算单"; + const typeLabel = formatInventoryTxnTypeLabel(row.txn_type) || "生产入库结算单"; + return `${lotNo}-${typeLabel}归档.pdf`; +} + +async function previewDocumentArchive(typeKey, businessId) { + resetFeedback(); + if (!businessId) { + errorMessage.value = "缺少归档业务ID,无法预览"; + return; + } + try { + await openResource(`/document-archives/${typeKey}/${businessId}/latest/preview`); + } catch (error) { + errorMessage.value = error.message || "PDF归档预览失败"; + } +} + +async function downloadDocumentArchive(typeKey, businessId, fallbackFilename) { + resetFeedback(); + if (!businessId) { + errorMessage.value = "缺少归档业务ID,无法下载"; + return; + } + try { + await downloadResource(`/document-archives/${typeKey}/${businessId}/latest/download`, fallbackFilename); + feedbackMessage.value = "PDF归档已下载"; + } catch (error) { + errorMessage.value = error.message || "PDF归档下载失败"; + } +} + +async function regenerateDocumentArchive(typeKey, businessId) { + resetFeedback(); + if (!businessId) { + errorMessage.value = "缺少归档业务ID,无法重新生成"; + return; + } + try { + const result = await postResource(`/document-archives/${typeKey}/${businessId}/generate`, {}); + feedbackMessage.value = result.message || "PDF归档已重新生成"; + await loadWarehouseLedger(); + await loadAll(); + } catch (error) { + errorMessage.value = error.message || "PDF归档重新生成失败"; + } +} +``` + +- [ ] **Step 6: Store shortcut after `生产台账入库` save** + +In `submitProductionWorkOrderInbound`, after the API result: + +```javascript +lastArchiveAction.value = buildProductionInboundArchiveAction(result); +feedbackMessage.value = `生产台账入库 ${result.material_lot_no || ""} 已保存${formatArchiveFeedback(result)}`; +``` + +Keep the drawer close behavior unchanged. + +- [ ] **Step 7: Add compact styles** + +Append to `frontend/src/styles/main.css`: + +```css +.warehouse-ledger-archive-cell { + min-width: 260px; +} + +.document-archive-actions-compact { + align-items: stretch; + display: grid; + gap: 6px; +} + +.document-archive-actions-compact .document-archive-status { + justify-content: center; + min-height: 26px; +} + +.document-archive-actions-compact .document-archive-buttons { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 5px; +} + +.document-archive-actions-compact .document-archive-button { + min-height: 28px; + padding: 0 7px; + font-size: 11px; +} + +.document-archive-feedback-panel { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin: 10px 0 14px; + padding: 10px; + border: 1px solid rgba(31, 122, 74, 0.26); + border-radius: 14px; + background: rgba(232, 246, 232, 0.76); +} + +.document-archive-feedback-panel > span { + color: #145c39; + font-weight: 900; +} +``` + +- [ ] **Step 8: Run the frontend static test** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node --test src/views/InventoryLedgerView.test.js +``` + +Expected: pass. + +--- + +### Task 5: Verification + +**Files:** +- No code files. + +- [ ] **Step 1: Run backend archive metadata test** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +python -m unittest backend.tests.test_inventory_transaction_ledger_archive_fields -v +``` + +Expected: all tests pass. + +- [ ] **Step 2: Run frontend static tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node --test src/views/InventoryLedgerView.test.js src/views/ProductionLedgerView.test.js +``` + +Expected: all tests pass. + +- [ ] **Step 3: Build frontend** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +npm run build +``` + +Expected: build exits 0. The existing Vite chunk-size warning is acceptable. + +- [ ] **Step 4: Browser verify warehouse ledger** + +Use the in-app browser: + +1. Open `http://127.0.0.1:5173/inventory-ledger`. +2. Click `工具 -> 流水`. +3. Find a `生产台账` related `成品入库` / `生产余料入库` / `生产废料入库` row. +4. Confirm the `单据留档` column shows `已归档` plus `预览PDF` and `下载PDF`. +5. Click `预览PDF`. +6. Confirm the opened PDF is `生产入库结算单` and includes 成品、余料、废料明细 when the settlement action created multiple warehouse transactions. + +- [ ] **Step 5: Browser verify post-save shortcut** + +Use the in-app browser: + +1. Open `http://127.0.0.1:5173/inventory-ledger`. +2. Click `工具 -> 生产台账入库`. +3. Select an open production ledger with safe test values. +4. Save. +5. Confirm the success area shows `预览结算单` and `下载PDF`. +6. Click `预览结算单`. +7. Confirm it opens the same `生产入库结算单` archive as the warehouse ledger row. + +--- + +## Self-Review + +- Spec coverage: The plan covers the approved design: a single `生产入库结算单` shared by the multiple warehouse流水 rows from one production ledger inbound action, warehouse ledger buttons, production ledger detail already existing, and post-save shortcut. +- Placeholder scan: No `TBD`, `TODO`, or vague “add appropriate handling” steps remain. +- Type consistency: The plan consistently uses `archive_status`, `archive_business_id`, `archive_document_type`, `archive_error_message`, and route key `production-inbound-settlement`. +- Scope control: This plan does not implement all warehouse branch PDFs. It only exposes production settlement archives already generated by the production main chain. diff --git a/docs/superpowers/plans/2026-06-11-production-warehouse-document-archive.md b/docs/superpowers/plans/2026-06-11-production-warehouse-document-archive.md new file mode 100644 index 0000000..4c040d7 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-production-warehouse-document-archive.md @@ -0,0 +1,1666 @@ +# Production Warehouse Document Archive Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add formal PDF archive support for the production warehouse chain: production material issue, production ledger inbound, finished production inbound, production surplus inbound, and production scrap inbound. + +**Architecture:** Reuse the existing `document_archives` table, PDF renderer, and `/document-archives` API. Add two document types, `生产领料出库单` keyed by `ProductionBatchLedger.id` and `生产入库结算单` keyed by the main `InventoryTxn.id`; expose archive status through production ledger rows and production ledger transaction rows. + +**Tech Stack:** FastAPI, SQLAlchemy, Pydantic, ReportLab PDF generation, Vue 3, existing document form/archive components. + +--- + +## File Structure + +## Scope Mapping + +- 原材料库 · 生产出库: implemented by `生产领料出库单`, generated after raw material production outbound updates `ProductionBatchLedger`. +- 生产台账入口 · 生产工单入库: implemented by `生产入库结算单`, generated after `/production/production-ledger-inbounds` saves. +- 成品库 · 生产入库: implemented by `生产入库结算单`, generated from the branch production finished inbound main `InventoryTxn`. +- 原材料库 · 生产余料入库: implemented by `生产入库结算单`, generated from the branch production surplus inbound main `InventoryTxn`. +- 废料库 · 生产废料入库: implemented by `生产入库结算单`, generated from the branch production scrap inbound main `InventoryTxn`. + +### Backend + +- Modify: `backend/app/services/document_archives.py` + - Add production warehouse document constants. + - Add production material issue context collector. + - Add production inbound settlement context collector. + - Add helper to group settlement inventory transactions from a main transaction. + - Add document type dispatch. +- Modify: `backend/app/api/routes/document_archives.py` + - Add route keys `production-material-out` and `production-inbound-settlement`. + - Update Chinese validation error text. +- Modify: `backend/app/schemas/operations.py` + - Add archive fields to `ProductionBatchLedgerRead`. + - Add archive fields to `ProductionBatchLedgerTxnRead`. + - Add archive fields to `ProductionBatchLedgerInboundRead`. + - Add archive fields to `ProductionWorkOrderInboundRead`. + - Add optional archive fields to `StockLotRead` so warehouse branch entry responses can carry the archive result. +- Modify: `backend/app/api/routes/production.py` + - Generate `生产入库结算单` after `/production/production-ledger-inbounds` saves. + - Generate `生产入库结算单` after `/production/work-order-inbounds` saves through the existing service response. + - Add latest archive joins to `/production/production-ledger`. +- Modify: `backend/app/services/production_work_order_inbound.py` + - Generate `生产入库结算单` after old work-order inbound saves. + - Return archive status fields without rolling back saved business data. +- Modify: `backend/app/api/routes/inventory.py` + - Generate `生产领料出库单` after raw material production outbound creates or updates a production ledger. + - Generate `生产入库结算单` after branch production inbound creates a main inventory transaction. + - Return archive status fields in existing response models. +- Test: `backend/tests/test_production_warehouse_document_archive.py` + - New focused backend tests for production warehouse archive collection and generation. +- Modify: `backend/tests/test_document_archive_routes.py` + - Add route key normalization assertions for production warehouse document types. + +### Frontend + +- Modify: `frontend/src/views/ProductionLedgerView.vue` + - Import and render `DocumentArchiveActions`. + - Show production material issue archive actions in the production ledger detail header. + - Show production inbound settlement archive actions for production ledger transaction rows that came from inventory transactions. + - Add preview/download/regenerate handlers using the two new route keys. +- Modify: `frontend/src/views/InventoryLedgerView.vue` + - Update production ledger inbound success feedback to include archive status. + - Update generic production inbound/outbound success feedback to include archive status where the backend returns it. + - Add archive action helpers only if needed by the visible warehouse flow; do not duplicate `DocumentArchiveActions` if production ledger already covers the detail action. +- Test: `frontend/src/views/ProductionLedgerView.test.js` + - New static tests for archive actions and route keys. +- Modify: `frontend/src/views/InventoryLedgerView.test.js` if present; otherwise no new inventory static test is required. + +--- + +### Task 1: Backend Tests For Production Archive Types And Contexts + +**Files:** +- Create: `backend/tests/test_production_warehouse_document_archive.py` +- Modify: `backend/tests/test_document_archive_routes.py` + +- [ ] **Step 1: Create the focused backend test file** + +Create `backend/tests/test_production_warehouse_document_archive.py` with this complete structure. The seed intentionally uses only the tables needed by document archive context collection. + +```python +from __future__ import annotations + +import unittest +from datetime import datetime +from decimal import Decimal +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import Session, sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_big_integer_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.document_archive # noqa: E402,F401 +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.document_archive import DocumentArchive # noqa: E402 +from app.models.master_data import Item, Warehouse # noqa: E402 +from app.models.operations import InventoryTxn, ProductionBatchLedger, ProductionBatchLedgerTxn, StockLot # noqa: E402 +from app.services.document_archives import ( # noqa: E402 + ARCHIVE_STATUS_READY, + DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, + collect_production_inbound_settlement_archive_context, + collect_production_material_out_archive_context, + generate_document_archive, +) + + +class ProductionWarehouseDocumentArchiveTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db: Session = self.SessionLocal() + self.now = datetime(2026, 6, 11, 10, 30, 0) + self._seed_data() + + def tearDown(self) -> None: + self.db.close() + + def _seed_data(self) -> None: + self.raw_warehouse = Warehouse( + id=1, + warehouse_code="WH-RAW", + warehouse_name="原材料库", + warehouse_type="RAW", + status="ACTIVE", + created_at=self.now, + updated_at=self.now, + ) + self.finished_warehouse = Warehouse( + id=2, + warehouse_code="WH-FIN", + warehouse_name="成品库", + warehouse_type="FINISHED", + status="ACTIVE", + created_at=self.now, + updated_at=self.now, + ) + self.scrap_warehouse = Warehouse( + id=3, + warehouse_code="WH-SCRAP", + warehouse_name="废料库", + warehouse_type="SCRAP", + status="ACTIVE", + created_at=self.now, + updated_at=self.now, + ) + self.material = Item( + id=10, + item_code="RM-00010", + item_name="测试冷轧板", + item_type="RAW_MATERIAL", + specification="1.2mm", + material_grade="SPCC", + unit_weight_kg=Decimal("0"), + status="ACTIVE", + created_at=self.now, + updated_at=self.now, + ) + self.product = Item( + id=20, + item_code="FG-00020", + item_name="测试钢制碗", + item_type="FINISHED", + specification="φ180", + material_grade=None, + unit_weight_kg=Decimal("0.5"), + status="ACTIVE", + created_at=self.now, + updated_at=self.now, + ) + self.material_lot = StockLot( + id=100, + lot_no="YL0001", + parent_lot_id=None, + lot_role="INBOUND_RAW", + material_sub_batch_no=None, + item_id=self.material.id, + warehouse_id=self.raw_warehouse.id, + location_id=None, + source_doc_type="PURCHASE_RECEIPT", + source_doc_id=900, + source_line_id=901, + source_material_lot_id=None, + source_material_sub_batch_no=None, + source_material_summary=None, + inbound_qty=0, + inbound_weight_kg=500, + remaining_qty=0, + remaining_weight_kg=300, + locked_qty=0, + locked_weight_kg=0, + unit_cost=Decimal("6.6"), + production_date=None, + expire_date=None, + quality_status="PASS", + status="AVAILABLE", + remark="测试材料批次", + created_at=self.now, + updated_at=self.now, + ) + self.ledger = ProductionBatchLedger( + id=200, + material_lot_id=self.material_lot.id, + material_lot_no=self.material_lot.lot_no, + material_item_id=self.material.id, + product_item_id=self.product.id, + status="在生产", + miniapp_selectable=1, + total_issued_weight_kg=Decimal("200"), + outside_weight_kg=Decimal("80"), + finished_inbound_qty=Decimal("198"), + surplus_inbound_weight_kg=Decimal("60"), + scrap_inbound_weight_kg=Decimal("2"), + writeoff_weight_kg=Decimal("0"), + first_issue_time=self.now, + last_issue_time=self.now, + locked_at=None, + reopened_at=None, + lock_count=0, + remark="生产台账测试", + created_at=self.now, + updated_at=self.now, + ) + self.issue_ledger_txn = ProductionBatchLedgerTxn( + id=300, + production_ledger_id=self.ledger.id, + txn_type="生产出库", + qty_delta=0, + weight_delta_kg=Decimal("200"), + outside_weight_after_kg=Decimal("200"), + source_doc_type="库存流水", + source_doc_id=400, + source_line_id=self.material_lot.id, + biz_time=self.now, + operator_user_id=1, + remark="原材料库生产出库形成生产台账", + created_at=self.now, + updated_at=self.now, + ) + self.issue_inventory_txn = InventoryTxn( + id=400, + txn_no="TXN-ISSUE-001", + txn_type="PRODUCTION_OUT", + item_id=self.material.id, + warehouse_id=self.raw_warehouse.id, + location_id=None, + lot_id=self.material_lot.id, + qty_change=0, + weight_change_kg=Decimal("-200"), + unit_cost=Decimal("6.6"), + amount=Decimal("1320"), + source_doc_type="生产台账", + source_doc_id=self.ledger.id, + source_line_id=self.material_lot.id, + biz_time=self.now, + operator_user_id=1, + remark="生产出库", + created_at=self.now, + updated_at=self.now, + ) + self.finished_lot = StockLot( + id=101, + lot_no="FGL20260611001", + parent_lot_id=self.material_lot.id, + lot_role="FINISHED_FROM_RAW_BATCH", + material_sub_batch_no=None, + item_id=self.product.id, + warehouse_id=self.finished_warehouse.id, + location_id=None, + source_doc_type="PRODUCTION_LEDGER_IN", + source_doc_id=self.ledger.id, + source_line_id=None, + source_material_lot_id=self.material_lot.id, + source_material_sub_batch_no=self.material_lot.lot_no, + source_material_summary=f"{self.material.item_name}:{self.material_lot.lot_no}", + inbound_qty=198, + inbound_weight_kg=99, + remaining_qty=198, + remaining_weight_kg=99, + locked_qty=0, + locked_weight_kg=0, + unit_cost=Decimal("3.3"), + production_date=self.now.date(), + expire_date=None, + quality_status="PASS", + status="AVAILABLE", + remark="生产台账入库;成品入库;来源库存批次已记录", + created_at=self.now, + updated_at=self.now, + ) + self.finished_txn = InventoryTxn( + id=401, + txn_no="TXN-FG-001", + txn_type="FG_IN", + item_id=self.product.id, + warehouse_id=self.finished_warehouse.id, + location_id=None, + lot_id=self.finished_lot.id, + qty_change=198, + weight_change_kg=99, + unit_cost=Decimal("3.3"), + amount=Decimal("653.4"), + source_doc_type="生产台账", + source_doc_id=self.ledger.id, + source_line_id=self.finished_lot.id, + biz_time=self.now, + operator_user_id=1, + remark="生产台账入库;成品入库;偏差说明:称重复核通过", + created_at=self.now, + updated_at=self.now, + ) + self.surplus_txn = InventoryTxn( + id=402, + txn_no="TXN-SURPLUS-001", + txn_type="PRODUCTION_SURPLUS_IN", + item_id=self.material.id, + warehouse_id=self.raw_warehouse.id, + location_id=None, + lot_id=self.material_lot.id, + qty_change=0, + weight_change_kg=60, + unit_cost=Decimal("6.6"), + amount=Decimal("396"), + source_doc_type="生产台账", + source_doc_id=self.ledger.id, + source_line_id=self.material_lot.id, + biz_time=self.now, + operator_user_id=1, + remark="生产台账入库;生产余料入库;偏差说明:称重复核通过", + created_at=self.now, + updated_at=self.now, + ) + self.db.add_all( + [ + self.raw_warehouse, + self.finished_warehouse, + self.scrap_warehouse, + self.material, + self.product, + self.material_lot, + self.ledger, + self.issue_ledger_txn, + self.issue_inventory_txn, + self.finished_lot, + self.finished_txn, + self.surplus_txn, + ] + ) + self.db.commit() + + def test_collect_material_out_context_uses_production_ledger(self) -> None: + context = collect_production_material_out_archive_context(self.db, self.ledger.id) + + self.assertEqual(context.document_type, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT) + self.assertEqual(context.business_id, self.ledger.id) + self.assertEqual(context.document_no, "生产领料-YL0001-测试钢制碗") + self.assertEqual(context.partner_label, "生产产品") + self.assertEqual(context.partner_name, "测试钢制碗") + self.assertEqual(context.address, "原材料库") + self.assertIn("材料库存批次号:YL0001", context.remark) + self.assertEqual(len(context.lines), 1) + self.assertEqual(context.lines[0].item_name, "测试冷轧板") + self.assertEqual(Decimal(str(context.lines[0].quantity)), Decimal("200")) + self.assertEqual(Decimal(str(context.lines[0].line_amount)), Decimal("1320.00")) + + def test_collect_inbound_settlement_context_groups_same_save_action(self) -> None: + context = collect_production_inbound_settlement_archive_context(self.db, self.finished_txn.id) + + self.assertEqual(context.document_type, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT) + self.assertEqual(context.business_id, self.finished_txn.id) + self.assertEqual(context.document_no, "生产入库结算-TXN-FG-001") + self.assertEqual(context.partner_label, "生产产品") + self.assertEqual(context.partner_name, "测试钢制碗") + self.assertIn("材料库存批次号:YL0001", context.remark) + self.assertEqual(len(context.lines), 2) + self.assertEqual([line.remark.split(";")[0] for line in context.lines], ["入库类型:成品入库", "入库类型:生产余料入库"]) + + def test_generate_production_material_out_archive_pdf(self) -> None: + with TemporaryDirectory() as tmp_dir, patch("app.services.document_archives.default_archive_root", return_value=tmp_dir): + result = generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, self.ledger.id, created_by=1) + + self.assertEqual(result.status, ARCHIVE_STATUS_READY) + saved = self.db.scalar(select(DocumentArchive).where(DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT)) + self.assertIsNotNone(saved) + self.assertEqual(saved.business_id, self.ledger.id) + self.assertEqual(saved.archive_version, 1) + + def test_generate_production_inbound_settlement_archive_pdf(self) -> None: + with TemporaryDirectory() as tmp_dir, patch("app.services.document_archives.default_archive_root", return_value=tmp_dir): + result = generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, self.finished_txn.id, created_by=1) + + self.assertEqual(result.status, ARCHIVE_STATUS_READY) + saved = self.db.scalar(select(DocumentArchive).where(DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT)) + self.assertIsNotNone(saved) + self.assertEqual(saved.business_id, self.finished_txn.id) + self.assertEqual(saved.archive_version, 1) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the new test file and verify it fails for missing symbols** + +Run: + +```bash +PYTHONPATH=backend backend/.venv/bin/pytest backend/tests/test_production_warehouse_document_archive.py -q +``` + +Expected: FAIL with import errors for `DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT`, `DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT`, `collect_production_inbound_settlement_archive_context`, and `collect_production_material_out_archive_context`. + +- [ ] **Step 3: Extend document archive route key tests** + +Modify `backend/tests/test_document_archive_routes.py::DocumentArchiveRouteTest.test_normalize_document_type_key_supports_route_keys` to include: + +```python +self.assertEqual(normalize_document_type_key("production-material-out"), "生产领料出库单") +self.assertEqual(normalize_document_type_key("production-inbound-settlement"), "生产入库结算单") +``` + +- [ ] **Step 4: Run route tests and verify they fail** + +Run: + +```bash +PYTHONPATH=backend backend/.venv/bin/pytest backend/tests/test_document_archive_routes.py::DocumentArchiveRouteTest::test_normalize_document_type_key_supports_route_keys -q +``` + +Expected: FAIL because the new document type keys are not mapped yet. + +- [ ] **Step 5: Commit tests** + +Run: + +```bash +git add backend/tests/test_production_warehouse_document_archive.py backend/tests/test_document_archive_routes.py +git commit -m "test: cover production warehouse document archives" +``` + +Expected: commit succeeds in a git worktree. If the workspace reports `fatal: not a git repository`, record that commit was skipped because this project copy has no `.git` directory. + +--- + +### Task 2: Add Production Document Archive Contexts And Route Keys + +**Files:** +- Modify: `backend/app/services/document_archives.py` +- Modify: `backend/app/api/routes/document_archives.py` +- Test: `backend/tests/test_production_warehouse_document_archive.py` +- Test: `backend/tests/test_document_archive_routes.py` + +- [ ] **Step 1: Add imports and constants in `document_archives.py`** + +Modify imports: + +```python +from app.models.operations import ( + InventoryTxn, + ProductionBatchLedger, + ProductionBatchLedgerTxn, + PurchaseOrder, + PurchaseOrderItem, + PurchaseReceipt, + PurchaseReceiptItem, + StockLot, + Supplier, +) +``` + +Add constants below `DOCUMENT_TYPE_QUALITY_INSPECTION`: + +```python +DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT = "生产领料出库单" +DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT = "生产入库结算单" +``` + +- [ ] **Step 2: Add production helper functions** + +Add these helper functions below `collect_quality_inspection_archive_context`. + +```python +PRODUCTION_INBOUND_TXN_LABELS = { + "FG_IN": "成品入库", + "PRODUCTION_SURPLUS_IN": "生产余料入库", + "PRODUCTION_SCRAP_IN": "生产废料入库", +} + + +def _production_document_no_part(value: str | None) -> str: + text = str(value or "").strip() + return text or "-" + + +def _production_amount(quantity_or_weight: Decimal | float | int | None, unit_cost: Decimal | float | int | None) -> Decimal: + return Decimal(str(quantity_or_weight or 0)) * Decimal(str(unit_cost or 0)) + + +def _production_ledger_row(db: Session, production_ledger_id: int): + material_item = aliased(Item) + product_item = aliased(Item) + row = db.execute( + select( + ProductionBatchLedger, + StockLot, + material_item, + product_item, + Warehouse, + ) + .join(StockLot, StockLot.id == ProductionBatchLedger.material_lot_id) + .join(material_item, material_item.id == ProductionBatchLedger.material_item_id) + .join(product_item, product_item.id == ProductionBatchLedger.product_item_id) + .join(Warehouse, Warehouse.id == StockLot.warehouse_id) + .where(ProductionBatchLedger.id == production_ledger_id) + ).one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="生产台账不存在") + return row +``` + +Also add `aliased` to the SQLAlchemy import line: + +```python +from sqlalchemy.orm import Session, aliased +``` + +- [ ] **Step 3: Add `collect_production_material_out_archive_context`** + +Add: + +```python +def collect_production_material_out_archive_context(db: Session, production_ledger_id: int) -> ArchiveContext: + ledger, material_lot, material, product, warehouse = _production_ledger_row(db, production_ledger_id) + issue_txns = db.scalars( + select(ProductionBatchLedgerTxn) + .where( + ProductionBatchLedgerTxn.production_ledger_id == production_ledger_id, + ProductionBatchLedgerTxn.txn_type == "生产出库", + ) + .order_by(ProductionBatchLedgerTxn.biz_time.asc(), ProductionBatchLedgerTxn.id.asc()) + ).all() + issued_weight = Decimal(str(ledger.total_issued_weight_kg or 0)) + unit_cost = Decimal(str(material_lot.unit_cost or 0)) + amount = issued_weight * unit_cost + latest_issue_time = ledger.last_issue_time or ledger.updated_at + issue_count = len(issue_txns) + line_remark_parts = [ + f"库存批次号:{ledger.material_lot_no}", + f"累计生产出库次数:{issue_count}", + f"库外未闭环重量:{decimal_text(ledger.outside_weight_kg, 3)}kg", + ] + if ledger.remark: + line_remark_parts.append(str(ledger.remark)) + + return ArchiveContext( + document_type=DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, + business_id=ledger.id, + document_no=f"生产领料-{_production_document_no_part(ledger.material_lot_no)}-{_production_document_no_part(product.item_name)}", + title="生产领料出库单归档", + partner_label="生产产品", + partner_name=product.item_name, + document_date=latest_issue_time, + due_date_label="生产台账", + due_date=ledger.id, + status=archive_status_text(ledger.status), + tax_rate=Decimal("0"), + total_amount=amount, + contact_name=None, + contact_phone=None, + address=warehouse.warehouse_name, + remark=f"生产台账号:{ledger.id};材料库存批次号:{ledger.material_lot_no};产品编码:{product.item_code}", + lines=[ + ArchiveLine( + line_no=1, + item_code=material.item_code, + item_name=material.item_name, + specification=material.specification, + quantity=issued_weight, + delivered_or_received_quantity=ledger.outside_weight_kg, + unit_price=unit_cost, + line_amount=amount, + promised_or_expected_date=latest_issue_time, + remark=";".join(line_remark_parts), + ) + ], + ) +``` + +- [ ] **Step 4: Add settlement grouping and `collect_production_inbound_settlement_archive_context`** + +Add: + +```python +def _production_inbound_related_txns(db: Session, main_txn: InventoryTxn) -> list[InventoryTxn]: + source_doc_type = str(main_txn.source_doc_type or "") + source_doc_id = int(main_txn.source_doc_id or 0) + if source_doc_id <= 0: + return [main_txn] + rows = db.scalars( + select(InventoryTxn) + .where( + InventoryTxn.source_doc_type == source_doc_type, + InventoryTxn.source_doc_id == source_doc_id, + InventoryTxn.biz_time == main_txn.biz_time, + InventoryTxn.txn_type.in_(tuple(PRODUCTION_INBOUND_TXN_LABELS.keys())), + ) + .order_by( + case( + (InventoryTxn.txn_type == "FG_IN", 1), + (InventoryTxn.txn_type == "PRODUCTION_SURPLUS_IN", 2), + (InventoryTxn.txn_type == "PRODUCTION_SCRAP_IN", 3), + else_=9, + ), + InventoryTxn.id.asc(), + ) + ).all() + return rows or [main_txn] + + +def _production_ledger_from_inbound_txn(db: Session, txn: InventoryTxn) -> ProductionBatchLedger | None: + if str(txn.source_doc_type or "") == "生产台账" and txn.source_doc_id: + return db.get(ProductionBatchLedger, int(txn.source_doc_id)) + linked = db.scalar( + select(ProductionBatchLedger) + .join(StockLot, StockLot.id == ProductionBatchLedger.material_lot_id) + .where( + or_( + StockLot.id == txn.lot_id, + StockLot.id == db.scalar(select(StockLot.source_material_lot_id).where(StockLot.id == txn.lot_id)), + ) + ) + .order_by(ProductionBatchLedger.updated_at.desc(), ProductionBatchLedger.id.desc()) + .limit(1) + ) + return linked + + +def collect_production_inbound_settlement_archive_context(db: Session, inventory_txn_id: int) -> ArchiveContext: + main_txn = db.get(InventoryTxn, inventory_txn_id) + if main_txn is None: + raise HTTPException(status_code=404, detail="生产入库库存流水不存在") + if main_txn.txn_type not in PRODUCTION_INBOUND_TXN_LABELS: + raise HTTPException(status_code=400, detail="该库存流水不是生产入库结算流水") + + txns = _production_inbound_related_txns(db, main_txn) + ledger = _production_ledger_from_inbound_txn(db, main_txn) + if ledger: + ledger_row = _production_ledger_row(db, ledger.id) + ledger, material_lot, material, product, _warehouse = ledger_row + partner_name = product.item_name + remark_prefix = f"生产台账号:{ledger.id};材料库存批次号:{ledger.material_lot_no};产品编码:{product.item_code}" + status_text = archive_status_text(ledger.status) + else: + material_lot = db.get(StockLot, main_txn.lot_id) if main_txn.lot_id else None + item = db.get(Item, main_txn.item_id) + material = item + product = item + partner_name = item.item_name if item else "生产入库" + remark_prefix = f"来源:{main_txn.source_doc_type} #{main_txn.source_doc_id}" + status_text = "已入库" + + lines: list[ArchiveLine] = [] + total_amount = Decimal("0") + for index, txn in enumerate(txns, start=1): + item = db.get(Item, txn.item_id) + lot = db.get(StockLot, txn.lot_id) if txn.lot_id else None + warehouse = db.get(Warehouse, txn.warehouse_id) + txn_label = PRODUCTION_INBOUND_TXN_LABELS.get(txn.txn_type, archive_status_text(txn.txn_type)) + qty_or_weight = txn.qty_change if txn.txn_type == "FG_IN" else txn.weight_change_kg + amount = Decimal(str(txn.amount or 0)) + total_amount += amount + remark_parts = [ + f"入库类型:{txn_label}", + f"仓库:{warehouse.warehouse_name if warehouse else '-'}", + f"库存批次号:{lot.lot_no if lot else '-'}", + ] + if lot and lot.source_material_lot_id: + source_lot = db.get(StockLot, lot.source_material_lot_id) + if source_lot: + remark_parts.append(f"原材料库存批次号:{source_lot.lot_no}") + elif ledger: + remark_parts.append(f"原材料库存批次号:{ledger.material_lot_no}") + if txn.remark: + remark_parts.append(str(txn.remark)) + lines.append( + ArchiveLine( + line_no=index, + item_code=item.item_code if item else "-", + item_name=item.item_name if item else "-", + specification=item.specification if item else None, + quantity=qty_or_weight, + delivered_or_received_quantity=txn.weight_change_kg, + unit_price=txn.unit_cost, + line_amount=amount, + promised_or_expected_date=txn.biz_time, + remark=";".join(remark_parts), + ) + ) + + return ArchiveContext( + document_type=DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + business_id=main_txn.id, + document_no=f"生产入库结算-{main_txn.txn_no}", + title="生产入库结算单归档", + partner_label="生产产品", + partner_name=partner_name, + document_date=main_txn.biz_time, + due_date_label="主库存流水", + due_date=main_txn.txn_no, + status=status_text, + tax_rate=Decimal("0"), + total_amount=total_amount, + contact_name=None, + contact_phone=None, + address=material_lot.lot_no if material_lot else "", + remark=remark_prefix, + lines=lines, + ) +``` + +Also add `case` and `or_` to the SQLAlchemy import line: + +```python +from sqlalchemy import case, func, or_, select +``` + +- [ ] **Step 5: Register new collectors** + +Modify `collect_archive_context`: + +```python +if document_type == DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT: + return collect_production_material_out_archive_context(db, business_id) +if document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT: + return collect_production_inbound_settlement_archive_context(db, business_id) +``` + +- [ ] **Step 6: Add document route keys** + +Modify `backend/app/api/routes/document_archives.py` imports: + +```python +DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, +DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, +``` + +Add to `DOCUMENT_TYPE_KEY_MAP`: + +```python +"production-material-out": DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, +"production-inbound-settlement": DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, +DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT: DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, +DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT: DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, +``` + +Update the validation error: + +```python +raise HTTPException(status_code=400, detail="单据类型只能选择销售订单、采购订单、到货入库单、质量校验单、生产领料出库单或生产入库结算单") +``` + +- [ ] **Step 7: Run archive tests** + +Run: + +```bash +PYTHONPATH=backend backend/.venv/bin/pytest backend/tests/test_production_warehouse_document_archive.py backend/tests/test_document_archive_routes.py::DocumentArchiveRouteTest::test_normalize_document_type_key_supports_route_keys -q +``` + +Expected: PASS. + +- [ ] **Step 8: Run existing document archive tests** + +Run: + +```bash +PYTHONPATH=backend backend/.venv/bin/pytest backend/tests/test_document_archive_service.py backend/tests/test_quality_inspection_document_archive.py backend/tests/test_document_archive_routes.py -q +``` + +Expected: PASS. This confirms sales, purchase, receipt, and quality archive paths still work. + +- [ ] **Step 9: Commit backend archive service changes** + +Run: + +```bash +git add backend/app/services/document_archives.py backend/app/api/routes/document_archives.py backend/tests/test_production_warehouse_document_archive.py backend/tests/test_document_archive_routes.py +git commit -m "feat: add production warehouse document archive types" +``` + +Expected: commit succeeds in a git worktree. If no `.git` exists, record the skipped commit. + +--- + +### Task 3: Generate Archives From Production Save Points + +**Files:** +- Modify: `backend/app/schemas/operations.py` +- Modify: `backend/app/api/routes/production.py` +- Modify: `backend/app/services/production_work_order_inbound.py` +- Modify: `backend/app/api/routes/inventory.py` +- Test: `backend/tests/test_production_warehouse_document_archive.py` + +- [ ] **Step 1: Add archive fields to response schemas** + +In `backend/app/schemas/operations.py`, add these fields to `StockLotRead`, `ProductionWorkOrderInboundRead`, and `ProductionBatchLedgerInboundRead`: + +```python +archive_status: str | None = None +archive_business_id: int | None = None +archive_document_type: str | None = None +archive_error_message: str | None = None +``` + +Add these fields to `ProductionBatchLedgerTxnRead`: + +```python +archive_status: str | None = None +archive_business_id: int | None = None +archive_document_type: str | None = None +archive_error_message: str | None = None +``` + +Add these fields to `ProductionBatchLedgerRead`: + +```python +material_out_archive_status: str | None = None +material_out_archive_business_id: int | None = None +material_out_archive_document_type: str | None = None +material_out_archive_error_message: str | None = None +``` + +- [ ] **Step 2: Add a small archive-result helper in `production.py`** + +In `backend/app/api/routes/production.py`, import: + +```python +from app.schemas.document_archives import DocumentArchiveGenerateResult +from app.services.document_archives import ( + DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + generate_document_archive, +) +``` + +Add helper near route helpers: + +```python +def _archive_fields_from_result(result: DocumentArchiveGenerateResult | None, *, document_type: str, business_id: int | None) -> dict[str, object]: + if result is None: + return { + "archive_status": "未生成", + "archive_business_id": business_id, + "archive_document_type": document_type, + "archive_error_message": None, + } + return { + "archive_status": result.status, + "archive_business_id": business_id, + "archive_document_type": document_type, + "archive_error_message": result.error_message, + } +``` + +- [ ] **Step 3: Generate settlement archive after `/production/production-ledger-inbounds`** + +In `create_production_batch_ledger_inbound`, after `db.commit()` and before returning, determine the main transaction ID: + +```python +main_txn_id = finished_txn_id or surplus_txn_id or scrap_txn_id +archive_result = None +if main_txn_id: + try: + archive_result = generate_document_archive( + db, + DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + int(main_txn_id), + created_by=context.user.id, + ) + except Exception as exc: + archive_result = DocumentArchiveGenerateResult( + document_type=DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + business_id=int(main_txn_id), + document_no=f"库存流水#{main_txn_id}", + archive_version=0, + status="归档失败", + file_name="", + file_path="", + error_message=str(exc), + ) +``` + +In the returned `ProductionBatchLedgerInboundRead`, include: + +```python +**_archive_fields_from_result( + archive_result, + document_type=DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + business_id=int(main_txn_id) if main_txn_id else None, +) +``` + +To keep the current constructor style easy to review, replace the direct constructor return for `ProductionBatchLedgerInboundRead` with this explicit response object: + +```python +response = ProductionBatchLedgerInboundRead( + production_ledger_id=ledger.id, + material_lot_no=ledger.material_lot_no, + finished_qty=float(finished_qty), + surplus_weight_kg=float(surplus_weight), + scrap_weight_kg=float(scrap_weight), + finished_lot_id=finished_lot_id, + surplus_lot_id=surplus_lot_id, + scrap_lot_id=scrap_lot_id, + finished_txn_id=finished_txn_id, + surplus_txn_id=surplus_txn_id, + scrap_txn_id=scrap_txn_id, + locked=bool(payload.lock_material_batch), + status=ledger.status, + remark=payload.remark, +) +for key, value in _archive_fields_from_result( + archive_result, + document_type=DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + business_id=int(main_txn_id) if main_txn_id else None, +).items(): + setattr(response, key, value) +return response +``` + +- [ ] **Step 4: Generate settlement archive after old `/production/work-order-inbounds`** + +In `backend/app/services/production_work_order_inbound.py`, import: + +```python +from app.schemas.document_archives import DocumentArchiveGenerateResult +from app.services.document_archives import DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, generate_document_archive +``` + +After `db.commit()`, before returning, add: + +```python +main_txn_id = finished_txn_id or surplus_txn_id or scrap_txn_id +archive_status = "未生成" +archive_error_message = None +if main_txn_id: + try: + archive_result = generate_document_archive( + db, + DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + int(main_txn_id), + created_by=operator_user_id, + ) + archive_status = archive_result.status + archive_error_message = archive_result.error_message + except Exception as exc: + archive_status = "归档失败" + archive_error_message = str(exc) +``` + +Include these fields in `ProductionWorkOrderInboundRead`: + +```python +archive_status=archive_status, +archive_business_id=int(main_txn_id) if main_txn_id else None, +archive_document_type=DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT if main_txn_id else None, +archive_error_message=archive_error_message, +``` + +- [ ] **Step 5: Generate material issue archive after raw material production outbound** + +In `backend/app/api/routes/inventory.py`, import: + +```python +from app.schemas.document_archives import DocumentArchiveGenerateResult +from app.services.document_archives import ( + DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, + DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, + generate_document_archive, +) +``` + +In `create_warehouse_outbound`, track production ledger ID for production outbound. After the branch that posts production issue to ledger, store: + +```python +production_material_out_archive_ledger_id = None +``` + +When `biz_type == "PRODUCTION_OUT"` creates or updates a `ProductionBatchLedger`, set: + +```python +production_material_out_archive_ledger_id = ledger.id +``` + +After the existing `db.commit()`, before returning `StockLotRead`, generate: + +```python +archive_status = None +archive_business_id = None +archive_document_type = None +archive_error_message = None +if production_material_out_archive_ledger_id: + archive_business_id = int(production_material_out_archive_ledger_id) + archive_document_type = DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT + try: + archive_result = generate_document_archive( + db, + DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, + archive_business_id, + created_by=context.user.id, + ) + archive_status = archive_result.status + archive_error_message = archive_result.error_message + except Exception as exc: + archive_status = "归档失败" + archive_error_message = str(exc) +``` + +When returning the `StockLotRead`, assign: + +```python +response = StockLotRead.model_validate(dict(row)) +response.archive_status = archive_status +response.archive_business_id = archive_business_id +response.archive_document_type = archive_document_type +response.archive_error_message = archive_error_message +return response +``` + +- [ ] **Step 6: Generate settlement archive after branch production inbound** + +In `create_warehouse_inbound`, track the main inventory transaction ID for these `biz_type` values: + +```python +PRODUCTION_ARCHIVE_INBOUND_BIZ_TYPES = {"PRODUCTION_FINISHED", "PRODUCTION_SURPLUS", "PRODUCTION_SCRAP_IN"} +``` + +For the source-lot return branch, after the existing assignment to `return_txn` from `create_inventory_txn`, set: + +```python +production_inbound_archive_main_txn_id = return_txn.id if biz_type in PRODUCTION_ARCHIVE_INBOUND_BIZ_TYPES else None +``` + +For the non-return branches, capture the `InventoryTxn` created for finished production inbound and production scrap inbound. Use the same priority rule: + +```python +production_inbound_archive_main_txn_id = finished_txn.id or surplus_txn.id or scrap_txn.id +``` + +After `db.commit()`, generate `DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT` with `production_inbound_archive_main_txn_id`, catch exceptions, and set the same four archive fields on `StockLotRead`. + +- [ ] **Step 7: Add integration tests for archive generation not rolling back business data** + +Append to `backend/tests/test_production_warehouse_document_archive.py`: + +```python +def test_archive_generation_failure_keeps_existing_business_rows(self) -> None: + with patch("app.services.document_archives.render_pdf_archive", side_effect=RuntimeError("PDF服务异常")): + result = generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, self.finished_txn.id, created_by=1) + + self.assertEqual(result.status, "归档失败") + self.assertIsNotNone(self.db.get(InventoryTxn, self.finished_txn.id)) + saved = self.db.scalar(select(DocumentArchive).where(DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT)) + self.assertIsNotNone(saved) + self.assertEqual(saved.status, "归档失败") +``` + +- [ ] **Step 8: Run focused backend tests** + +Run: + +```bash +PYTHONPATH=backend backend/.venv/bin/pytest backend/tests/test_production_warehouse_document_archive.py backend/tests/test_production_batch_ledger.py backend/tests/test_production_work_order_inbound.py -q +``` + +Expected: PASS. + +- [ ] **Step 9: Run production inventory regression tests** + +Run: + +```bash +PYTHONPATH=backend backend/.venv/bin/pytest backend/tests/test_selected_stock_lot_production_issue.py backend/tests/test_production_work_order_ledger.py backend/tests/test_scrap_warehouse_flow.py -q +``` + +Expected: PASS. + +- [ ] **Step 10: Commit production save-point archive generation** + +Run: + +```bash +git add backend/app/schemas/operations.py backend/app/api/routes/production.py backend/app/services/production_work_order_inbound.py backend/app/api/routes/inventory.py backend/tests/test_production_warehouse_document_archive.py +git commit -m "feat: archive production warehouse save points" +``` + +Expected: commit succeeds in a git worktree. If no `.git` exists, record the skipped commit. + +--- + +### Task 4: Expose Archive Status In Production Ledger API + +**Files:** +- Modify: `backend/app/api/routes/production.py` +- Test: `backend/tests/test_production_warehouse_document_archive.py` + +- [ ] **Step 1: Import archive model and status constants** + +In `backend/app/api/routes/production.py`, import: + +```python +from app.models.document_archive import DocumentArchive +from app.services.document_archives import ARCHIVE_STATUS_MISSING, FILE_FORMAT_PDF +``` + +Keep the production document type imports from Task 3. + +- [ ] **Step 2: Add helper to get latest archive rows by business IDs** + +Add near route helpers: + +```python +def _latest_archive_map(db: Session, document_type: str, business_ids: list[int]) -> dict[int, DocumentArchive]: + if not business_ids: + return {} + latest_version_subquery = ( + select( + DocumentArchive.business_id.label("business_id"), + func.max(DocumentArchive.archive_version).label("archive_version"), + ) + .where( + DocumentArchive.document_type == document_type, + DocumentArchive.file_format == FILE_FORMAT_PDF, + DocumentArchive.business_id.in_(business_ids), + ) + .group_by(DocumentArchive.business_id) + .subquery() + ) + latest_id_subquery = ( + select( + DocumentArchive.business_id.label("business_id"), + func.max(DocumentArchive.id).label("archive_id"), + ) + .join( + latest_version_subquery, + (latest_version_subquery.c.business_id == DocumentArchive.business_id) + & (latest_version_subquery.c.archive_version == DocumentArchive.archive_version), + ) + .where( + DocumentArchive.document_type == document_type, + DocumentArchive.file_format == FILE_FORMAT_PDF, + ) + .group_by(DocumentArchive.business_id) + .subquery() + ) + rows = db.scalars( + select(DocumentArchive).join(latest_id_subquery, latest_id_subquery.c.archive_id == DocumentArchive.id) + ).all() + return {int(row.business_id): row for row in rows} +``` + +- [ ] **Step 3: Populate production ledger archive fields** + +Inside `list_production_batch_ledger`, after `ledger_ids` is built: + +```python +material_out_archives = _latest_archive_map(db, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, ledger_ids) +``` + +After `txn_rows` is loaded, collect settlement business IDs: + +```python +settlement_business_ids = [ + int(txn.source_doc_id) + for txn in txn_rows + if txn.source_doc_type == "库存流水" and txn.source_doc_id and txn.txn_type in {"成品入库", "生产余料入库", "生产废料入库"} +] +settlement_archives = _latest_archive_map(db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, settlement_business_ids) +``` + +If current `ProductionBatchLedgerTxn.txn_type` values for inbound rows are exactly `成品入库`, `生产余料入库`, `生产废料入库`, keep the set above. If the code uses `post_finished_inbound_to_ledger`, `post_surplus_inbound_to_ledger`, and `post_scrap_inbound_to_ledger` with these names, do not change them. + +- [ ] **Step 4: Extend `_production_ledger_txn_read`** + +Modify `_production_ledger_txn_read(txn)` to compute archive fields: + +```python +def _production_ledger_txn_read(txn: ProductionBatchLedgerTxn, archive: DocumentArchive | None = None) -> dict: + archive_business_id = int(txn.source_doc_id) if txn.source_doc_type == "库存流水" and txn.source_doc_id else None + return { + "production_ledger_txn_id": txn.id, + "production_ledger_id": txn.production_ledger_id, + "txn_type": txn.txn_type, + "qty_delta": float(to_decimal(txn.qty_delta)), + "weight_delta_kg": float(to_decimal(txn.weight_delta_kg)), + "outside_weight_after_kg": float(to_decimal(txn.outside_weight_after_kg)), + "source_doc_type": txn.source_doc_type, + "source_doc_id": txn.source_doc_id, + "source_line_id": txn.source_line_id, + "biz_time": txn.biz_time, + "operator_user_id": txn.operator_user_id, + "remark": txn.remark, + "archive_status": archive.status if archive else (ARCHIVE_STATUS_MISSING if archive_business_id else None), + "archive_business_id": archive_business_id, + "archive_document_type": DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT if archive_business_id else None, + "archive_error_message": archive.error_message if archive else None, + } +``` + +Update all call sites to pass the archive: + +```python +txns=[ + _production_ledger_txn_read( + txn, + settlement_archives.get(int(txn.source_doc_id)) if txn.source_doc_type == "库存流水" and txn.source_doc_id else None, + ) + for txn in txns_by_ledger_id.get(ledger.id, []) +] +``` + +- [ ] **Step 5: Extend `ProductionBatchLedgerRead` construction** + +When appending `ProductionBatchLedgerRead`, get: + +```python +material_archive = material_out_archives.get(int(ledger.id)) +``` + +Add fields: + +```python +material_out_archive_status=material_archive.status if material_archive else ARCHIVE_STATUS_MISSING, +material_out_archive_business_id=ledger.id, +material_out_archive_document_type=DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, +material_out_archive_error_message=material_archive.error_message if material_archive else None, +``` + +- [ ] **Step 6: Add a production ledger API test** + +Add to `backend/tests/test_production_warehouse_document_archive.py` a direct helper-level assertion if full FastAPI setup is too heavy: + +```python +def test_latest_archive_rows_can_be_joined_by_business_id(self) -> None: + with TemporaryDirectory() as tmp_dir, patch("app.services.document_archives.default_archive_root", return_value=tmp_dir): + generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, self.ledger.id, created_by=1) + generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, self.finished_txn.id, created_by=1) + + material_archive = self.db.scalar(select(DocumentArchive).where(DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT)) + settlement_archive = self.db.scalar(select(DocumentArchive).where(DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT)) + self.assertEqual(material_archive.business_id, self.ledger.id) + self.assertEqual(settlement_archive.business_id, self.finished_txn.id) +``` + +The execution agent should also run the full production API tests after implementation. + +- [ ] **Step 7: Run production API and archive tests** + +Run: + +```bash +PYTHONPATH=backend backend/.venv/bin/pytest backend/tests/test_production_warehouse_document_archive.py backend/tests/test_production_batch_ledger.py -q +``` + +Expected: PASS. + +- [ ] **Step 8: Commit production ledger archive status API** + +Run: + +```bash +git add backend/app/api/routes/production.py backend/app/schemas/operations.py backend/tests/test_production_warehouse_document_archive.py +git commit -m "feat: expose production archive status" +``` + +Expected: commit succeeds in a git worktree. If no `.git` exists, record the skipped commit. + +--- + +### Task 5: Frontend Archive Actions For Production Ledger And Warehouse Feedback + +**Files:** +- Modify: `frontend/src/views/ProductionLedgerView.vue` +- Modify: `frontend/src/views/InventoryLedgerView.vue` +- Create: `frontend/src/views/ProductionLedgerView.test.js` + +- [ ] **Step 1: Add static frontend test for production ledger archive UI** + +Create `frontend/src/views/ProductionLedgerView.test.js`: + +```javascript +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const source = readFileSync(join(__dirname, "ProductionLedgerView.vue"), "utf8"); + +describe("ProductionLedgerView archive actions", () => { + it("uses DocumentArchiveActions for material out and inbound settlement archives", () => { + assert.match(source, /DocumentArchiveActions/); + assert.match(source, /production-material-out/); + assert.match(source, /production-inbound-settlement/); + }); + + it("has preview, download and regenerate archive handlers", () => { + assert.match(source, /previewDocumentArchive/); + assert.match(source, /downloadDocumentArchive/); + assert.match(source, /regenerateDocumentArchive/); + }); +}); +``` + +- [ ] **Step 2: Run the frontend test and verify it fails** + +Run: + +```bash +node --test frontend/src/views/ProductionLedgerView.test.js +``` + +Expected: FAIL because the archive component and route keys are not yet used in `ProductionLedgerView.vue`. + +- [ ] **Step 3: Import archive component and API helpers** + +In `frontend/src/views/ProductionLedgerView.vue`, add: + +```javascript +import DocumentArchiveActions from "../components/documentForms/DocumentArchiveActions.vue"; +import { downloadResource, fetchResource, openResource, postResource } from "../services/api"; +``` + +Replace the current import: + +```javascript +import { fetchResource, postResource } from "../services/api"; +``` + +with the combined import above. + +- [ ] **Step 4: Render material issue archive actions in the detail header** + +Inside the first detail drawer, below the `drawer-row-detail-grid`, add: + +```vue +
+
+ 生产领料出库单 + {{ activeLedger.material_lot_no || "-" }} +
+ +
+``` + +- [ ] **Step 5: Render inbound settlement archive actions in transaction detail** + +Inside the `DrawerDataTable` `#detail` slot, after the existing `drawer-row-detail-grid`, add: + +```vue +
+
+ 生产入库结算单 + {{ row.txn_type || "-" }} +
+ +
+``` + +- [ ] **Step 6: Add handlers** + +In `ProductionLedgerView.vue` script, add: + +```javascript +async function previewDocumentArchive(typeKey, businessId) { + feedbackMessage.value = ""; + errorMessage.value = ""; + if (!businessId) { + errorMessage.value = "缺少归档业务ID,无法预览"; + return; + } + try { + await openResource(`/document-archives/${typeKey}/${businessId}/latest/preview`); + } catch (error) { + errorMessage.value = error.message || "PDF归档预览失败"; + } +} + +async function downloadDocumentArchive(typeKey, businessId, fallbackFilename) { + feedbackMessage.value = ""; + errorMessage.value = ""; + if (!businessId) { + errorMessage.value = "缺少归档业务ID,无法下载"; + return; + } + try { + await downloadResource(`/document-archives/${typeKey}/${businessId}/latest/download`, fallbackFilename); + feedbackMessage.value = "PDF归档已下载"; + } catch (error) { + errorMessage.value = error.message || "PDF归档下载失败"; + } +} + +async function regenerateDocumentArchive(typeKey, businessId) { + feedbackMessage.value = ""; + errorMessage.value = ""; + if (!businessId) { + errorMessage.value = "缺少归档业务ID,无法重新生成"; + return; + } + try { + const result = await postResource(`/document-archives/${typeKey}/${businessId}/generate`, {}); + feedbackMessage.value = result.message || "PDF归档已重新生成"; + await loadAll(); + } catch (error) { + errorMessage.value = error.message || "PDF归档重新生成失败"; + } +} +``` + +- [ ] **Step 7: Add production archive styles** + +Add scoped styles to `ProductionLedgerView.vue`: + +```css +.production-archive-panel { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin: 14px 0; + padding: 12px 14px; + border: 1px solid rgba(59, 130, 246, 0.18); + border-radius: 16px; + background: linear-gradient(135deg, rgba(239, 246, 255, 0.96), rgba(255, 255, 255, 0.9)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8); +} + +.production-archive-panel > div:first-child { + display: grid; + gap: 4px; +} + +.production-archive-panel span { + color: var(--muted); + font-size: 12px; +} + +.production-archive-panel strong { + color: var(--ink); + font-size: 14px; +} + +.production-archive-panel-inline { + margin-top: 12px; +} +``` + +- [ ] **Step 8: Update warehouse feedback messages** + +In `InventoryLedgerView.vue`, add helper: + +```javascript +function formatArchiveFeedback(result) { + if (!result?.archive_status) { + return ""; + } + if (result.archive_status === "已归档") { + return ",PDF归档已生成"; + } + if (result.archive_status === "归档失败") { + return `,PDF归档失败${result.archive_error_message ? `:${result.archive_error_message}` : ""}`; + } + return `,PDF归档状态:${result.archive_status}`; +} +``` + +Update `submitProductionWorkOrderInbound` success message: + +```javascript +feedbackMessage.value = `生产台账入库 ${result.material_lot_no || ""} 已保存${formatArchiveFeedback(result)}`; +``` + +Update `submitGenericOperation` success messages for production branch operations so the saved result appends `formatArchiveFeedback(result)`. For inbound and outbound requests currently calling `postResource` without keeping the response, keep the payload unchanged and store the response: + +```javascript +const result = await postResource("/inventory/inbound", payload); +feedbackMessage.value = `${activeOperation.value?.label || "入库"}已保存${formatArchiveFeedback(result)}`; +``` + +and: + +```javascript +const result = await postResource("/inventory/outbound", payload); +feedbackMessage.value = `${activeOperation.value?.label || "出库"}已保存${formatArchiveFeedback(result)}`; +``` + +Do not change validation or payload fields. + +- [ ] **Step 9: Run frontend static test** + +Run: + +```bash +node --test frontend/src/views/ProductionLedgerView.test.js +``` + +Expected: PASS. + +- [ ] **Step 10: Run frontend build** + +Run: + +```bash +cd frontend +npm run build +``` + +Expected: PASS. A Vite chunk-size warning is acceptable. + +- [ ] **Step 11: Commit frontend archive UI** + +Run: + +```bash +git add frontend/src/views/ProductionLedgerView.vue frontend/src/views/InventoryLedgerView.vue frontend/src/views/ProductionLedgerView.test.js +git commit -m "feat: show production warehouse archive actions" +``` + +Expected: commit succeeds in a git worktree. If no `.git` exists, record the skipped commit. + +--- + +### Task 6: Full Verification And Manual Browser Check + +**Files:** +- No code changes expected. + +- [ ] **Step 1: Run full focused backend verification** + +Run: + +```bash +PYTHONPATH=backend backend/.venv/bin/pytest \ + backend/tests/test_document_archive_service.py \ + backend/tests/test_quality_inspection_document_archive.py \ + backend/tests/test_document_archive_routes.py \ + backend/tests/test_production_warehouse_document_archive.py \ + backend/tests/test_production_batch_ledger.py \ + backend/tests/test_production_work_order_inbound.py \ + backend/tests/test_selected_stock_lot_production_issue.py \ + backend/tests/test_production_work_order_ledger.py \ + backend/tests/test_scrap_warehouse_flow.py \ + -q +``` + +Expected: PASS. + +- [ ] **Step 2: Run frontend checks** + +Run: + +```bash +node --test frontend/src/views/ProductionLedgerView.test.js +cd frontend +npm run build +``` + +Expected: static test PASS and build PASS. Vite chunk-size warning is acceptable. + +- [ ] **Step 3: Browser verify production ledger archive actions** + +Use the in-app Browser and navigate to: + +```text +http://127.0.0.1:5173/production-ledger +``` + +Expected: + +- Production ledger list loads. +- Clicking a material lot opens the detail drawer. +- Detail drawer shows `生产领料出库单` archive actions. +- Transaction detail rows that have inventory source IDs show `生产入库结算单` archive actions. +- If old rows show `未生成`, clicking `重新生成` creates the PDF or shows a Chinese error. + +- [ ] **Step 4: Browser verify warehouse feedback** + +Use the in-app Browser and navigate to: + +```text +http://127.0.0.1:5173/inventory-ledger +``` + +Expected: + +- Production ledger inbound save success message includes PDF archive result when backend returns it. +- Production branch inbound/outbound save success message includes PDF archive result when backend returns it. +- Existing validations and payload behavior remain unchanged. + +- [ ] **Step 5: Inspect generated PDF manually** + +Preview one generated `生产领料出库单` and one generated `生产入库结算单`. + +Expected: + +- PDF title is Chinese. +- Status values are Chinese. +- Long material lot numbers and remarks wrap instead of truncating. +- No ellipsis is used in PDF cell content. +- PDF includes material lot number, product, quantities/weights, unit cost, amount, and remarks. + +- [ ] **Step 6: Final status report** + +Report: + +- Backend tests run and pass/fail count. +- Frontend tests/build run and pass/fail count. +- Browser verification result. +- Any Vite warnings. +- Whether commits were created or skipped because this workspace has no `.git`. + +--- + +## Self-Review Checklist + +- Spec coverage: + - Production material issue is covered by `生产领料出库单`. + - Production ledger inbound and branch inbound are covered by `生产入库结算单`. + - PDF failure does not roll back business save; archive failure is returned in response fields. + - History rows show `未生成` and can regenerate through existing document archive routes. + - Batch download remains out of scope. +- Placeholder scan: + - No implementation step uses placeholder tokens or unresolved file names. + - Commands and expected results are explicit. +- Type consistency: + - Route keys are `production-material-out` and `production-inbound-settlement`. + - Document types are `生产领料出库单` and `生产入库结算单`. + - Archive response fields are consistently named `archive_status`, `archive_business_id`, `archive_document_type`, and `archive_error_message`. diff --git a/docs/superpowers/plans/2026-06-11-sales-purchase-document-form-archive.md b/docs/superpowers/plans/2026-06-11-sales-purchase-document-form-archive.md new file mode 100644 index 0000000..adb656a --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-sales-purchase-document-form-archive.md @@ -0,0 +1,2986 @@ +# Sales Purchase Document Form Archive Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将销售订单、采购订单新增/编辑抽屉升级为真实纸质业务单据风格,并在保存成功后由后端生成 PDF 归档快照,支持预览、下载、批量下载和归档失败重试。 + +**Architecture:** 后端新增通用 `document_archives` 归档底座,销售/采购路由在业务保存后调用归档服务生成 PDF,归档失败不阻断业务保存但必须产生可见状态。前端新增单据纸面组件族,销售订单、采购订单只替换抽屉内部表现形式,不改变字段、计算和提交语义。 + +**Tech Stack:** FastAPI, SQLAlchemy 2, Pydantic, ReportLab PDF, Python `zipfile`, Vue 3 Composition API, Vite, CSS design tokens. + +--- + +## Scope And Non-Negotiables + +- 第一版只覆盖销售订单和采购订单,不扩展到入库单、质检单、仓库出入库单、盘库单。 +- 外层仍使用现有 `FormDrawer` 入口,内层替换为 v3 确认过的“工厂联单/归档单”视觉。 +- 不删除销售订单、采购订单现有字段,不改变 payload 字段名和计算逻辑。 +- PDF 是主归档文件,Word 不在本计划中实现为主留档。 +- 合同生成逻辑保持独立,不复用当前 `window.print()` 合同代码作为订单归档。 +- 业务保存成功后生成 PDF;PDF 生成失败不能回滚业务单据,但列表必须显示“归档失败”并允许重试。 +- 归档状态、业务文案和错误信息使用中文:`未生成`、`已归档`、`归档失败`。 +- 当前项目目录可能不是 git 工作区。执行任务时若 `git status` 可用,每个任务结束提交一次;若不可用,记录变更文件和验证结果。 + +## Visual Benchmark + +已确认的视觉基准: + +- `outputs/document-form-mockups/sales-order-document-form-mockup-v3-real-paper.png` +- `outputs/document-form-mockups/purchase-order-document-form-mockup-v3-real-paper.png` +- `outputs/document-form-mockups/order-form-document-mockup-v3.html` + +必须保留的视觉特征: + +- 真实米白纸张、轻纸纹、黑色表格线。 +- 金属夹、叠纸阴影、装订孔。 +- 标题居中大黑字:`销售订单`、`采购订单`。 +- 右上角红/绿单据编号章。 +- 竖向 `ERP留存联` 标记。 +- PDF 归档章不能遮挡核心数据。 +- 明细区域是纸面表格行,不再是多张卡片堆叠。 +- 底部有签字/确认栏。 + +## File Structure + +### Backend + +- Create: `backend/app/models/document_archive.py` + - SQLAlchemy model for `document_archives`. +- Modify: `backend/app/models/__init__.py` + - Export `DocumentArchive` so `Base.metadata.create_all()` includes the table in tests. +- Create: `backend/app/schemas/document_archives.py` + - Pydantic read models and batch download request. +- Modify: `backend/app/schemas/database.py` + - Add archive fields to `SalesOrderRead` and `SalesOrderCreateResult`. +- Modify: `backend/app/schemas/operations.py` + - Add archive fields to `PurchaseOrderRead`. +- Create: `backend/sql/add_document_archives_patch.sql` + - MySQL migration for the archive table. +- Modify: `backend/requirements.txt` + - Add ReportLab dependency. +- Create: `backend/app/services/document_archives.py` + - Collect sales/purchase data, render PDF, persist archive rows, return latest archive, build zip. +- Create: `backend/app/api/routes/document_archives.py` + - Generic generate/preview/download/batch download endpoints. +- Modify: `backend/app/api/router.py` + - Register document archive router. +- Modify: `backend/app/services/sales_planning.py` + - Enrich sales order query with latest archive metadata. +- Modify: `backend/app/services/operations.py` + - Enrich purchase order query with latest archive metadata. +- Modify: `backend/app/api/routes/sales.py` + - Generate archive after sales order save. +- Modify: `backend/app/api/routes/purchase.py` + - Generate archive after purchase order create/update. +- Create: `backend/tests/test_document_archive_model.py` +- Create: `backend/tests/test_document_archive_service.py` +- Create: `backend/tests/test_document_archive_routes.py` +- Create: `backend/tests/test_sales_order_document_archive.py` +- Create: `backend/tests/test_purchase_order_document_archive.py` + +### Frontend + +- Create: `frontend/src/components/documentForms/DocumentPaper.vue` + - Paper shell: clip, stacked paper, binder holes, title, document number stamp, retained-copy mark. +- Create: `frontend/src/components/documentForms/DocumentGrid.vue` + - Paper-style label/value grid for header fields. +- Create: `frontend/src/components/documentForms/DocumentLineTable.vue` + - Paper-style editable line table wrapper. +- Create: `frontend/src/components/documentForms/DocumentArchiveActions.vue` + - Archive status, preview, download, retry, batch download buttons. +- Modify: `frontend/src/styles/main.css` + - Add document paper CSS tokens and responsive layout. +- Modify: `frontend/src/services/api.js` + - Keep `downloadResource()` and `openResource()`; add `postDownloadResource()` for batch zip. +- Modify: `frontend/src/views/SalesPlanningView.vue` + - Replace sales order drawer internals with document form components and add archive actions to list. +- Modify: `frontend/src/views/PurchaseOrderView.vue` + - Replace purchase order drawer internals with document form components and add archive actions to list. +- Create: `frontend/scripts/test-document-form-components.mjs` +- Create: `frontend/scripts/test-sales-purchase-document-archive-ui.mjs` + +--- + +### Task 1: Archive Data Model And SQL Foundation + +**Files:** +- Create: `backend/app/models/document_archive.py` +- Modify: `backend/app/models/__init__.py` +- Create: `backend/app/schemas/document_archives.py` +- Modify: `backend/app/schemas/database.py` +- Modify: `backend/app/schemas/operations.py` +- Create: `backend/sql/add_document_archives_patch.sql` +- Test: `backend/tests/test_document_archive_model.py` + +- [ ] **Step 1: Write the failing model test** + +Create `backend/tests/test_document_archive_model.py`: + +```python +from __future__ import annotations + +import unittest +from datetime import datetime + +from sqlalchemy import BigInteger, create_engine, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.document_archive # noqa: E402,F401 +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.document_archive import DocumentArchive # noqa: E402 + + +class DocumentArchiveModelTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db = self.SessionLocal() + + def tearDown(self) -> None: + self.db.close() + + def test_document_archive_round_trips_chinese_business_status(self) -> None: + row = DocumentArchive( + document_type="销售订单", + business_id=101, + document_no="销售2026-00001", + archive_version=1, + template_version="单据纸面V1", + file_format="PDF", + file_name="销售订单-销售2026-00001-v1.pdf", + file_path="outputs/document_archives/sales_order/销售订单-销售2026-00001-v1.pdf", + file_hash="abc123", + status="已归档", + error_message=None, + created_by=None, + created_at=datetime(2026, 6, 11, 10, 0, 0), + updated_at=datetime(2026, 6, 11, 10, 0, 0), + ) + self.db.add(row) + self.db.commit() + + saved = self.db.scalar(select(DocumentArchive).where(DocumentArchive.document_type == "销售订单")) + + self.assertIsNotNone(saved) + self.assertEqual(saved.status, "已归档") + self.assertEqual(saved.template_version, "单据纸面V1") + self.assertEqual(saved.archive_version, 1) +``` + +- [ ] **Step 2: Run the model test to verify it fails** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +PYTHONPATH=backend pytest backend/tests/test_document_archive_model.py -q +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'app.models.document_archive'`. + +- [ ] **Step 3: Create the SQLAlchemy model** + +Create `backend/app/models/document_archive.py`: + +```python +from __future__ import annotations + +from sqlalchemy import BigInteger, DateTime, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class DocumentArchive(Base): + __tablename__ = "document_archives" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + document_type: Mapped[str] = mapped_column(String(50)) + business_id: Mapped[int] = mapped_column(BigInteger) + document_no: Mapped[str] = mapped_column(String(100)) + archive_version: Mapped[int] = mapped_column(Integer) + template_version: Mapped[str] = mapped_column(String(50)) + file_format: Mapped[str] = mapped_column(String(20)) + file_name: Mapped[str] = mapped_column(String(255)) + file_path: Mapped[str] = mapped_column(String(500)) + file_hash: Mapped[str | None] = mapped_column(String(128), nullable=True) + status: Mapped[str] = mapped_column(String(32)) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + created_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + created_at: Mapped[object] = mapped_column(DateTime) + updated_at: Mapped[object] = mapped_column(DateTime) +``` + +- [ ] **Step 4: Export the model** + +Modify `backend/app/models/__init__.py`: + +```python +from app.models.document_archive import DocumentArchive +``` + +Add `"DocumentArchive"` to `__all__`. + +- [ ] **Step 5: Create archive schemas** + +Create `backend/app/schemas/document_archives.py`: + +```python +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class DocumentArchiveRead(BaseModel): + archive_id: int + document_type: str + business_id: int + document_no: str + archive_version: int + template_version: str + file_format: str + file_name: str + status: str + error_message: str | None = None + created_at: datetime + + +class DocumentArchiveGenerateResult(BaseModel): + business_id: int + document_type: str + document_no: str + archive_status: str + archive_version: int | None = None + archive_error_message: str | None = None + + +class DocumentArchiveBatchDownloadRequest(BaseModel): + document_type: str + business_ids: list[int] = Field(default_factory=list, min_length=1) +``` + +- [ ] **Step 6: Add archive fields to sales schemas** + +Modify `backend/app/schemas/database.py`. + +In `SalesOrderRead`, add: + +```python + archive_status: str = "未生成" + archive_version: int | None = None + archive_created_at: datetime | None = None + archive_error_message: str | None = None +``` + +In `SalesOrderCreateResult`, add: + +```python + archive_status: str = "未生成" + archive_version: int | None = None + archive_error_message: str | None = None +``` + +If `datetime` is not already imported in `backend/app/schemas/database.py`, extend the existing datetime import to include it. + +- [ ] **Step 7: Add archive fields to purchase schemas** + +Modify `backend/app/schemas/operations.py`. + +In `PurchaseOrderRead`, add: + +```python + archive_status: str = "未生成" + archive_version: int | None = None + archive_created_at: datetime | None = None + archive_error_message: str | None = None +``` + +If `datetime` is not already imported in `backend/app/schemas/operations.py`, extend the existing datetime import to include it. + +- [ ] **Step 8: Create the MySQL SQL patch** + +Create `backend/sql/add_document_archives_patch.sql`: + +```sql +CREATE TABLE IF NOT EXISTS document_archives ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + document_type VARCHAR(50) NOT NULL COMMENT '单据类型:销售订单/采购订单', + business_id BIGINT NOT NULL COMMENT '业务主表ID', + document_no VARCHAR(100) NOT NULL COMMENT '业务单号', + archive_version INT NOT NULL COMMENT '归档版本', + template_version VARCHAR(50) NOT NULL DEFAULT '单据纸面V1' COMMENT '模板版本', + file_format VARCHAR(20) NOT NULL DEFAULT 'PDF' COMMENT '文件格式', + file_name VARCHAR(255) NOT NULL COMMENT '文件名', + file_path VARCHAR(500) NOT NULL COMMENT '文件路径', + file_hash VARCHAR(128) NULL COMMENT '文件哈希', + status VARCHAR(32) NOT NULL COMMENT '归档状态:已归档/归档失败', + error_message TEXT NULL COMMENT '归档失败原因', + created_by BIGINT NULL COMMENT '生成用户ID', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_document_archives_version (document_type, business_id, archive_version, file_format), + KEY idx_document_archives_business_latest (document_type, business_id, archive_version), + KEY idx_document_archives_document_no (document_no), + KEY idx_document_archives_status (status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +- [ ] **Step 9: Run the model test to verify it passes** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +PYTHONPATH=backend pytest backend/tests/test_document_archive_model.py -q +``` + +Expected: PASS. + +- [ ] **Step 10: Record checkpoint** + +Run: + +```bash +git status --short || true +``` + +Expected in this workspace: either changed files are listed, or git reports this directory is not a git repository. + +--- + +### Task 2: PDF Archive Rendering Service + +**Files:** +- Modify: `backend/requirements.txt` +- Create: `backend/app/services/document_archives.py` +- Test: `backend/tests/test_document_archive_service.py` + +- [ ] **Step 1: Add a PDF dependency** + +Modify `backend/requirements.txt` and append: + +```text +reportlab>=4.2,<5.0 +``` + +- [ ] **Step 2: Write the failing service test** + +Create `backend/tests/test_document_archive_service.py`: + +```python +from __future__ import annotations + +import unittest +from datetime import date, datetime +from pathlib import Path +from tempfile import TemporaryDirectory + +from sqlalchemy import BigInteger, create_engine +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.document_archive # noqa: E402,F401 +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.models.base import Base # noqa: E402 +from app.models.document_archive import DocumentArchive # noqa: E402 +from app.models.master_data import Item # noqa: E402 +from app.models.sales import Customer, SalesOrder, SalesOrderItem # noqa: E402 +from app.services.document_archives import ( # noqa: E402 + DOCUMENT_TYPE_SALES_ORDER, + generate_document_archive, + get_latest_archive, +) + + +class DocumentArchiveServiceTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db = self.SessionLocal() + now = datetime(2026, 6, 11, 10, 0, 0) + self.customer = Customer( + id=1, + customer_code="CUS001", + customer_name="嘉恒客户", + short_name="嘉恒", + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.product = Item( + id=10, + item_code="CP00010", + item_name="钢制碗", + item_type="FINISHED", + unit_id=None, + status="ACTIVE", + created_at=now, + updated_at=now, + ) + self.order = SalesOrder( + id=100, + order_no="销售2026-00001", + customer_id=1, + order_date=date(2026, 6, 11), + promised_date=date(2026, 6, 20), + sales_employee_id=None, + delivery_address="宁波市测试路1号", + tax_rate=0.13, + total_amount=600, + status="OPEN", + created_at=now, + updated_at=now, + ) + self.item = SalesOrderItem( + id=1000, + sales_order_id=100, + line_no=1, + product_item_id=10, + customer_part_no="KH-001", + order_qty=100, + delivered_qty=0, + unit_price=6, + line_amount=600, + promised_date=date(2026, 6, 20), + status="OPEN", + created_at=now, + updated_at=now, + ) + self.db.add_all([self.customer, self.product, self.order, self.item]) + self.db.commit() + self.tempdir = TemporaryDirectory() + + def tearDown(self) -> None: + self.tempdir.cleanup() + self.db.close() + + def test_generate_sales_order_archive_creates_pdf_and_archive_row(self) -> None: + result = generate_document_archive( + self.db, + document_type=DOCUMENT_TYPE_SALES_ORDER, + business_id=100, + created_by=None, + archive_root=Path(self.tempdir.name), + ) + + self.assertEqual(result.archive_status, "已归档") + self.assertEqual(result.archive_version, 1) + archive = get_latest_archive(self.db, DOCUMENT_TYPE_SALES_ORDER, 100) + self.assertIsNotNone(archive) + self.assertEqual(archive.document_no, "销售2026-00001") + pdf_path = Path(archive.file_path) + self.assertTrue(pdf_path.exists()) + self.assertEqual(pdf_path.read_bytes()[:4], b"%PDF") + + def test_generate_archive_uses_version_sequence(self) -> None: + generate_document_archive(self.db, DOCUMENT_TYPE_SALES_ORDER, 100, archive_root=Path(self.tempdir.name)) + second = generate_document_archive(self.db, DOCUMENT_TYPE_SALES_ORDER, 100, archive_root=Path(self.tempdir.name)) + + self.assertEqual(second.archive_version, 2) + rows = self.db.query(DocumentArchive).filter(DocumentArchive.business_id == 100).all() + self.assertEqual(len(rows), 2) +``` + +- [ ] **Step 3: Run the service test to verify it fails** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +PYTHONPATH=backend pytest backend/tests/test_document_archive_service.py -q +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'app.services.document_archives'`. + +- [ ] **Step 4: Create service constants and helpers** + +Create `backend/app/services/document_archives.py` with these top-level constants and helpers: + +```python +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal +from pathlib import Path +from zipfile import ZIP_DEFLATED, ZipFile + +from fastapi import HTTPException +from reportlab.lib import colors +from reportlab.lib.pagesizes import A4 +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.cidfonts import UnicodeCIDFont +from reportlab.pdfgen import canvas +from sqlalchemy import func, select +from sqlalchemy.orm import Session, aliased + +from app.models.document_archive import DocumentArchive +from app.models.master_data import Item +from app.models.operations import PurchaseOrder, PurchaseOrderItem, PurchaseOrderSalesOrderLink, Supplier +from app.models.org import Employee +from app.models.sales import Customer, SalesOrder, SalesOrderItem +from app.schemas.document_archives import DocumentArchiveGenerateResult + +DOCUMENT_TYPE_SALES_ORDER = "销售订单" +DOCUMENT_TYPE_PURCHASE_ORDER = "采购订单" +ARCHIVE_STATUS_READY = "已归档" +ARCHIVE_STATUS_FAILED = "归档失败" +ARCHIVE_STATUS_MISSING = "未生成" +TEMPLATE_VERSION = "单据纸面V1" +FILE_FORMAT_PDF = "PDF" + + +@dataclass(frozen=True) +class ArchiveLine: + line_no: int + name: str + code: str + spec: str + qty_text: str + price_text: str + amount_text: str + date_text: str + remark: str + + +@dataclass(frozen=True) +class ArchiveContext: + document_type: str + document_no: str + title: str + party_label: str + party_name: str + owner_label: str + owner_name: str + date_label: str + date_text: str + promised_label: str + promised_text: str + tax_rate_text: str + address_or_target_text: str + remark: str + total_label: str + total_text: str + lines: list[ArchiveLine] + signature_labels: tuple[str, str, str, str] + + +def default_archive_root() -> Path: + return Path(__file__).resolve().parents[3] / "outputs" / "document_archives" + + +def safe_filename(value: str) -> str: + normalized = re.sub(r"[\\/:*?\"<>|\\s]+", "-", str(value or "").strip()) + return normalized.strip("-") or "单据" + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def decimal_text(value: object, places: int = 2) -> str: + amount = Decimal(str(value or 0)) + return f"{amount:,.{places}f}" + + +def date_text(value: object | None) -> str: + return str(value or "-")[:10] +``` + +- [ ] **Step 5: Add latest archive and version helpers** + +Append to `backend/app/services/document_archives.py`: + +```python +def get_latest_archive(db: Session, document_type: str, business_id: int) -> DocumentArchive | None: + return db.scalar( + select(DocumentArchive) + .where( + DocumentArchive.document_type == document_type, + DocumentArchive.business_id == business_id, + DocumentArchive.file_format == FILE_FORMAT_PDF, + ) + .order_by(DocumentArchive.archive_version.desc(), DocumentArchive.id.desc()) + ) + + +def next_archive_version(db: Session, document_type: str, business_id: int) -> int: + latest = db.scalar( + select(func.max(DocumentArchive.archive_version)).where( + DocumentArchive.document_type == document_type, + DocumentArchive.business_id == business_id, + DocumentArchive.file_format == FILE_FORMAT_PDF, + ) + ) + return int(latest or 0) + 1 +``` + +- [ ] **Step 6: Add sales context collection** + +Append to `backend/app/services/document_archives.py`: + +```python +def collect_sales_order_archive_context(db: Session, sales_order_id: int) -> ArchiveContext: + sales_employee = aliased(Employee) + row = db.execute( + select( + SalesOrder, + Customer.customer_name.label("customer_name"), + sales_employee.employee_name.label("sales_employee_name"), + ) + .join(Customer, Customer.id == SalesOrder.customer_id) + .outerjoin(sales_employee, sales_employee.id == SalesOrder.sales_employee_id) + .where(SalesOrder.id == sales_order_id) + ).first() + if not row: + raise HTTPException(status_code=404, detail="销售订单不存在") + + order = row[0] + item_rows = db.execute( + select(SalesOrderItem, Item.item_code, Item.item_name) + .join(Item, Item.id == SalesOrderItem.product_item_id) + .where(SalesOrderItem.sales_order_id == sales_order_id) + .order_by(SalesOrderItem.line_no.asc()) + ).all() + lines = [ + ArchiveLine( + line_no=int(item.line_no or index), + name=str(item_name or "-"), + code=str(item_code or "-"), + spec=str(item.customer_part_no or "-"), + qty_text=decimal_text(item.order_qty, 3), + price_text=decimal_text(item.unit_price, 4), + amount_text=decimal_text(item.line_amount, 2), + date_text=date_text(item.promised_date), + remark="客户料号" if item.customer_part_no else "", + ) + for index, (item, item_code, item_name) in enumerate(item_rows, start=1) + ] + return ArchiveContext( + document_type=DOCUMENT_TYPE_SALES_ORDER, + document_no=order.order_no, + title="销售订单", + party_label="客户", + party_name=str(row.customer_name or "-"), + owner_label="销售人员", + owner_name=str(row.sales_employee_name or "-"), + date_label="下单日期", + date_text=date_text(order.order_date), + promised_label="交期", + promised_text=date_text(order.promised_date), + tax_rate_text=f"{Decimal(str(order.tax_rate or 0)) * 100:.2f}%", + address_or_target_text=str(order.delivery_address or "-"), + remark=str(getattr(order, "remark", "") or ""), + total_label="订单金额", + total_text=decimal_text(order.total_amount, 2), + lines=lines, + signature_labels=("销售确认", "客户确认", "财务复核", "制单人"), + ) +``` + +- [ ] **Step 7: Add purchase context collection** + +Append to `backend/app/services/document_archives.py`: + +```python +def collect_purchase_order_archive_context(db: Session, purchase_order_id: int) -> ArchiveContext: + purchaser = aliased(Employee) + row = db.execute( + select( + PurchaseOrder, + Supplier.supplier_name.label("supplier_name"), + purchaser.employee_name.label("purchaser_name"), + ) + .join(Supplier, Supplier.id == PurchaseOrder.supplier_id) + .outerjoin(purchaser, purchaser.id == PurchaseOrder.purchaser_employee_id) + .where(PurchaseOrder.id == purchase_order_id) + ).first() + if not row: + raise HTTPException(status_code=404, detail="采购订单不存在") + + order = row[0] + sales_nos = db.scalars( + select(SalesOrder.order_no) + .join(PurchaseOrderSalesOrderLink, PurchaseOrderSalesOrderLink.sales_order_id == SalesOrder.id) + .where(PurchaseOrderSalesOrderLink.purchase_order_id == purchase_order_id) + .order_by(SalesOrder.id.asc()) + ).all() + item_rows = db.execute( + select(PurchaseOrderItem, Item.item_code, Item.item_name) + .join(Item, Item.id == PurchaseOrderItem.material_item_id) + .where(PurchaseOrderItem.purchase_order_id == purchase_order_id) + .order_by(PurchaseOrderItem.line_no.asc()) + ).all() + is_aux = str(order.target_warehouse_type or "").upper() == "AUX" + lines = [ + ArchiveLine( + line_no=int(item.line_no or index), + name=str(item_name or "-"), + code=str(item_code or "-"), + spec=str(item.remark or "-"), + qty_text=decimal_text(item.order_qty if is_aux else item.order_weight_kg, 3), + price_text=decimal_text(item.unit_price, 4), + amount_text=decimal_text(item.line_amount, 2), + date_text=date_text(order.expected_date), + remark="数量" if is_aux else "重量kg", + ) + for index, (item, item_code, item_name) in enumerate(item_rows, start=1) + ] + target_text = "辅料库" if is_aux else "原材料库" + if sales_nos: + target_text = f"{target_text};关联销售订单:{'、'.join(str(no) for no in sales_nos)}" + return ArchiveContext( + document_type=DOCUMENT_TYPE_PURCHASE_ORDER, + document_no=order.po_no, + title="采购订单", + party_label="供应商", + party_name=str(row.supplier_name or "-"), + owner_label="采购人员", + owner_name=str(row.purchaser_name or "-"), + date_label="采购日期", + date_text=date_text(order.order_date), + promised_label="预计到货", + promised_text=date_text(order.expected_date), + tax_rate_text=f"{Decimal(str(order.tax_rate or 0)) * 100:.2f}%", + address_or_target_text=target_text, + remark=str(order.remark or ""), + total_label="采购金额", + total_text=decimal_text(order.total_amount, 2), + lines=lines, + signature_labels=("采购确认", "供应商确认", "仓库接收", "制单人"), + ) +``` + +- [ ] **Step 8: Add PDF rendering** + +Append to `backend/app/services/document_archives.py`: + +```python +def _register_pdf_fonts() -> None: + try: + pdfmetrics.registerFont(UnicodeCIDFont("STSong-Light")) + except Exception: + pass + + +def draw_text(pdf: canvas.Canvas, x: float, y: float, text: object, size: int = 9, bold: bool = False) -> None: + _ = bold + pdf.setFont("STSong-Light", size) + pdf.drawString(x, y, str(text or "-")) + + +def render_pdf_archive(context: ArchiveContext, output_path: Path) -> None: + _register_pdf_fonts() + output_path.parent.mkdir(parents=True, exist_ok=True) + pdf = canvas.Canvas(str(output_path), pagesize=A4) + width, height = A4 + pdf.setTitle(f"{context.title}-{context.document_no}") + pdf.setFillColor(colors.HexColor("#fbf3df")) + pdf.rect(24, 24, width - 48, height - 48, fill=1, stroke=0) + pdf.setStrokeColor(colors.HexColor("#1d1d1d")) + pdf.setLineWidth(1) + pdf.rect(42, 56, width - 84, height - 112, fill=0, stroke=1) + pdf.setFillColor(colors.black) + pdf.setFont("STSong-Light", 22) + pdf.drawCentredString(width / 2, height - 92, context.title) + pdf.setStrokeColor(colors.HexColor("#b91c1c")) + pdf.setFillColor(colors.HexColor("#b91c1c")) + pdf.roundRect(width - 205, height - 116, 150, 28, 4, fill=0, stroke=1) + draw_text(pdf, width - 195, height - 107, f"单号:{context.document_no}", 8) + pdf.setFillColor(colors.HexColor("#6b1f1f")) + pdf.rotate(90) + draw_text(pdf, height - 245, -34, "ERP留存联", 10) + pdf.rotate(-90) + + y = height - 145 + row_h = 27 + left = 54 + right = width - 54 + col_w = (right - left) / 4 + header_cells = [ + (context.party_label, context.party_name), + (context.owner_label, context.owner_name), + (context.date_label, context.date_text), + (context.promised_label, context.promised_text), + ("税率", context.tax_rate_text), + ("地址/到货库", context.address_or_target_text), + ("备注", context.remark or "-"), + (context.total_label, context.total_text), + ] + for row_index in range(2): + for col_index in range(4): + x = left + col_index * col_w + pdf.rect(x, y - row_h, col_w, row_h, fill=0, stroke=1) + label, value = header_cells[row_index * 4 + col_index] + draw_text(pdf, x + 6, y - 12, label, 7) + draw_text(pdf, x + 6, y - 23, value, 8) + y -= row_h + + y -= 24 + columns = [ + ("序号", 34), + ("编码", 78), + ("名称", 120), + ("规格/料号", 95), + ("数量/重量", 70), + ("单价", 70), + ("金额", 70), + ("交期", 72), + ] + x = left + for label, col_width in columns: + pdf.rect(x, y - row_h, col_width, row_h, fill=0, stroke=1) + draw_text(pdf, x + 4, y - 18, label, 8) + x += col_width + y -= row_h + for line in context.lines[:12]: + values = [ + line.line_no, + line.code, + line.name, + line.spec, + line.qty_text, + line.price_text, + line.amount_text, + line.date_text, + ] + x = left + for value, (_, col_width) in zip(values, columns, strict=True): + pdf.rect(x, y - row_h, col_width, row_h, fill=0, stroke=1) + draw_text(pdf, x + 4, y - 18, str(value)[:24], 8) + x += col_width + y -= row_h + + while y > 166: + x = left + for _, col_width in columns: + pdf.rect(x, y - row_h, col_width, row_h, fill=0, stroke=1) + x += col_width + y -= row_h + + pdf.setStrokeColor(colors.HexColor("#991b1b")) + pdf.circle(width - 122, 148, 34, fill=0, stroke=1) + draw_text(pdf, width - 151, 148, "PDF归档", 10) + pdf.setStrokeColor(colors.HexColor("#1d1d1d")) + sign_y = 104 + sign_w = (right - left) / 4 + for index, label in enumerate(context.signature_labels): + x = left + index * sign_w + pdf.rect(x, sign_y, sign_w, 34, fill=0, stroke=1) + draw_text(pdf, x + 6, sign_y + 20, label, 8) + draw_text(pdf, x + 6, sign_y + 8, "签字:", 8) + draw_text(pdf, left, 72, f"模板版本:{TEMPLATE_VERSION}", 7) + draw_text(pdf, right - 150, 72, f"生成时间:{datetime.now():%Y-%m-%d %H:%M}", 7) + pdf.showPage() + pdf.save() +``` + +- [ ] **Step 9: Add generation and zip functions** + +Append to `backend/app/services/document_archives.py`: + +```python +def collect_archive_context(db: Session, document_type: str, business_id: int) -> ArchiveContext: + if document_type == DOCUMENT_TYPE_SALES_ORDER: + return collect_sales_order_archive_context(db, business_id) + if document_type == DOCUMENT_TYPE_PURCHASE_ORDER: + return collect_purchase_order_archive_context(db, business_id) + raise HTTPException(status_code=400, detail="单据类型只能选择销售订单或采购订单") + + +def generate_document_archive( + db: Session, + document_type: str, + business_id: int, + created_by: int | None = None, + archive_root: Path | None = None, +) -> DocumentArchiveGenerateResult: + root = archive_root or default_archive_root() + version = next_archive_version(db, document_type, business_id) + now = datetime.now() + try: + context = collect_archive_context(db, document_type, business_id) + type_dir = "sales_order" if document_type == DOCUMENT_TYPE_SALES_ORDER else "purchase_order" + file_name = f"{safe_filename(context.title)}-{safe_filename(context.document_no)}-v{version}.pdf" + file_path = root / type_dir / file_name + render_pdf_archive(context, file_path) + archive = DocumentArchive( + document_type=document_type, + business_id=business_id, + document_no=context.document_no, + archive_version=version, + template_version=TEMPLATE_VERSION, + file_format=FILE_FORMAT_PDF, + file_name=file_name, + file_path=str(file_path), + file_hash=sha256_file(file_path), + status=ARCHIVE_STATUS_READY, + error_message=None, + created_by=created_by, + created_at=now, + updated_at=now, + ) + db.add(archive) + db.commit() + return DocumentArchiveGenerateResult( + business_id=business_id, + document_type=document_type, + document_no=context.document_no, + archive_status=ARCHIVE_STATUS_READY, + archive_version=version, + archive_error_message=None, + ) + except HTTPException: + db.rollback() + raise + except Exception as exc: + db.rollback() + failed = DocumentArchive( + document_type=document_type, + business_id=business_id, + document_no=f"{document_type}-{business_id}", + archive_version=version, + template_version=TEMPLATE_VERSION, + file_format=FILE_FORMAT_PDF, + file_name="", + file_path="", + file_hash=None, + status=ARCHIVE_STATUS_FAILED, + error_message=f"归档失败:{exc}", + created_by=created_by, + created_at=now, + updated_at=now, + ) + db.add(failed) + db.commit() + return DocumentArchiveGenerateResult( + business_id=business_id, + document_type=document_type, + document_no=failed.document_no, + archive_status=ARCHIVE_STATUS_FAILED, + archive_version=version, + archive_error_message=failed.error_message, + ) + + +def build_archive_zip(db: Session, document_type: str, business_ids: list[int], output_path: Path) -> Path: + output_path.parent.mkdir(parents=True, exist_ok=True) + with ZipFile(output_path, "w", ZIP_DEFLATED) as archive_zip: + for business_id in business_ids: + archive = get_latest_archive(db, document_type, int(business_id)) + if not archive or archive.status != ARCHIVE_STATUS_READY: + continue + pdf_path = Path(archive.file_path) + if pdf_path.exists(): + archive_zip.write(pdf_path, arcname=archive.file_name) + return output_path +``` + +- [ ] **Step 10: Run the service test to verify it passes** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +PYTHONPATH=backend pytest backend/tests/test_document_archive_service.py -q +``` + +Expected: PASS. + +- [ ] **Step 11: Install dependency if the local venv lacks ReportLab** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend +python -m pip install -r requirements.txt +``` + +Expected: `reportlab` installs successfully or is already satisfied. + +--- + +### Task 3: Archive API Routes + +**Files:** +- Create: `backend/app/api/routes/document_archives.py` +- Modify: `backend/app/api/router.py` +- Test: `backend/tests/test_document_archive_routes.py` + +- [ ] **Step 1: Write the failing route test** + +Create `backend/tests/test_document_archive_routes.py`: + +```python +from __future__ import annotations + +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import BigInteger, create_engine +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.document_archive # noqa: E402,F401 +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.api.routes import document_archives # noqa: E402 +from app.db.session import get_db # noqa: E402 +from app.models.base import Base # noqa: E402 +from app.services.auth import require_authenticated_user # noqa: E402 + + +class DocumentArchiveRoutesTest(unittest.TestCase): + def setUp(self) -> None: + self.tempdir = TemporaryDirectory() + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db = SessionLocal() + + app = FastAPI() + app.include_router(document_archives.router, prefix="/api/document-archives") + + def override_db(): + yield self.db + + app.dependency_overrides[get_db] = override_db + app.dependency_overrides[require_authenticated_user] = lambda: {"user_id": 1, "username": "admin"} + self.client = TestClient(app) + + def tearDown(self) -> None: + self.db.close() + self.tempdir.cleanup() + + def test_document_type_key_maps_to_chinese_type(self) -> None: + self.assertEqual(document_archives.normalize_document_type_key("sales-order"), "销售订单") + self.assertEqual(document_archives.normalize_document_type_key("purchase-order"), "采购订单") + + def test_preview_missing_archive_returns_chinese_error(self) -> None: + response = self.client.get("/api/document-archives/sales-order/999/latest/preview") + + self.assertEqual(response.status_code, 404) + self.assertIn("归档文件不存在", response.json()["detail"]) +``` + +- [ ] **Step 2: Run the route test to verify it fails** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +PYTHONPATH=backend pytest backend/tests/test_document_archive_routes.py -q +``` + +Expected: FAIL with import error for `document_archives`. + +- [ ] **Step 3: Create archive routes** + +Create `backend/app/api/routes/document_archives.py`: + +```python +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +from urllib.parse import quote + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.schemas.document_archives import DocumentArchiveBatchDownloadRequest, DocumentArchiveGenerateResult +from app.services.auth import AuthContext, require_authenticated_user +from app.services.document_archives import ( + ARCHIVE_STATUS_READY, + DOCUMENT_TYPE_PURCHASE_ORDER, + DOCUMENT_TYPE_SALES_ORDER, + build_archive_zip, + default_archive_root, + generate_document_archive, + get_latest_archive, +) + +router = APIRouter(dependencies=[Depends(require_authenticated_user)]) + + +def normalize_document_type_key(document_type_key: str) -> str: + mapping = { + "sales-order": DOCUMENT_TYPE_SALES_ORDER, + "purchase-order": DOCUMENT_TYPE_PURCHASE_ORDER, + DOCUMENT_TYPE_SALES_ORDER: DOCUMENT_TYPE_SALES_ORDER, + DOCUMENT_TYPE_PURCHASE_ORDER: DOCUMENT_TYPE_PURCHASE_ORDER, + } + try: + return mapping[document_type_key] + except KeyError as exc: + raise HTTPException(status_code=400, detail="单据类型只能选择销售订单或采购订单") from exc + + +def _archive_file_response(path: Path, filename: str, inline: bool) -> FileResponse: + if not path.exists(): + raise HTTPException(status_code=404, detail="归档文件不存在,请重新生成") + disposition = "inline" if inline else "attachment" + encoded_filename = quote(filename) + return FileResponse( + path, + media_type="application/pdf", + filename=filename, + headers={"Content-Disposition": f"{disposition}; filename=\"archive.pdf\"; filename*=UTF-8''{encoded_filename}"}, + ) + + +@router.post("/{document_type_key}/{business_id}/generate", response_model=DocumentArchiveGenerateResult) +def generate_archive( + document_type_key: str, + business_id: int, + db: Session = Depends(get_db), + auth: AuthContext = Depends(require_authenticated_user), +) -> DocumentArchiveGenerateResult: + document_type = normalize_document_type_key(document_type_key) + user_id = getattr(auth, "user_id", None) if not isinstance(auth, dict) else auth.get("user_id") + return generate_document_archive(db, document_type=document_type, business_id=business_id, created_by=user_id) + + +@router.get("/{document_type_key}/{business_id}/latest/preview") +def preview_latest_archive(document_type_key: str, business_id: int, db: Session = Depends(get_db)) -> FileResponse: + document_type = normalize_document_type_key(document_type_key) + archive = get_latest_archive(db, document_type, business_id) + if not archive or archive.status != ARCHIVE_STATUS_READY: + raise HTTPException(status_code=404, detail="归档文件不存在,请重新生成") + return _archive_file_response(Path(archive.file_path), archive.file_name, inline=True) + + +@router.get("/{document_type_key}/{business_id}/latest/download") +def download_latest_archive(document_type_key: str, business_id: int, db: Session = Depends(get_db)) -> FileResponse: + document_type = normalize_document_type_key(document_type_key) + archive = get_latest_archive(db, document_type, business_id) + if not archive or archive.status != ARCHIVE_STATUS_READY: + raise HTTPException(status_code=404, detail="归档文件不存在,请重新生成") + return _archive_file_response(Path(archive.file_path), archive.file_name, inline=False) + + +@router.post("/batch-download") +def batch_download_archives(payload: DocumentArchiveBatchDownloadRequest, db: Session = Depends(get_db)) -> FileResponse: + document_type = normalize_document_type_key(payload.document_type) + timestamp = datetime.now().strftime("%Y%m%d%H%M%S") + zip_name = f"{document_type}-归档-{timestamp}.zip" + zip_path = default_archive_root() / "batch" / zip_name + build_archive_zip(db, document_type, payload.business_ids, zip_path) + encoded_filename = quote(zip_name) + return FileResponse( + zip_path, + media_type="application/zip", + filename=zip_name, + headers={"Content-Disposition": f"attachment; filename=\"archives.zip\"; filename*=UTF-8''{encoded_filename}"}, + ) +``` + +- [ ] **Step 4: Register the router** + +Modify `backend/app/api/router.py`: + +```python +from app.api.routes import ( + auth, + dashboard, + document_archives, + equipment, + finance, + inventory, + master_data, + planning, + production, + purchase, + quality, + returns, + sales, + system, + system_extension, + system_permissions, +) +``` + +Add: + +```python +api_router.include_router(document_archives.router, prefix="/document-archives", tags=["document-archives"]) +``` + +- [ ] **Step 5: Run the route test to verify it passes** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +PYTHONPATH=backend pytest backend/tests/test_document_archive_routes.py -q +``` + +Expected: PASS. + +--- + +### Task 4: Sales Order Archive Integration + +**Files:** +- Modify: `backend/app/services/sales_planning.py` +- Modify: `backend/app/api/routes/sales.py` +- Test: `backend/tests/test_sales_order_document_archive.py` + +- [ ] **Step 1: Write the failing sales integration test** + +Create `backend/tests/test_sales_order_document_archive.py`: + +```python +from __future__ import annotations + +import unittest +from datetime import date, datetime +from unittest.mock import patch + +from sqlalchemy import BigInteger, create_engine +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.document_archive # noqa: E402,F401 +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.api.routes.sales import create_sales_order, list_sales_orders # noqa: E402 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Item # noqa: E402 +from app.models.sales import Customer # noqa: E402 +from app.schemas.database import SalesOrderCreate, SalesOrderItemCreate # noqa: E402 + + +class SalesOrderDocumentArchiveTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db = self.SessionLocal() + now = datetime(2026, 6, 11, 10, 0, 0) + self.db.add(Customer(id=1, customer_code="CUS001", customer_name="测试客户", short_name="客户", status="ACTIVE", created_at=now, updated_at=now)) + self.db.add(Item(id=10, item_code="CP00010", item_name="钢制碗", item_type="FINISHED", unit_id=None, status="ACTIVE", created_at=now, updated_at=now)) + self.db.commit() + + def tearDown(self) -> None: + self.db.close() + + def test_create_sales_order_returns_archive_status(self) -> None: + payload = SalesOrderCreate( + customer_id=1, + sales_employee_id=None, + promised_date=date(2026, 6, 20), + delivery_address="测试地址", + tax_rate=0.13, + remark="测试备注", + items=[SalesOrderItemCreate(product_item_id=10, order_qty=10, unit_price=5, promised_date=date(2026, 6, 20), customer_part_no="KH-01")], + ) + + with patch("app.api.routes.sales.generate_document_archive") as mocked: + mocked.return_value.archive_status = "已归档" + mocked.return_value.archive_version = 1 + mocked.return_value.archive_error_message = None + result = create_sales_order(payload, self.db) + + self.assertEqual(result.archive_status, "已归档") + self.assertEqual(result.archive_version, 1) + mocked.assert_called_once() + + def test_list_sales_orders_exposes_archive_fields(self) -> None: + rows = list_sales_orders(db=self.db) + + self.assertEqual(rows, []) +``` + +- [ ] **Step 2: Run the sales integration test to verify it fails** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +PYTHONPATH=backend pytest backend/tests/test_sales_order_document_archive.py -q +``` + +Expected: FAIL because `generate_document_archive` is not imported in `sales.py`, and list query does not expose archive metadata yet. + +- [ ] **Step 3: Enrich sales order query with latest archive metadata** + +Modify `backend/app/services/sales_planning.py`. + +Import: + +```python +from app.models.document_archive import DocumentArchive +``` + +Inside `get_sales_order_query()`, add this subquery after the existing subqueries: + +```python + latest_archive_version_subquery = ( + select( + DocumentArchive.business_id.label("sales_order_id"), + func.max(DocumentArchive.archive_version).label("archive_version"), + ) + .where(DocumentArchive.document_type == "销售订单") + .group_by(DocumentArchive.business_id) + .subquery() + ) + latest_archive_subquery = ( + select( + DocumentArchive.business_id.label("sales_order_id"), + DocumentArchive.archive_version.label("archive_version"), + DocumentArchive.status.label("archive_status"), + DocumentArchive.created_at.label("archive_created_at"), + DocumentArchive.error_message.label("archive_error_message"), + ) + .join( + latest_archive_version_subquery, + (latest_archive_version_subquery.c.sales_order_id == DocumentArchive.business_id) + & (latest_archive_version_subquery.c.archive_version == DocumentArchive.archive_version), + ) + .where(DocumentArchive.document_type == "销售订单") + .subquery() + ) +``` + +Add selected columns: + +```python + func.coalesce(latest_archive_subquery.c.archive_status, "未生成").label("archive_status"), + latest_archive_subquery.c.archive_version.label("archive_version"), + latest_archive_subquery.c.archive_created_at.label("archive_created_at"), + latest_archive_subquery.c.archive_error_message.label("archive_error_message"), +``` + +Add outer join: + +```python + .outerjoin(latest_archive_subquery, latest_archive_subquery.c.sales_order_id == SalesOrder.id) +``` + +- [ ] **Step 4: Generate archive after creating a sales order** + +Modify `backend/app/api/routes/sales.py`. + +Import: + +```python +from app.services.document_archives import DOCUMENT_TYPE_SALES_ORDER, generate_document_archive +``` + +In `create_sales_order()`, after `db.commit()` and before returning, add: + +```python + archive_result = generate_document_archive(db, DOCUMENT_TYPE_SALES_ORDER, int(order.id)) +``` + +Update the return: + +```python + return SalesOrderCreateResult( + order_id=order.id, + order_no=order.order_no, + total_amount=float(total_amount), + item_count=len(payload.items), + status=order.status, + archive_status=archive_result.archive_status, + archive_version=archive_result.archive_version, + archive_error_message=archive_result.archive_error_message, + ) +``` + +The service catches PDF render failures and writes `归档失败`, so this route does not need to swallow archive exceptions except for unexpected `HTTPException`. + +- [ ] **Step 5: Run the sales integration test to verify it passes** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +PYTHONPATH=backend pytest backend/tests/test_sales_order_document_archive.py backend/tests/test_sales_order_no_generation.py -q +``` + +Expected: PASS. + +--- + +### Task 5: Purchase Order Archive Integration + +**Files:** +- Modify: `backend/app/services/operations.py` +- Modify: `backend/app/api/routes/purchase.py` +- Test: `backend/tests/test_purchase_order_document_archive.py` + +- [ ] **Step 1: Write the failing purchase integration test** + +Create `backend/tests/test_purchase_order_document_archive.py`: + +```python +from __future__ import annotations + +import unittest +from datetime import date, datetime +from unittest.mock import patch + +from sqlalchemy import BigInteger, create_engine +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import sessionmaker + + +@compiles(BigInteger, "sqlite") +def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: + _ = type_, compiler, kw + return "INTEGER" + + +import app.models.document_archive # noqa: E402,F401 +import app.models.master_data # noqa: E402,F401 +import app.models.miniapp # noqa: E402,F401 +import app.models.operations # noqa: E402,F401 +import app.models.org # noqa: E402,F401 +import app.models.planning # noqa: E402,F401 +import app.models.sales # noqa: E402,F401 +from app.api.routes.purchase import create_purchase_order # noqa: E402 +from app.models.base import Base # noqa: E402 +from app.models.master_data import Item, Material # noqa: E402 +from app.models.operations import Supplier # noqa: E402 +from app.schemas.operations import PurchaseOrderCreate, PurchaseOrderItemCreate # noqa: E402 + + +class PurchaseOrderDocumentArchiveTest(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + self.db = self.SessionLocal() + now = datetime(2026, 6, 11, 10, 0, 0) + self.db.add(Supplier(id=1, supplier_code="SUP001", supplier_name="测试供应商", short_name="供应商", lead_time_days=0, default_tax_rate=0.13, status="ACTIVE", created_at=now, updated_at=now)) + self.db.add(Item(id=10, item_code="YL00010", item_name="冷轧钢", item_type="RAW_MATERIAL", unit_id=None, status="ACTIVE", created_at=now, updated_at=now)) + self.db.add(Material(id=1, item_id=10, material_grade="Q235", specification="1.0mm", density=7.85, purchase_calc_mode="WEIGHT", default_supplier_id=1, status="ACTIVE", created_at=now, updated_at=now)) + self.db.commit() + + def tearDown(self) -> None: + self.db.close() + + def test_create_purchase_order_returns_archive_status(self) -> None: + payload = PurchaseOrderCreate( + supplier_id=1, + order_date=date(2026, 6, 11), + expected_date=date(2026, 6, 18), + warning_lead_days=3, + purchaser_employee_id=None, + target_warehouse_type="RAW", + sales_order_ids=[], + tax_rate=0.13, + remark="采购备注", + items=[PurchaseOrderItemCreate(material_item_id=10, order_weight_kg=100, unit_price=4.5, remark="性能要求")], + ) + + with patch("app.api.routes.purchase.generate_document_archive") as mocked: + mocked.return_value.archive_status = "已归档" + mocked.return_value.archive_version = 1 + mocked.return_value.archive_error_message = None + result = create_purchase_order(payload, self.db) + + self.assertEqual(result.archive_status, "已归档") + self.assertEqual(result.archive_version, 1) + mocked.assert_called_once() +``` + +- [ ] **Step 2: Run the purchase integration test to verify it fails** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +PYTHONPATH=backend pytest backend/tests/test_purchase_order_document_archive.py -q +``` + +Expected: FAIL because purchase route does not generate archive and purchase query does not include archive metadata. + +- [ ] **Step 3: Enrich purchase order query with latest archive metadata** + +Modify `backend/app/services/operations.py`. + +Import: + +```python +from app.models.document_archive import DocumentArchive +``` + +Inside `get_purchase_order_query()`, add latest archive subqueries using `PurchaseOrder.id` as `business_id` and `document_type == "采购订单"`. + +Use this pattern: + +```python + latest_archive_version_subquery = ( + select( + DocumentArchive.business_id.label("purchase_order_id"), + func.max(DocumentArchive.archive_version).label("archive_version"), + ) + .where(DocumentArchive.document_type == "采购订单") + .group_by(DocumentArchive.business_id) + .subquery() + ) + latest_archive_subquery = ( + select( + DocumentArchive.business_id.label("purchase_order_id"), + DocumentArchive.archive_version.label("archive_version"), + DocumentArchive.status.label("archive_status"), + DocumentArchive.created_at.label("archive_created_at"), + DocumentArchive.error_message.label("archive_error_message"), + ) + .join( + latest_archive_version_subquery, + (latest_archive_version_subquery.c.purchase_order_id == DocumentArchive.business_id) + & (latest_archive_version_subquery.c.archive_version == DocumentArchive.archive_version), + ) + .where(DocumentArchive.document_type == "采购订单") + .subquery() + ) +``` + +Add selected columns: + +```python + func.coalesce(latest_archive_subquery.c.archive_status, "未生成").label("archive_status"), + latest_archive_subquery.c.archive_version.label("archive_version"), + latest_archive_subquery.c.archive_created_at.label("archive_created_at"), + latest_archive_subquery.c.archive_error_message.label("archive_error_message"), +``` + +Add outer join: + +```python + .outerjoin(latest_archive_subquery, latest_archive_subquery.c.purchase_order_id == PurchaseOrder.id) +``` + +- [ ] **Step 4: Generate archive after creating a purchase order** + +Modify `backend/app/api/routes/purchase.py`. + +Import: + +```python +from app.services.document_archives import DOCUMENT_TYPE_PURCHASE_ORDER, generate_document_archive +``` + +In `create_purchase_order()`, after `db.commit()`, add: + +```python + archive_result = generate_document_archive(db, DOCUMENT_TYPE_PURCHASE_ORDER, int(order.id)) +``` + +Then read the purchase order and attach archive fields: + +```python + result = _read_purchase_order(db, order.id) + return result.model_copy( + update={ + "archive_status": archive_result.archive_status, + "archive_version": archive_result.archive_version, + "archive_error_message": archive_result.archive_error_message, + } + ) +``` + +- [ ] **Step 5: Generate a new archive version after editable purchase update** + +In `update_purchase_order()`, after the existing `db.commit()`, add: + +```python + archive_result = generate_document_archive(db, DOCUMENT_TYPE_PURCHASE_ORDER, int(order.id)) + result = _read_purchase_order(db, order.id) + return result.model_copy( + update={ + "archive_status": archive_result.archive_status, + "archive_version": archive_result.archive_version, + "archive_error_message": archive_result.archive_error_message, + } + ) +``` + +Remove the old direct `return _read_purchase_order(db, order.id)` at the end of `update_purchase_order()`. + +- [ ] **Step 6: Run the purchase integration test to verify it passes** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP +PYTHONPATH=backend pytest backend/tests/test_purchase_order_document_archive.py backend/tests/test_purchase_order_no_generation.py backend/tests/test_purchase_order_multi_sales_links.py -q +``` + +Expected: PASS. + +--- + +### Task 6: Frontend Document Paper Components + +**Files:** +- Create: `frontend/src/components/documentForms/DocumentPaper.vue` +- Create: `frontend/src/components/documentForms/DocumentGrid.vue` +- Create: `frontend/src/components/documentForms/DocumentLineTable.vue` +- Create: `frontend/src/components/documentForms/DocumentArchiveActions.vue` +- Modify: `frontend/src/styles/main.css` +- Create: `frontend/scripts/test-document-form-components.mjs` + +- [ ] **Step 1: Write the failing static component test** + +Create `frontend/scripts/test-document-form-components.mjs`: + +```javascript +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const root = process.cwd(); +const files = [ + "src/components/documentForms/DocumentPaper.vue", + "src/components/documentForms/DocumentGrid.vue", + "src/components/documentForms/DocumentLineTable.vue", + "src/components/documentForms/DocumentArchiveActions.vue" +]; + +for (const file of files) { + const source = readFileSync(resolve(root, file), "utf8"); + assert.ok(source.length > 200, `${file} should contain a real component`); +} + +const paper = readFileSync(resolve(root, "src/components/documentForms/DocumentPaper.vue"), "utf8"); +assert.match(paper, /ERP留存联/); +assert.match(paper, /document-paper-clip/); +assert.match(paper, /document-paper-binder-hole/); + +const actions = readFileSync(resolve(root, "src/components/documentForms/DocumentArchiveActions.vue"), "utf8"); +assert.match(actions, /预览PDF/); +assert.match(actions, /下载PDF/); +assert.match(actions, /重新生成/); + +const css = readFileSync(resolve(root, "src/styles/main.css"), "utf8"); +assert.match(css, /\.document-paper-shell/); +assert.match(css, /\.document-paper-sheet/); +assert.match(css, /\.document-line-table/); + +console.log("document form component static tests passed"); +``` + +- [ ] **Step 2: Run the static component test to verify it fails** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node scripts/test-document-form-components.mjs +``` + +Expected: FAIL with missing component file. + +- [ ] **Step 3: Create `DocumentPaper.vue`** + +Create `frontend/src/components/documentForms/DocumentPaper.vue`: + +```vue + + + +``` + +- [ ] **Step 4: Create `DocumentGrid.vue`** + +Create `frontend/src/components/documentForms/DocumentGrid.vue`: + +```vue + + + +``` + +- [ ] **Step 5: Create `DocumentLineTable.vue`** + +Create `frontend/src/components/documentForms/DocumentLineTable.vue`: + +```vue + + + +``` + +- [ ] **Step 6: Create `DocumentArchiveActions.vue`** + +Create `frontend/src/components/documentForms/DocumentArchiveActions.vue`: + +```vue + + + +``` + +- [ ] **Step 7: Add document paper CSS** + +Append to `frontend/src/styles/main.css`: + +```css +/* Document-form paper system */ +.document-paper-shell { + position: relative; + max-width: 1060px; + margin: 0 auto; + padding: 34px 22px 22px; +} + +.document-paper-stack { + position: absolute; + inset: 42px 34px 18px; + border: 1px solid rgba(60, 44, 28, 0.18); + background: #efe3c9; + box-shadow: 0 20px 44px rgba(72, 48, 24, 0.16); +} + +.document-paper-stack-back { + transform: rotate(-1.4deg) translateY(8px); +} + +.document-paper-stack-mid { + transform: rotate(0.8deg) translateY(3px); + background: #f6ecd7; +} + +.document-paper-sheet { + position: relative; + min-height: 720px; + padding: 54px 42px 36px; + border: 1px solid rgba(34, 24, 12, 0.42); + background: + linear-gradient(90deg, rgba(80, 54, 25, 0.035) 1px, transparent 1px) 0 0 / 18px 18px, + linear-gradient(rgba(80, 54, 25, 0.03) 1px, transparent 1px) 0 0 / 18px 18px, + #fbf3df; + box-shadow: 0 28px 78px rgba(71, 49, 23, 0.22); +} + +.document-paper-clip { + position: absolute; + top: -22px; + left: 50%; + width: 154px; + height: 42px; + transform: translateX(-50%); + border-radius: 14px 14px 8px 8px; + background: linear-gradient(180deg, #d9e0e6, #7c8791 48%, #cbd3da); + box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.65), 0 12px 20px rgba(30, 41, 59, 0.22); +} + +.document-paper-binder { + position: absolute; + top: 26px; + left: 18px; + display: grid; + gap: 42px; +} + +.document-paper-binder-hole { + width: 12px; + height: 12px; + border-radius: 999px; + background: #d8c59e; + box-shadow: inset 0 2px 5px rgba(56, 39, 18, 0.28); +} + +.document-retained-mark { + position: absolute; + right: 14px; + top: 180px; + writing-mode: vertical-rl; + letter-spacing: 6px; + color: rgba(127, 29, 29, 0.68); + font-weight: 900; +} + +.document-paper-head { + position: relative; + display: grid; + justify-items: center; + gap: 6px; + margin-bottom: 26px; + color: #17120b; +} + +.document-company { + margin: 0; + font-size: 13px; + letter-spacing: 0.3em; +} + +.document-paper-head h2 { + margin: 0; + font-size: 34px; + letter-spacing: 0.18em; +} + +.document-no-stamp { + position: absolute; + right: 0; + top: 4px; + display: grid; + gap: 2px; + min-width: 156px; + padding: 8px 12px; + border: 2px solid #991b1b; + color: #991b1b; + transform: rotate(-1.5deg); +} + +.document-paper-green .document-no-stamp { + border-color: #166534; + color: #166534; +} + +.document-grid { + display: grid; + grid-template-columns: repeat(var(--document-grid-columns), minmax(0, 1fr)); + border-top: 1px solid #2c2418; + border-left: 1px solid #2c2418; +} + +.document-grid-cell { + min-height: 58px; + padding: 8px 10px; + border-right: 1px solid #2c2418; + border-bottom: 1px solid #2c2418; + background: rgba(255, 255, 255, 0.16); +} + +.document-grid-cell-wide { + grid-column: span 2; +} + +.document-grid-cell span, +.document-line-table th { + color: rgba(23, 18, 11, 0.72); + font-size: 12px; + font-weight: 800; +} + +.document-grid-cell input, +.document-grid-cell select, +.document-grid-cell textarea, +.document-line-table input, +.document-line-table select { + width: 100%; + min-height: 32px; + border: 0; + border-bottom: 1px solid rgba(44, 36, 24, 0.42); + border-radius: 0; + background: transparent; + color: #17120b; +} + +.document-line-block { + margin-top: 24px; +} + +.document-line-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.document-line-table-wrap { + overflow-x: auto; + border: 1px solid #2c2418; +} + +.document-line-table { + width: 100%; + min-width: 920px; + border-collapse: collapse; +} + +.document-line-table th, +.document-line-table td { + border: 1px solid #2c2418; + padding: 8px; + text-align: left; + vertical-align: top; +} + +.document-signature-row { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-top: 24px; + border-top: 1px solid #2c2418; + border-left: 1px solid #2c2418; +} + +.document-signature-cell { + min-height: 58px; + padding: 8px 10px; + border-right: 1px solid #2c2418; + border-bottom: 1px solid #2c2418; +} + +.document-archive-actions { + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.document-archive-status { + display: inline-flex; + align-items: center; + min-height: 30px; + padding: 0 10px; + border-radius: 999px; + font-weight: 900; +} + +.document-archive-status-ready { + color: #166534; + background: #dcfce7; +} + +.document-archive-status-failed { + color: #991b1b; + background: #fee2e2; +} + +.document-archive-status-missing { + color: #92400e; + background: #fef3c7; +} + +@media (max-width: 860px) { + .document-paper-shell { + padding: 26px 0 12px; + } + + .document-paper-sheet { + padding: 48px 18px 26px; + } + + .document-paper-head h2 { + font-size: 26px; + } + + .document-no-stamp { + position: static; + margin-top: 8px; + } + + .document-grid { + grid-template-columns: 1fr; + } + + .document-grid-cell-wide { + grid-column: auto; + } + + .document-signature-row { + grid-template-columns: 1fr 1fr; + } +} +``` + +- [ ] **Step 8: Run the static component test to verify it passes** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node scripts/test-document-form-components.mjs +``` + +Expected: PASS. + +--- + +### Task 7: Sales Order Document UI And Archive Actions + +**Files:** +- Modify: `frontend/src/services/api.js` +- Modify: `frontend/src/views/SalesPlanningView.vue` +- Create: `frontend/scripts/test-sales-purchase-document-archive-ui.mjs` + +- [ ] **Step 1: Write the failing sales UI static test** + +Create `frontend/scripts/test-sales-purchase-document-archive-ui.mjs`: + +```javascript +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const sales = readFileSync(resolve(process.cwd(), "src/views/SalesPlanningView.vue"), "utf8"); +const purchase = readFileSync(resolve(process.cwd(), "src/views/PurchaseOrderView.vue"), "utf8"); +const api = readFileSync(resolve(process.cwd(), "src/services/api.js"), "utf8"); + +for (const source of [sales, purchase]) { + assert.match(source, /DocumentPaper/); + assert.match(source, /DocumentGrid/); + assert.match(source, /DocumentLineTable/); + assert.match(source, /DocumentArchiveActions/); + assert.match(source, /archive_status/); +} + +assert.match(sales, /title="销售订单"/); +assert.match(sales, /signature-labels="\['销售确认', '客户确认', '财务复核', '制单人'\]"/); +assert.match(sales, /previewDocumentArchive\('sales-order'/); +assert.match(sales, /downloadDocumentArchive\('sales-order'/); +assert.match(sales, /regenerateDocumentArchive\('sales-order'/); + +assert.match(api, /postDownloadResource/); + +console.log("sales and purchase document archive UI static tests passed"); +``` + +This test is expected to fail until both sales and purchase views are migrated. Task 7 makes the sales-related assertions pass; Task 8 makes the full script pass. + +- [ ] **Step 2: Add batch download helper** + +Modify `frontend/src/services/api.js` and add after `downloadResource()`: + +```javascript +export async function postDownloadResource(path, payload, fallbackFilename = "download.zip") { + const session = readSession(); + const authHeaders = + session?.access_token && !isSessionExpired(session) + ? { Authorization: `Bearer ${session.access_token}` } + : {}; + const response = await fetch(`${API_BASE_URL}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...authHeaders + }, + body: JSON.stringify(payload) + }); + + if ((response.status === 401 || response.status === 403) && typeof window !== "undefined" && window.location.pathname !== "/login") { + clearSession(); + window.location.href = "/login"; + } + + if (!response.ok) { + return parseResponse(response); + } + + const blob = await response.blob(); + const filename = getDownloadFilename(response, fallbackFilename); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + return { filename }; +} +``` + +- [ ] **Step 3: Import document components and archive helpers in sales view** + +Modify `frontend/src/views/SalesPlanningView.vue`. + +Add imports: + +```javascript +import DocumentArchiveActions from "../components/documentForms/DocumentArchiveActions.vue"; +import DocumentGrid from "../components/documentForms/DocumentGrid.vue"; +import DocumentLineTable from "../components/documentForms/DocumentLineTable.vue"; +import DocumentPaper from "../components/documentForms/DocumentPaper.vue"; +import { downloadResource, openResource, postDownloadResource, postResource, putResource } from "../services/api"; +``` + +If the file already imports these API functions separately, merge them into one import without duplicates. + +- [ ] **Step 4: Add sales document computed fields** + +Inside ` + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..e75afc8 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1229 @@ +{ + "name": "forgeflow-erp-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "forgeflow-erp-frontend", + "version": "0.1.0", + "dependencies": { + "vue": "^3.5.0", + "vue-router": "^4.4.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.1.0", + "vite": "^5.4.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "vue": "3.5.32" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b546e58 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,20 @@ +{ + "name": "forgeflow-erp-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5.0", + "vue-router": "^4.4.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.1.0", + "vite": "^5.4.0" + } +} + diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..44165ad --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/jiaheng-office-bg.jpg b/frontend/public/jiaheng-office-bg.jpg new file mode 100644 index 0000000..199f127 Binary files /dev/null and b/frontend/public/jiaheng-office-bg.jpg differ diff --git a/frontend/public/logo.png b/frontend/public/logo.png new file mode 100644 index 0000000..310868b Binary files /dev/null and b/frontend/public/logo.png differ diff --git a/frontend/public/logo.svg b/frontend/public/logo.svg new file mode 100644 index 0000000..eb1c284 --- /dev/null +++ b/frontend/public/logo.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/scripts/test-auxiliary-quantity-flow.mjs b/frontend/scripts/test-auxiliary-quantity-flow.mjs new file mode 100644 index 0000000..a8f418a --- /dev/null +++ b/frontend/scripts/test-auxiliary-quantity-flow.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const inventoryLedger = readFileSync(new URL("../src/views/InventoryLedgerView.vue", import.meta.url), "utf8"); +const purchaseOrder = readFileSync(new URL("../src/views/PurchaseOrderView.vue", import.meta.url), "utf8"); +const purchaseReceipt = readFileSync(new URL("../src/views/PurchaseReceiptView.vue", import.meta.url), "utf8"); + +assert.match(inventoryLedger, /const activeWeightOnly = computed\(\(\) => \["raw", "scrap"\]\.includes\(activeInventoryTab\.value\)\)/); +assert.match(inventoryLedger, /const selectedWeightOnly = computed\(\s*\(\) => \["RAW", "SCRAP"\]\.includes\(selectedWarehouseType\.value\)\s*\)/); +assert.match(inventoryLedger, /const activeQuantityOnly = computed\(\(\) => activeInventoryTab\.value === "auxiliary"\)/); +assert.match(inventoryLedger, /const selectedQuantityOnly = computed\(\(\) => selectedWarehouseType\.value === "AUX"\)/); +assert.match(inventoryLedger, /normalizeWarehouseType\(lot\) === "AUX"[\s\S]*return lotAvailableQty\(lot\) > 0/); +assert.match(inventoryLedger, /
生产工单产品原材料来源库存批次号计划生产数领料重量已用领料重量归还原料重量已入库成品状态 + +{{ workOrder.product_name }}{{ workOrder.material_summary || "-" }}{{ workOrder.source_lot_summary || "-" }}{{ formatQty(workOrder.planned_qty) }}{{ formatWeight(workOrder.issued_weight_kg) }}{{ formatWeight(workOrder.used_material_weight_kg) }}{{ formatWeight(workOrder.returned_material_weight_kg) }}{{ formatQty(workOrder.finished_received_qty) }}{{ formatStatusLabel(workOrder.status) }}销售人员{{ order.sales_employee_name || "-" }}{{ row.status }} + {{ column.compactLabel || column.label }} +单据留档 + +单据留档<\/th>/); + assert.match(source, / { + assert.match(source, /lastArchiveAction/); + assert.match(source, /buildProductionInboundArchiveAction/); + assert.match(source, /预览结算单/); + assert.match(source, /下载PDF/); + }); +}); +``` + +- [ ] **Step 2: Run the static test and verify it fails** + +Run: + +```bash +cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend +node --test src/views/InventoryLedgerView.test.js +``` + +Expected: fail because archive entrypoint code has not been added yet. + +--- + +### Task 4: Add Warehouse Ledger Archive Actions And Post-Save Shortcut + +**Files:** +- Modify: `frontend/src/views/InventoryLedgerView.vue` +- Modify: `frontend/src/styles/main.css` + +- [ ] **Step 1: Import archive helpers** + +In `frontend/src/views/InventoryLedgerView.vue`, add the component import: + +```javascript +import DocumentArchiveActions from "../components/documentForms/DocumentArchiveActions.vue"; +``` + +Update the API import to include download/open helpers: + +```javascript +import { downloadResource, fetchResource, openResource, postResource, requestJson, uploadResource } from "../services/api"; +``` + +- [ ] **Step 2: Add latest archive shortcut state** + +Near existing refs, add: + +```javascript +const lastArchiveAction = ref(null); +``` + +Add this helper near `formatArchiveFeedback`: + +```javascript +function buildProductionInboundArchiveAction(result) { + if (!result?.archive_business_id) { + return null; + } + return { + typeKey: "production-inbound-settlement", + businessId: Number(result.archive_business_id), + status: result.archive_status || "未生成", + label: "生产入库结算单", + filename: `${result.material_lot_no || "生产入库结算单"}归档.pdf` + }; +} +``` + +Clear stale shortcut in `resetFeedback`: + +```javascript +function resetFeedback() { + feedbackMessage.value = ""; + errorMessage.value = ""; + lastArchiveAction.value = null; +} +``` + +- [ ] **Step 3: Render post-save shortcut near feedback banners** + +In the main page area, after the existing `feedbackMessage` banner if present, render: + +```vue +
+ {{ lastArchiveAction.label }} + + +
+``` + +Also render the same panel inside the `productionWorkOrderInboundDrawerOpen` form under its `feedbackMessage` banner, so the shortcut works if a future change keeps the drawer open after save. + +- [ ] **Step 4: Add `单据留档` column to warehouse流水** + +In the warehouse ledger drawer table header, add a column before `备注`: + +```vue +
单据留档 + + - +现存重量\(kg\)<\/th>/); +assert.match(inventoryLedger, /[\s\S]*formatWeight\(balance\.weight_on_hand_kg\)/); +assert.match(inventoryLedger, /变动重量\(kg\)<\/th>/); +assert.match(inventoryLedger, /selectedQuantityOnly\.value[\s\S]*return "可用数量"/); +assert.match(inventoryLedger, /isSelectedFinishedWarehouse\.value \|\| selectedQuantityOnly\.value[\s\S]*key: "inbound_qty"/); +assert.match(inventoryLedger, /!\s*selectedQuantityOnly\.value \? \[\s*\{ key: "inbound_weight_kg"/); +assert.match(inventoryLedger, /activeWarehouseType\.value === "AUX"\s*\?\s*Number\(genericForm\.qty \|\| 0\)/); +assert.match(inventoryLedger, /辅料生产出库必须填写用途/); +assert.match(inventoryLedger, /activeWarehouseType === "AUX" \? "单价\(元\/件\)" : "单价\(元\/kg\)"/); +assert.match(inventoryLedger, /单位<\/th>/); +assert.match(inventoryLedger, /\{\{ formatInventoryUnitLabel\(balance\) \}\}<\/td>/); +assert.match(inventoryLedger, /searchFields: \["item_code", "item_name", "specification", "stock_unit_name", "purchase_unit_name", "sales_unit_name", "warehouse_name", "location_name", "operation_label", "source_material_summary"\]/); +assert.match(inventoryLedger, /const specificationLabel = balance\.specification \? ` · \$\{balance\.specification\}` : ""/); +assert.match(inventoryLedger, /const baseLabel = `\$\{balance\.item_code\} · \$\{balance\.item_name\}\$\{specificationLabel\}`/); +assert.match(inventoryLedger, /function formatInventoryUnitLabel\(item\)/); +assert.match(inventoryLedger, /计量单位:\(\[\^;;\]\+\)/); + +assert.match(purchaseOrder, /v-model\.number="item\.order_qty"/); +assert.match(purchaseOrder, /order_qty: form\.target_warehouse_type === "AUX" \? Number\(item\.order_qty \|\| 0\) : 0/); +assert.match(purchaseOrder, /order_weight_kg: form\.target_warehouse_type === "AUX" \? 0 : Number\(item\.order_weight_kg \|\| 0\)/); +assert.match(purchaseOrder, /formatOrderTotal\(order\)/); +assert.match(purchaseOrder, /formatPurchaseUnitPriceDisplay\(row\)/); + +assert.match(purchaseReceipt, /const selectedPurchaseTargetType = computed/); +assert.match(purchaseReceipt, /v-model\.number="item\.received_qty"/); +assert.match(purchaseReceipt, /received_qty: selectedPurchaseTargetType\.value === "AUX" \? Number\(item\.received_qty \|\| 0\) : 0/); +assert.match(purchaseReceipt, /received_weight_kg: selectedPurchaseTargetType\.value === "AUX" \? 0 : Number\(item\.received_weight_kg \|\| 0\)/); +assert.match(purchaseReceipt, /formatReceiptItemReceived\(row\)/); + +console.log("辅料库数量口径前端静态检查通过"); diff --git a/frontend/scripts/test-dashboard-lifecycle-dag-zoom.mjs b/frontend/scripts/test-dashboard-lifecycle-dag-zoom.mjs new file mode 100644 index 0000000..871fb0a --- /dev/null +++ b/frontend/scripts/test-dashboard-lifecycle-dag-zoom.mjs @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const dashboardSource = readFileSync(new URL("../src/views/DashboardView.vue", import.meta.url), "utf8"); + +assert.match(dashboardSource, /原材料生命周期 DAG/, "经营总览应保留原材料生命周期 DAG"); +assert.match(dashboardSource, /拖动画布 · 点击节点看详情/, "DAG 操作提示不应再提滚轮缩放"); +assert.match(dashboardSource, /title="放大"[\s\S]*@click="zoomCanvasBy\(0\.08\)"/, "DAG 右上角应提供放大按钮"); +assert.match(dashboardSource, /title="缩小"[\s\S]*@click="zoomCanvasBy\(-0\.08\)"/, "DAG 右上角应提供缩小按钮"); +assert.match(dashboardSource, /function zoomCanvasBy\(delta\)/, "DAG 应通过按钮函数调整缩放比例"); + +assert.doesNotMatch(dashboardSource, /@wheel/, "DAG 视图不应绑定鼠标滚轮缩放"); +assert.doesNotMatch(dashboardSource, /function zoomCanvas\(event\)/, "DAG 不应保留鼠标滚轮缩放函数"); +assert.doesNotMatch(dashboardSource, /deltaY/, "DAG 缩放逻辑不应依赖鼠标滚轮 deltaY"); + +console.log("dashboard lifecycle DAG zoom checks passed"); diff --git a/frontend/scripts/test-delivery-entry-unification.mjs b/frontend/scripts/test-delivery-entry-unification.mjs new file mode 100644 index 0000000..ce53d5c --- /dev/null +++ b/frontend/scripts/test-delivery-entry-unification.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +const root = process.cwd(); + +function readSource(relativePath) { + return readFileSync(path.join(root, relativePath), "utf8"); +} + +const inventory = readSource("src/views/InventoryLedgerView.vue"); +const delivery = readSource("src/views/DeliveryManagementView.vue"); +const app = readSource("src/App.vue"); +const router = readSource("src/router/index.js"); +const deliveryWorkspace = readSource("src/views/DeliveryWorkspaceView.vue"); + +assert.match(inventory, /delivery_mode:\s*"SALES_ORDER"/, "sales-out form should default to sales-order delivery mode"); +assert.match(inventory, /直接客户发货/, "warehouse sales-out drawer should support direct customer delivery"); +assert.match(inventory, /销售订单发货/, "warehouse sales-out drawer should keep sales-order delivery mode"); +assert.match(inventory, /isSalesOrderDelivery/, "sales-out drawer should centralize mode-dependent behavior"); +assert.match(inventory, /setDeliveryMode\("DIRECT_CUSTOMER"\)/, "direct customer mode should be selectable"); +assert.match(inventory, /deliveryForm\.delivery_mode === "SALES_ORDER"/, "sales order fields should be conditional"); +assert.match(inventory, /source_material_summary/, "pre-submit allocation rows should expose source material summary"); +assert.match(inventory, />来源材料摘要成品库存批次号来源原材料库存批次号销售订单号/, + "delivery ledger should build sales-order-level aggregate rows" +); + +assert.match( + source, + /v-for="group in paginatedDeliveryGroups"/, + "main delivery ledger table should render aggregated sales-order groups" +); + +assert.doesNotMatch( + source, + /v-for="delivery in paginatedDeliveries"/, + "main delivery ledger table should not render one row per delivery order" +); + +assert.match( + source, + /function buildDeliveryLedgerRows\(/, + "delivery ledger should have a dedicated aggregation helper" +); + +assert.match( + source, + /发货单流水/, + "sales-order detail drawer should show delivery order flow" +); + +assert.match( + source, + /发货明细流水/, + "sales-order detail drawer should show delivery item flow" +); + +assert.match( + source, + /const activeDeliveryGroup = computed\(\(\) =>/, + "detail drawer should be driven by the selected sales-order group" +); + +console.log("delivery ledger order aggregation checks passed"); diff --git a/frontend/scripts/test-delivery-overview-rename.mjs b/frontend/scripts/test-delivery-overview-rename.mjs new file mode 100644 index 0000000..77fbd04 --- /dev/null +++ b/frontend/scripts/test-delivery-overview-rename.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +const appSource = fs.readFileSync(path.resolve("src/App.vue"), "utf8"); +const routerSource = fs.readFileSync(path.resolve("src/router/index.js"), "utf8"); +const deliveryWorkspaceSource = fs.readFileSync(path.resolve("src/views/DeliveryWorkspaceView.vue"), "utf8"); +const scrapReworkSource = fs.readFileSync(path.resolve("src/views/ScrapReworkView.vue"), "utf8"); + +assert.match(appSource, /label:\s*"发货概览"/, "left menu module label should be renamed to 发货概览"); +assert.match(routerSource, /title:\s*"发货概览"/, "route title should be renamed to 发货概览"); +assert.match(deliveryWorkspaceSource, /eyebrow="发货概览"/, "delivery workspace eyebrow should use 发货概览"); +assert.match(deliveryWorkspaceSource, /发货概览模块/, "delivery workspace description should use 发货概览"); +assert.match(scrapReworkSource, /发货概览模块/, "scrap/rework guidance should use the renamed module"); + +for (const [file, source] of [ + ["src/App.vue", appSource], + ["src/router/index.js", routerSource], + ["src/views/DeliveryWorkspaceView.vue", deliveryWorkspaceSource], + ["src/views/ScrapReworkView.vue", scrapReworkSource] +]) { + assert.doesNotMatch(source, /发货与售后/, `${file} should not keep the old delivery module name`); +} + +console.log("delivery overview rename checks passed"); diff --git a/frontend/scripts/test-dictionary-localization.mjs b/frontend/scripts/test-dictionary-localization.mjs new file mode 100644 index 0000000..f4bbf75 --- /dev/null +++ b/frontend/scripts/test-dictionary-localization.mjs @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import * as dictionaries from "../src/utils/dictionaries.js"; + +const root = process.cwd(); + +function readSource(relativePath) { + return readFileSync(path.join(root, relativePath), "utf8"); +} + +function assertFormatter(name, samples) { + assert.equal(typeof dictionaries[name], "function", `${name} should be exported`); + samples.forEach(([value, expected]) => { + assert.equal(dictionaries[name](value), expected, `${name} should translate ${value}`); + }); +} + +function assertNoRawEnumFallback(name) { + const rawCode = "UNTRANSLATED_SAMPLE_ENUM"; + assert.notEqual(dictionaries[name](rawCode), rawCode, `${name} must not render raw English enum fallbacks`); + assert.match(dictionaries[name](rawCode), /未识别/, `${name} fallback should be Chinese`); +} + +assertFormatter("formatInventoryTxnTypeLabel", [ + ["OPENING_IN", "期初入库"], + ["PRODUCTION_SURPLUS_IN", "生产余料入库"], + ["OUTSOURCING_SURPLUS_IN", "委外余料入库"], + ["WIP_IN", "产中入库"], + ["WIP_OUT", "产中出库"], + ["PRODUCTION_FINISHED_IN", "生产入库"], + ["STOCKTAKE_GAIN", "盘库盘盈"], + ["STOCKTAKE_LOSS", "盘库盘亏"], + ["STOCKTAKE_NEW_LOT", "盘库新增批次"] +]); +assertNoRawEnumFallback("formatInventoryTxnTypeLabel"); + +assertFormatter("formatSourceDocTypeLabel", [ + ["OPENING_STOCK", "期初入库"], + ["STOCKTAKE", "盘库"], + ["PRODUCTION_OUT", "生产出库"], + ["OUTSOURCING_OUT", "委外出库"], + ["RETURN_OUT", "返工出库"] +]); +assertNoRawEnumFallback("formatSourceDocTypeLabel"); + +assertFormatter("formatWarehouseTypeLabel", [ + ["RAW", "原材料库"], + ["SEMI", "半成品库"], + ["FINISHED", "成品库"], + ["AUX", "辅料库"], + ["SCRAP", "废料库"], + ["RETURN", "退货库"] +]); +assertNoRawEnumFallback("formatWarehouseTypeLabel"); + +assertFormatter("formatItemTypeLabel", [ + ["RAW_MATERIAL", "原材料"], + ["FINISHED_GOOD", "成品"], + ["PRODUCT", "产品"] +]); +assertNoRawEnumFallback("formatItemTypeLabel"); + +assertFormatter("formatPurchaseSourceTypeLabel", [ + ["SALES_ORDER", "销售订单采购"], + ["STOCKING", "备料采购"] +]); +assertNoRawEnumFallback("formatPurchaseSourceTypeLabel"); + +assertFormatter("formatMiniappRoleLabel", [ + ["worker", "员工"], + ["manager", "经理"], + ["admin", "管理员"] +]); +assertNoRawEnumFallback("formatMiniappRoleLabel"); + +assertFormatter("formatMaintenanceTypeLabel", [ + ["PERIODIC", "周期保养"], + ["REPAIR", "维修保养"], + ["INSPECTION", "点检保养"] +]); +assertNoRawEnumFallback("formatMaintenanceTypeLabel"); + +assertFormatter("formatStocktakeRowStatusLabel", [ + ["READY", "可确认"], + ["ERROR", "异常"], + ["IGNORED", "已忽略"] +]); +assertNoRawEnumFallback("formatStocktakeRowStatusLabel"); + +const dictionarySource = readSource("src/utils/dictionaries.js"); +assert.match(dictionarySource, /function normalizeDictionaryKey/, "dictionary lookup should normalize keys"); +assert.match(dictionarySource, /function isEnumLikeValue/, "dictionary lookup should identify enum-like fallbacks"); +assert.match(dictionarySource, /未识别/, "dictionary fallback should be Chinese"); + +const tableControlsSource = readSource("src/utils/tableControls.js"); +[ + "WAREHOUSE_TYPE_LABELS", + "ITEM_TYPE_LABELS", + "PURCHASE_SOURCE_TYPE_LABELS", + "MAINTENANCE_TYPE_LABELS", + "MINIAPP_ROLE_LABELS", + "STOCKTAKE_ROW_STATUS_LABELS" +].forEach((mapName) => { + assert.match(tableControlsSource, new RegExp(mapName), `TableControls search should include ${mapName}`); +}); + +const inventorySource = readSource("src/views/InventoryLedgerView.vue"); +assert.match(inventorySource, /formatWarehouseTypeLabel/, "inventory view should use warehouse type formatter"); +assert.match(inventorySource, /formatItemTypeLabel/, "inventory view should use item type formatter"); +assert.doesNotMatch(inventorySource, /placeholder="如 SALES_OUT、RETURN_IN"/, "inventory ledger filter should not show English examples"); + +const equipmentSource = readSource("src/views/EquipmentManagementView.vue"); +assert.match(equipmentSource, /formatMaintenanceTypeLabel/, "equipment view should translate maintenance types"); +assert.doesNotMatch(equipmentSource, /\{\{\s*order\.maintenance_type\s*\}\}/, "equipment view must not render raw maintenance_type"); + +const employeeSource = readSource("src/views/EmployeeManagementView.vue"); +assert.match(employeeSource, /formatPersonRoles/, "employee view should centralize role display formatting"); +assert.match(employeeSource, /formatMiniappRoleLabel/, "employee view should translate miniapp role codes"); +assert.doesNotMatch(employeeSource, /personnel \/ person_roles/, "employee view should not show raw table names"); + +const stocktakeSource = readSource("src/components/StocktakeDialog.vue"); +assert.match(stocktakeSource, /formatStocktakeRowStatusLabel/, "stocktake dialog should have row status formatter available"); + +const purchaseSource = readSource("src/views/PurchaseOrderView.vue"); +assert.match(purchaseSource, /formatPurchaseSourceTypeLabel/, "purchase order view should translate source_type"); +assert.doesNotMatch(purchaseSource, /"source_type",\s*$/m, "purchase order search should not rely on raw source_type only"); + +console.log("dictionary localization checks passed"); diff --git a/frontend/scripts/test-document-form-components.mjs b/frontend/scripts/test-document-form-components.mjs new file mode 100644 index 0000000..e772444 --- /dev/null +++ b/frontend/scripts/test-document-form-components.mjs @@ -0,0 +1,72 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const files = [ + "src/components/documentForms/DocumentPaper.vue", + "src/components/documentForms/DocumentGrid.vue", + "src/components/documentForms/DocumentLineTable.vue", + "src/components/documentForms/DocumentArchiveActions.vue", + "src/styles/main.css" +]; + +const sources = new Map(); + +for (const file of files) { + const path = resolve(process.cwd(), file); + if (!existsSync(path)) { + throw new Error(`Missing required file: ${file}`); + } + + const source = readFileSync(path, "utf8"); + if (source.trim().length < 200) { + throw new Error(`Required file has unexpectedly short content: ${file}`); + } + + sources.set(file, source); +} + +const documentPaper = sources.get("src/components/documentForms/DocumentPaper.vue"); +for (const snippet of ["ERP留存联", "document-paper-clip", "document-paper-binder-hole"]) { + if (!documentPaper.includes(snippet)) { + throw new Error(`DocumentPaper missing required snippet: ${snippet}`); + } +} + +const archiveActions = sources.get("src/components/documentForms/DocumentArchiveActions.vue"); +for (const snippet of ["预览PDF", "下载PDF", "重新生成"]) { + if (!archiveActions.includes(snippet)) { + throw new Error(`DocumentArchiveActions missing required label: ${snippet}`); + } +} + +for (const forbiddenSnippet of ["ghost-button", "action-button"]) { + if (archiveActions.includes(forbiddenSnippet)) { + throw new Error(`DocumentArchiveActions must not use global button class: ${forbiddenSnippet}`); + } +} + +for (const snippet of ["document-archive-button", "normalizedStatusType", ".trim()"]) { + if (!archiveActions.includes(snippet)) { + throw new Error(`DocumentArchiveActions missing archive action hardening snippet: ${snippet}`); + } +} + +const css = sources.get("src/styles/main.css"); +for (const selector of [".document-paper-shell", ".document-paper-sheet", ".document-line-table"]) { + if (!css.includes(selector)) { + throw new Error(`main.css missing required selector: ${selector}`); + } +} + +for (const selector of [ + ".document-archive-button", + ".document-archive-button-preview", + ".document-archive-button-download", + ".document-archive-button-regenerate" +]) { + if (!css.includes(selector)) { + throw new Error(`main.css missing document archive button selector: ${selector}`); + } +} + +console.log("document form components static tests passed"); diff --git a/frontend/scripts/test-drawer-data-table.mjs b/frontend/scripts/test-drawer-data-table.mjs new file mode 100644 index 0000000..ffb5db4 --- /dev/null +++ b/frontend/scripts/test-drawer-data-table.mjs @@ -0,0 +1,25 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const componentPath = resolve(process.cwd(), "src/components/DrawerDataTable.vue"); +const source = readFileSync(componentPath, "utf8"); + +const requiredSnippets = [ + "defineProps", + "columns", + "rows", + "rowKey", + "expandedRows", + "cellClass", + "slot :name=\"`cell-${column.key}`\"", + "drawer-data-table", + "drawer-row-detail" +]; + +for (const snippet of requiredSnippets) { + if (!source.includes(snippet)) { + throw new Error(`DrawerDataTable missing required snippet: ${snippet}`); + } +} + +console.log("drawer data table static tests passed"); diff --git a/frontend/scripts/test-drawer-table-migrations.mjs b/frontend/scripts/test-drawer-table-migrations.mjs new file mode 100644 index 0000000..4f4bc46 --- /dev/null +++ b/frontend/scripts/test-drawer-table-migrations.mjs @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const pages = [ + { + file: "src/views/DeliveryManagementView.vue", + snippets: [ + "import DrawerDataTable", + "const expandedDeliveryItemRows", + "const deliveryItemDrawerColumns", + " ({ + item_id: item.item_id, + item_code: item.item_code, + item_name: item.item_name, + weight_available_kg: item.weight_available_kg + })), + [ + { + item_id: 101, + item_code: "原材料00001", + item_name: "304不锈钢", + weight_available_kg: 140 + } + ] +); + +const rawInboundOptions = buildGenericItemOptions({ + direction: "in", + activeInventoryTab: "raw", + activeWarehouseType: "RAW", + warehouseId: 1, + balances: stockedBalances, + materials: [{ item_id: 201, item_code: "原材料00004", item_name: "铜板" }], + products: [{ item_id: 301, item_code: "产品00001", item_name: "支架" }] +}); + +assert.deepEqual(rawInboundOptions.map((item) => item.item_id), [201]); + +const finishedOutboundOptions = buildGenericItemOptions({ + direction: "out", + activeInventoryTab: "finished", + activeWarehouseType: "FINISHED", + warehouseId: 8, + balances: [ + { + item_id: 401, + item_code: "产品需规00001", + item_name: "电脑支架", + warehouse_id: 8, + warehouse_type: "FINISHED", + qty_available: 12, + weight_available_kg: 30 + } + ], + materials: [], + products: [] +}); + +assert.equal(finishedOutboundOptions.length, 1); +assert.equal(finishedOutboundOptions[0].qty_available, 12); + +const semiOutboundOptions = buildGenericItemOptions({ + direction: "out", + activeInventoryTab: "semi", + activeWarehouseType: "SEMI", + warehouseId: 9, + balances: [ + { + item_id: 501, + item_code: "产品需规00002", + item_name: "半成品支架", + source_line_id: 9101, + option_key: "501::9101", + operation_label: "产品:半成品支架;已完成工序:第1道 冲压", + warehouse_id: 9, + warehouse_type: "SEMI", + qty_available: 6, + weight_available_kg: 12 + }, + { + item_id: 501, + item_code: "产品需规00002", + item_name: "半成品支架", + source_line_id: 9102, + option_key: "501::9102", + operation_label: "产品:半成品支架;已完成工序:第2道 折弯", + warehouse_id: 9, + warehouse_type: "SEMI", + qty_available: 8, + weight_available_kg: 20 + } + ], + materials: [], + products: [] +}); + +assert.equal(semiOutboundOptions.length, 2); +assert.deepEqual(semiOutboundOptions.map((item) => item.option_key), ["501::9101", "501::9102"]); + +const productMasterRows = [{ item_id: 601, item_code: "产品需规00003", item_name: "委外成品" }]; +const productOperationRows = [ + { + option_key: "601::7001", + item_id: 601, + item_code: "产品需规00003", + item_name: "委外成品", + route_id: 8001, + route_operation_id: 7001, + seq_no: 1, + operation_name: "冲压" + }, + { + option_key: "601::7002", + item_id: 601, + item_code: "产品需规00003", + item_name: "委外成品", + route_id: 8001, + route_operation_id: 7002, + seq_no: 2, + operation_name: "折弯" + } +]; +const routeOperationOptions = buildProductOperationOptions([ + { + route_id: 8001, + product_item_id: 601, + product_code: "产品需规00003", + product_name: "委外成品", + version_no: "V1.0", + status: "ACTIVE", + operations: [ + { + route_operation_id: 7001, + seq_no: 1, + operation_name: "1", + process_name: "1", + stamping_method: "切边冲孔", + status: "ACTIVE" + } + ] + } +]); + +assert.equal(routeOperationOptions[0].operation_label, "第1道 切边冲孔"); +assert.equal(routeOperationOptions[0].source_material_summary, "产品:委外成品;已完成工序:第1道 切边冲孔"); + +const operationWithoutStampingMethod = buildProductOperationOptions([ + { + route_id: 8002, + product_item_id: 602, + product_code: "产品需规00004", + product_name: "无冲压方式产品", + version_no: "V1.0", + status: "ACTIVE", + operations: [ + { + route_operation_id: 7003, + seq_no: 1, + operation_name: "1", + process_name: "开平", + stamping_method: "", + status: "ACTIVE" + } + ] + } +]); + +assert.equal(operationWithoutStampingMethod[0].operation_label, "第1道"); +const semiInboundOptions = buildGenericItemOptions({ + direction: "in", + activeInventoryTab: "semi", + activeWarehouseType: "SEMI", + warehouseId: 9, + balances: [], + materials: [], + products: productMasterRows, + productOperations: productOperationRows +}); +const finishedInboundOptions = buildGenericItemOptions({ + direction: "in", + activeInventoryTab: "finished", + activeWarehouseType: "FINISHED", + warehouseId: 8, + balances: [], + materials: [], + products: productMasterRows +}); + +assert.deepEqual(semiInboundOptions.map((item) => item.option_key), ["601::7001", "601::7002"]); +assert.deepEqual(semiInboundOptions.map((item) => item.route_operation_id), [7001, 7002]); +assert.deepEqual(finishedInboundOptions.map((item) => item.item_id), [601]); + +const auxiliaryOutboundOptions = buildGenericItemOptions({ + direction: "out", + activeInventoryTab: "auxiliary", + activeWarehouseType: "AUX", + warehouseId: 10, + balances: [ + { + item_id: 701, + item_code: "辅料00001", + item_name: "包装膜", + warehouse_id: 10, + warehouse_type: "AUX", + qty_available: 0, + weight_available_kg: 25 + } + ], + materials: [], + products: [] +}); +const auxiliaryInboundOptions = buildGenericItemOptions({ + direction: "in", + activeInventoryTab: "auxiliary", + activeWarehouseType: "AUX", + warehouseId: 10, + balances: [], + materials: [{ item_id: 702, item_code: "辅料00002", item_name: "手套" }], + products: [] +}); + +assert.equal(auxiliaryOutboundOptions.length, 1); +assert.equal(auxiliaryOutboundOptions[0].item_id, 701); +assert.deepEqual(auxiliaryInboundOptions.map((item) => item.item_id), [702]); + +const scrapInboundOptions = buildGenericItemOptions({ + direction: "in", + activeInventoryTab: "scrap", + activeWarehouseType: "SCRAP", + warehouseId: 11, + balances: [], + materials: [{ item_id: 801, item_code: "原材料00005", item_name: "冲压边角料" }], + products: [{ item_id: 901, item_code: "产品需规00004", item_name: "报废支架" }] +}); +const scrapOutboundOptions = buildGenericItemOptions({ + direction: "out", + activeInventoryTab: "scrap", + activeWarehouseType: "SCRAP", + warehouseId: 11, + balances: [ + { + item_id: 801, + item_code: "原材料00005", + item_name: "冲压边角料", + warehouse_id: 11, + warehouse_type: "SCRAP", + qty_available: 10, + weight_available_kg: 0 + }, + { + item_id: 901, + item_code: "产品需规00004", + item_name: "报废支架", + warehouse_id: 11, + warehouse_type: "SCRAP", + qty_available: 0, + weight_available_kg: 42 + }, + { + item_id: 902, + item_code: "产品需规00005", + item_name: "异仓废料", + warehouse_id: 12, + warehouse_type: "SCRAP", + qty_available: 0, + weight_available_kg: 18 + } + ], + materials: [], + products: [] +}); +const scrapWarehouseTypeInboundOptions = buildGenericItemOptions({ + direction: "in", + activeInventoryTab: "raw", + activeWarehouseType: "SCRAP", + warehouseId: 11, + balances: [], + materials: [{ item_id: 803, item_code: "原材料00006", item_name: "废铜" }], + products: [{ item_id: 903, item_code: "产品需规00006", item_name: "报废夹具" }] +}); + +assert.deepEqual(scrapInboundOptions.map((item) => item.item_id), [801, 901]); +assert.deepEqual(scrapWarehouseTypeInboundOptions.map((item) => item.item_id), [803, 903]); +assert.deepEqual( + scrapOutboundOptions.map((item) => ({ + item_id: item.item_id, + qty_available: item.qty_available, + weight_available_kg: item.weight_available_kg + })), + [ + { + item_id: 901, + qty_available: 0, + weight_available_kg: 42 + } + ] +); + +const inventoryLedgerSource = readFileSync( + fileURLToPath(new URL("../src/views/InventoryLedgerView.vue", import.meta.url)), + "utf8" +); +const deliveryManagementSource = readFileSync( + fileURLToPath(new URL("../src/views/DeliveryManagementView.vue", import.meta.url)), + "utf8" +); +const tableControlsSource = readFileSync( + fileURLToPath(new URL("../src/components/TableControls.vue", import.meta.url)), + "utf8" +); +const stockLotTagSelectSource = readFileSync( + fileURLToPath(new URL("../src/components/StockLotTagSelect.vue", import.meta.url)), + "utf8" +); + +assert.match(inventoryLedgerSource, /key: "scrap"/); +assert.match(inventoryLedgerSource, /label: "废料库"/); +assert.match(inventoryLedgerSource, /PRODUCTION_SCRAP_IN/); +assert.match( + inventoryLedgerSource, + /const genericUnitCostLabel = computed\(\(\) =>[\s\S]*isProductionScrapInbound\.value[\s\S]*原材料成本单价\(元\/kg\)/, + "production scrap inbound should label unit cost as raw material cost unit price" +); +assert.match( + inventoryLedgerSource, + /const productionScrapToleranceRemainingWeight = computed\(\(\) => \{[\s\S]*productionScrapExpectedWeight\.value[\s\S]*1\.15[\s\S]*productionScrapInboundBeforeWeight/, + "production scrap inbound should calculate the non-settle upper limit from cumulative scrap inbound weight" +); +assert.match( + inventoryLedgerSource, + /genericForm\.settle_work_order && productionScrapSettleNeedsRemark\.value/, + "production scrap inbound should require a settle explanation when final scrap weight is outside tolerance" +); +assert.match( + inventoryLedgerSource, + /const scrapToleranceMax = Number\(productionScrapToleranceRemainingWeight\.value \|\| 0\);[\s\S]*inboundWeight > scrapToleranceMax/, + "production scrap inbound validation should compare inbound weight against cumulative upper-limit remaining amount" +); +assert.match( + inventoryLedgerSource, + /activeOperation\?\.direction !== 'out' && !isCustomerSuppliedInbound && !isSourceLotReturnInbound && !isSemiSourceLotTraceInbound && !isFinishedOutsourcingInbound && !isProductionScrapInbound && !isReworkScrapInbound && !isOutsourcingScrapInbound/, + "production scrap inbound should hide the manual batch number field" +); +assert.match( + inventoryLedgerSource, + /lot_no: isCustomerSuppliedInbound\.value \? null : isProductionScrapInbound\.value \? null[\s\S]*isOutsourcingScrapInbound\.value \? null/, + "production scrap inbound should never submit a manually-entered batch number" +); +assert.match(inventoryLedgerSource, /OUTSOURCING_SCRAP_IN/); +assert.match(inventoryLedgerSource, /SCRAP_SALE_OUT/); +assert.match(inventoryLedgerSource, /LOGISTICS_REQUIRED_BIZ_TYPES/); +assert.match(inventoryLedgerSource, /"CUSTOMER_SUPPLIED"/); +assert.match(inventoryLedgerSource, /const isCustomerSuppliedInbound = computed/); +assert.match(inventoryLedgerSource, /customerSuppliedLotNoPreview/); +assert.match(inventoryLedgerSource, /提交后自动生成/); +assert.match(inventoryLedgerSource, /activeOperation\?\.direction !== 'out' && !isCustomerSuppliedInbound/); +assert.match(inventoryLedgerSource, /lot_no: isCustomerSuppliedInbound\.value \? null/); +assert.match(inventoryLedgerSource, /source_material_sub_batch_no: isCustomerSuppliedInbound\.value\s+\?\s+null/); +assert.match(inventoryLedgerSource, /const workOrderLedgerRows = ref\(\[\]\);/); +assert.match(inventoryLedgerSource, /fetchResource\("\/production\/work-order-ledger\?limit=500", \[\]\)/); +assert.match(inventoryLedgerSource, /workOrderLedgerRows\.value = workOrderLedgerRowData;/); +assert.match( + stockLotTagSelectSource, + /issued_weight_kg:\s*normalizeSelectedIssuedWeight\(lot\)/, + "StockLotTagSelect should preserve an option's issued_weight_kg instead of overwriting it with remaining weight" +); +assert.match( + stockLotTagSelectSource, + /function displayWeight\(lot\)/, + "StockLotTagSelect should support scenario-specific selected-tag weight display" +); +assert.match( + inventoryLedgerSource, + /:display-weight-field="isProductionSurplusInbound \? 'issued_weight_kg' : 'remaining_weight_kg'"/, + "production surplus inbound should display selected source lots by work-order issued weight" +); +assert.doesNotMatch( + inventoryLedgerSource, + /操作<\/th>/); +assert.doesNotMatch(componentSource, /继续处理\s*<\/button>/); +assert.doesNotMatch(componentSource, /v-if="canContinueStocktake\(row\)"/); +assert.match(componentSource, /hasBlockingDiffErrors\(previewLines\.value\)/); +assert.match(componentSource, /盘点清单已导入:发现/); + +const styleSource = readFileSync( + fileURLToPath(new URL("../src/styles/main.css", import.meta.url)), + "utf8" +); +assert.match(styleSource, /\.stocktake-diff-modal \.stocktake-diff-table-v2[\s\S]*margin: 14px 18px 0;/); +assert.match(styleSource, /\.stocktake-diff-modal \.stocktake-footer[\s\S]*min-height: 72px;/); +assert.match(styleSource, /\.stocktake-diff-modal-mask[\s\S]*box-sizing: border-box;/); +assert.match(styleSource, /\.stocktake-diff-modal-mask[\s\S]*z-index: 3;/); +assert.match(styleSource, /\.stocktake-diff-modal[\s\S]*height: min\(820px, 100%\);/); +assert.match(styleSource, /\.stocktake-diff-modal[\s\S]*max-height: 100%;/); +assert.doesNotMatch(styleSource, /\.stocktake-diff-modal[\s\S]*calc\(100vh - 64px\)/); +assert.match(styleSource, /\.stocktake-diff-modal \.stocktake-footer[\s\S]*justify-content: flex-end;/); +assert.match(styleSource, /\.stocktake-confirm-mask[\s\S]*z-index: 4;/); +assert.match(styleSource, /\.stocktake-diff-cell,[\s\S]*overflow-wrap: anywhere;/); +assert.match(styleSource, /\.stocktake-diff-cell span,[\s\S]*line-height: 1\.55;/); + +const applyLoadedStart = componentSource.indexOf("async function applyLoadedStocktake(stocktake, options = {})"); +const activeStocktakeAssignment = componentSource.indexOf("activeStocktake.value = stocktake;", applyLoadedStart); +const clearConfirmDialogAssignment = componentSource.indexOf("confirmDialogOpen.value = false;", applyLoadedStart); +const clearPreviewLinesAssignment = componentSource.indexOf("previewLines.value = [];", applyLoadedStart); +const clearActiveStocktakeAssignment = componentSource.indexOf("activeStocktake.value = null;", applyLoadedStart); +const clearPreviewSummaryAssignment = componentSource.indexOf("previewSummary.value = {};", applyLoadedStart); +const lineFetchAssignment = componentSource.indexOf( + "fetchResource(`/inventory/stocktakes/${stocktake.id}/lines?warehouse_id=${currentWarehouseId.value}`, null)", + applyLoadedStart +); +const lineFetchGuard = componentSource.indexOf('throw new Error("读取盘库明细失败,请重新打开盘库单");', applyLoadedStart); +const activePanelAssignment = componentSource.indexOf( + 'activePanel.value = nextPanel === "diff" || nextPanel === "history" ? "import" : nextPanel;', + applyLoadedStart +); + +assert.notEqual(applyLoadedStart, -1); +assert.notEqual(activeStocktakeAssignment, -1); +assert.notEqual(clearConfirmDialogAssignment, -1); +assert.notEqual(clearPreviewLinesAssignment, -1); +assert.notEqual(clearActiveStocktakeAssignment, -1); +assert.notEqual(clearPreviewSummaryAssignment, -1); +assert.notEqual(lineFetchAssignment, -1); +assert.notEqual(lineFetchGuard, -1); +assert.notEqual(activePanelAssignment, -1); +assert.ok(clearConfirmDialogAssignment < lineFetchAssignment); +assert.ok(clearPreviewLinesAssignment < lineFetchAssignment); +assert.ok(clearActiveStocktakeAssignment < lineFetchAssignment); +assert.ok(clearPreviewSummaryAssignment < lineFetchAssignment); +assert.ok(lineFetchAssignment < activeStocktakeAssignment); +assert.ok(lineFetchAssignment < activePanelAssignment); + +console.log("stocktake workflow tests passed"); diff --git a/frontend/scripts/test-system-permission-management-ui.mjs b/frontend/scripts/test-system-permission-management-ui.mjs new file mode 100644 index 0000000..bbd898c --- /dev/null +++ b/frontend/scripts/test-system-permission-management-ui.mjs @@ -0,0 +1,354 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const appSource = readFileSync(new URL("../src/App.vue", import.meta.url), "utf8"); +const routerSource = readFileSync(new URL("../src/router/index.js", import.meta.url), "utf8"); +const systemPermissionSource = readFileSync(new URL("../src/views/SystemPermissionView.vue", import.meta.url), "utf8"); +const orgMindMapSource = readFileSync(new URL("../src/components/OrgMindMap.vue", import.meta.url), "utf8"); +const orgPermissionTreeSource = readFileSync(new URL("../src/components/OrgPermissionTree.vue", import.meta.url), "utf8"); + +assert.match(appSource, /系统管理/, "App.vue should show the system-management parent menu"); +assert.match(appSource, /系统权限管理/, "App.vue should show the system permission child menu"); +assert.match(appSource, /系统拓展/, "App.vue should keep the system extension child menu"); +assert.match( + appSource, + /permissionsAny:\s*\[[\s\S]*"MENU_SYSTEM_PERMISSION"[\s\S]*"MENU_SYSTEM_EXTENSION"[\s\S]*\]/, + "App.vue system menu should allow either system permission" +); + +assert.match( + routerSource, + /import SystemPermissionView from "\.\.\/views\/SystemPermissionView\.vue";/, + "router should import SystemPermissionView" +); +assert.match(routerSource, /path:\s*"\/system-management"[\s\S]*redirect:/, "router should add /system-management redirect"); +assert.match(routerSource, /path:\s*"\/system-permissions"/, "router should add /system-permissions route"); +assert.match( + routerSource, + /name:\s*"system-permissions"[\s\S]*permission:\s*"MENU_SYSTEM_PERMISSION"/, + "system-permissions route should require MENU_SYSTEM_PERMISSION" +); + +assert.match(systemPermissionSource, /系统权限管理/, "SystemPermissionView should show page title"); +assert.match(systemPermissionSource, /组织架构/, "SystemPermissionView should expose organization structure tab"); +assert.match(systemPermissionSource, /角色维护/, "SystemPermissionView should expose role-only maintenance tab"); +assert.doesNotMatch(systemPermissionSource, /人员与角色维护/, "personnel and role maintenance should be renamed"); +assert.doesNotMatch(systemPermissionSource, /maintenanceSubTab/, "role maintenance should not keep nested personnel tabs"); +assert.doesNotMatch(systemPermissionSource, /system-permission-sub-tabs/, "role maintenance should not render nested sub tabs"); +assert.doesNotMatch( + systemPermissionSource, + /v-if="maintenanceSubTab === 'users'"/, + "standalone system users table should be removed from maintenance page" +); +assert.doesNotMatch( + systemPermissionSource, + /v-if="maintenanceSubTab === 'roles'"/, + "roles should render directly in the role maintenance tab" +); +assert.match( + systemPermissionSource, + /import PaginationBar from "\.\.\/components\/PaginationBar\.vue";/, + "page should use global PaginationBar" +); +assert.match( + systemPermissionSource, + /import \{ usePagination \} from "\.\.\/utils\/pagination";/, + "page should use global pagination helper" +); +assert.match(systemPermissionSource, /paginatedRoles/, "roles table should use paginated rows"); +assert.match(systemPermissionSource, //, "role maintenance should use global table styling"); +assert.doesNotMatch( + systemPermissionSource, + /class="maintenance-grid"/, + "maintenance page should not use the old side-by-side card grid" +); +assert.doesNotMatch( + systemPermissionSource, + /
    [\s\S]*v-for="role in roles"/, + "roles should not render as old card list" +); +assert.doesNotMatch( + systemPermissionSource, + /
      [\s\S]*v-for="user in users"/, + "users should not render as old card list" +); +assert.match(systemPermissionSource, /permissionMainTab/, "SystemPermissionView should keep top-level tabs separate"); +assert.match( + systemPermissionSource, + /v-if="permissionMainTab === 'org'"/, + "organization mind map should live in its own top-level tab" +); +assert.match( + systemPermissionSource, + /v-if="permissionMainTab === 'maintenance'"/, + "role maintenance should live in a separate top-level tab" +); +assert.match(systemPermissionSource, /orgViewMode/, "organization tab should keep organization view mode state"); +assert.match(systemPermissionSource, /脑图模式/, "organization tab should expose mind-map mode switch"); +assert.match(systemPermissionSource, /组织树模式/, "organization tab should expose organization-tree mode switch"); +assert.match( + systemPermissionSource, + //, + "manager selector should not rely on native multi-select keyboard behavior" +); +assert.match(systemPermissionSource, /多位主管/, "manager drawer should explain multiple manager binding"); +assert.match(systemPermissionSource, /contextMenu/, "page must expose a right-click context menu for mind-map nodes"); +assert.match(systemPermissionSource, /@node-contextmenu/, "page must listen for node context menu events"); +assert.match(systemPermissionSource, /重命名/, "context menu should support renaming organization nodes"); +assert.match(systemPermissionSource, /新增分公司节点/, "company context menu should support adding branch nodes"); +assert.match(systemPermissionSource, /新增部门节点/, "branch context menu should support adding department nodes"); +assert.match(systemPermissionSource, /新增小组节点/, "department context menu should support adding group nodes"); +assert.match(systemPermissionSource, /新增人员/, "department and group context menu should support adding employee leaves"); +assert.match(systemPermissionSource, /添加该节点的主管/, "context menu should support manager binding"); +assert.match(systemPermissionSource, /deleteResource/, "page must call backend delete endpoints for org actions"); +assert.match(systemPermissionSource, /\/employees/, "page must call backend employee binding endpoints"); +assert.match(systemPermissionSource, /员工节点默认收拢/, "page should document collapsed employee leaves"); +assert.doesNotMatch(systemPermissionSource, /class="org-node-detail"/, "mind-map node operations must not stay in a persistent bottom bar"); +assert.match(systemPermissionSource, /mindmap-toolbar/, "mind-map should expose a top-right toolbar"); +assert.match(systemPermissionSource, /放大/, "mind-map toolbar should support zooming in"); +assert.match(systemPermissionSource, /缩小/, "mind-map toolbar should support zooming out"); +assert.match(systemPermissionSource, /刷新/, "mind-map toolbar should support refreshing data"); +assert.match(systemPermissionSource, /一屏展示/, "mind-map toolbar should expose a separate one-screen fit button"); +assert.match(systemPermissionSource, /mindMapScale/, "page should keep mind-map zoom state"); +assert.match(systemPermissionSource, /refreshMindMap/, "page should refresh mind-map data from toolbar"); +assert.match(systemPermissionSource, /fitMindMapToScreen/, "page should separate one-screen fitting from refreshing"); +assert.match(systemPermissionSource, /mindMapFitRequestKey/, "page should request one-screen mind-map fitting"); +assert.match( + systemPermissionSource, + /:fit-request-key="mindMapFitRequestKey"/, + "SystemPermissionView should pass fit request key to OrgMindMap" +); +assert.doesNotMatch( + systemPermissionSource, + /@zoom-scale=/, + "SystemPermissionView should not accept mouse-wheel zoom from OrgMindMap" +); +assert.match( + systemPermissionSource, + /@fit-scale="applyMindMapFitScale"/, + "SystemPermissionView should accept fit scale from OrgMindMap" +); +assert.match( + systemPermissionSource, + /function applyMindMapFitScale/, + "SystemPermissionView should apply one-screen fit scale" +); +assert.doesNotMatch( + systemPermissionSource, + /:key="mindMapRenderKey"/, + "mind-map refresh must not recreate OrgMindMap because that collapses expanded employees" +); +const refreshMindMapMatch = systemPermissionSource.match(/async function refreshMindMap\(\)\s*\{([\s\S]*?)\n\}/); +assert.ok(refreshMindMapMatch, "refreshMindMap function block must be parseable"); +assert.doesNotMatch( + refreshMindMapMatch[1], + /mindMapFitRequestKey\.value \+= 1/, + "refresh must not request one-screen fit; one-screen fit is a separate user action" +); +assert.doesNotMatch( + refreshMindMapMatch[1], + /mindMapRenderKey\.value \+= 1/, + "refresh must not recreate mind-map component" +); +assert.match( + systemPermissionSource, + /function fitMindMapToScreen\(\)[\s\S]*mindMapFitRequestKey\.value \+= 1/, + "one-screen fit action should request a viewport fit explicitly" +); +assert.match(systemPermissionSource, /const updatePayload\s*=/, "org edit must build an update-only payload"); +assert.match(systemPermissionSource, /const createPayload\s*=/, "org create must build a create payload"); +const updatePayloadMatch = systemPermissionSource.match(/const updatePayload\s*=\s*\{([\s\S]*?)\n\s*\};/); +assert.ok(updatePayloadMatch, "org edit updatePayload block must be parseable"); +assert.doesNotMatch( + updatePayloadMatch[1], + /parent_id|node_type|dept_code/, + "org edit updatePayload must exclude create-only fields" +); +assert.match( + systemPermissionSource, + /putResource\(`\/system-permissions\/org-nodes\/\$\{editingOrgNode\.value\.node_id\}`,\s*updatePayload\)/, + "org edit PUT must only send the update payload" +); +assert.match( + systemPermissionSource, + /postResource\("\/system-permissions\/org-nodes",\s*createPayload\)/, + "org create POST must send the create payload" +); +assert.match( + systemPermissionSource, + /@change="togglePermissionNode\(group, \$event\.target\.checked\)"/, + "parent permission checkbox must toggle descendants" +); +const persistablePermissionCodesMatch = systemPermissionSource.match(/function collectPersistablePermissionCodes\(node\)\s*\{([\s\S]*?)\n\}/); +assert.ok(persistablePermissionCodesMatch, "collectPersistablePermissionCodes function block must be parseable"); +assert.match( + persistablePermissionCodesMatch[1], + /if \(children\.length\)[\s\S]*return children\.flatMap\(\(child\) => collectPersistablePermissionCodes\(child\)\)/, + "group-only parent permission codes must be skipped in favor of child menu permissions" +); +assert.match( + systemPermissionSource, + /for \(const code of collectPersistablePermissionCodes\(node\)\)/, + "permission toggling must persist only real menu permission codes" +); +assert.match( + systemPermissionSource, + /if \(!authorizingEmployee\.value\?\.employee_id\)[\s\S]*notifyError\("请选择有效人员"\)/, + "authorization submit must reject missing employee context" +); +assert.match( + systemPermissionSource, + /if \(!authorizingEmployeePhone\.value\)[\s\S]*notifyError\("该人员没有电话号码,不能作为账号"\)/, + "authorization submit must reject employees without a phone account" +); +assert.doesNotMatch( + systemPermissionSource, + /window\.alert\("新增系统使用人员抽屉将在实现任务中接入/, + "temporary user alert must be removed" +); +assert.doesNotMatch( + systemPermissionSource, + /window\.alert\("新增角色抽屉将在实现任务中接入/, + "temporary role alert must be removed" +); + +assert.match(orgMindMapSource, /defineProps/, "OrgMindMap should use defineProps"); +assert.match(orgMindMapSource, /orthogonal/i, "OrgMindMap should name orthogonal connector logic"); +assert.match(orgMindMapSource, /collapsedEmployeeNodeIds/, "OrgMindMap should default employee leaves to collapsed"); +assert.match(orgMindMapSource, /toggle-employee-leaves/, "OrgMindMap should emit employee leaf expand or collapse events"); +assert.match(orgMindMapSource, /employee_count/, "OrgMindMap should display employee count on parent org cards"); +assert.match(orgMindMapSource, /node-contextmenu/, "OrgMindMap should emit right-click context menu events"); +assert.match(orgMindMapSource, /manager_names/, "OrgMindMap should display multiple manager names"); +assert.match(orgMindMapSource, /zoomScale/, "OrgMindMap should accept a zoom scale prop"); +assert.match(orgMindMapSource, /fitRequestKey/, "OrgMindMap should accept fit request key"); +assert.match(orgMindMapSource, /fit-scale/, "OrgMindMap should emit fit scale"); +assert.match(orgMindMapSource, /mindMapViewport/, "OrgMindMap should keep a viewport ref for canvas panning"); +assert.match(orgMindMapSource, /isCanvasPanning/, "OrgMindMap should track canvas panning state"); +assert.match(orgMindMapSource, /startCanvasPan/, "OrgMindMap should start canvas panning from empty canvas space"); +assert.match(orgMindMapSource, /moveCanvasPan/, "OrgMindMap should move the viewport while panning"); +assert.match(orgMindMapSource, /endCanvasPan/, "OrgMindMap should stop canvas panning"); +assert.doesNotMatch(orgMindMapSource, /@wheel/, "OrgMindMap should not bind mouse-wheel zooming"); +assert.doesNotMatch(orgMindMapSource, /function handleCanvasWheel/, "OrgMindMap should remove mouse-wheel zoom handling"); +assert.doesNotMatch(orgMindMapSource, /WHEEL_ZOOM_STEP/, "OrgMindMap should not keep mouse-wheel zoom step configuration"); +assert.doesNotMatch(orgMindMapSource, /emit\("zoom-scale"/, "OrgMindMap should not emit wheel zoom scale to parent"); +assert.match(orgMindMapSource, /fitCanvasToViewport/, "OrgMindMap should fit canvas to visible viewport"); +assert.match( + orgMindMapSource, + /window\.addEventListener\("mousemove", moveCanvasPan\)/, + "OrgMindMap should pan canvas through window mousemove listener" +); +assert.match( + orgMindMapSource, + /viewport\.scrollTop = canvasPan\.value\.scrollTop - deltaY/, + "OrgMindMap should support vertical canvas panning in addition to horizontal panning" +); +assert.match( + orgMindMapSource, + /window\.removeEventListener\("mousemove", moveCanvasPan\)/, + "OrgMindMap should clean up canvas pan listener" +); +assert.match( + orgMindMapSource, + /closest\?\.\("\.org-mind-map-node"\)/, + "canvas panning should not start from node cards" +); +assert.match( + orgMindMapSource, + /defineExpose/, + "OrgMindMap should expose fit helper for explicit refresh handling if needed" +); +assert.match(orgMindMapSource, /scaledCanvasStyle/, "OrgMindMap should size the canvas according to zoom scale"); +assert.match(orgMindMapSource, /overscroll-behavior: contain;/, "OrgMindMap should keep drag interaction inside the canvas viewport"); +assert.match(orgMindMapSource, /NODE_MIN_WIDTH/, "OrgMindMap should have a minimum node width"); +assert.match(orgMindMapSource, /NODE_MAX_WIDTH/, "OrgMindMap should have a maximum node width"); +assert.match(orgMindMapSource, /function estimateTextWidth/, "OrgMindMap should estimate text width before laying out cards"); +assert.match(orgMindMapSource, /function computeNodeWidth/, "OrgMindMap should compute node width from text length"); +assert.match(orgMindMapSource, /function fitTextToWidth/, "OrgMindMap should safely fit very long text into cards"); +assert.match(orgMindMapSource, /:width="node\.width"/, "OrgMindMap node card width should use computed per-node width"); +assert.match( + orgMindMapSource, + /parent\.x \+ parent\.width/, + "connector layout should use each node's computed width" +); +assert.match(orgMindMapSource, /function measureSubtree/, "mind map must measure subtree leaf spans before layout"); +assert.match(orgMindMapSource, /function layoutTree/, "mind map must use deterministic tree layout"); +assert.match(orgMindMapSource, /connectorSegments/, "mind map must deduplicate connector segments"); +assert.match(orgMindMapSource, /COLUMN_GAP/, "mind map must reserve a column gutter for connector lines"); +assert.match(orgMindMapSource, /NODE_MAX_WIDTH/, "mind map must know maximum node width for line avoidance"); +assert.match( + orgMindMapSource, + /H \$\{connector\.midX\} V \$\{connector\.toY\} H \$\{connector\.toX\}/, + "OrgMindMap should render H/V/H orthogonal connector paths" +); +assert.doesNotMatch(orgMindMapSource, /draggable="true"/, "OrgMindMap should not mark cards draggable"); +assert.doesNotMatch( + orgMindMapSource, + /@(drag|dragstart|dragend|pointermove|mousemove)\b/, + "OrgMindMap should not bind free-drag handlers" +); +assert.doesNotMatch( + orgMindMapSource, + /]*\sd="[^"]*[CQ]/i, + "OrgMindMap should not render curved SVG path commands" +); + +assert.match(orgPermissionTreeSource, /组织树/, "OrgPermissionTree should render organization tree UI"); +assert.match(orgPermissionTreeSource, /onContextmenu/, "OrgPermissionTree should support right-click org node actions"); +assert.match(orgPermissionTreeSource, /授权/, "OrgPermissionTree should expose personnel authorization action"); +assert.match(orgPermissionTreeSource, /移除/, "OrgPermissionTree should expose personnel removal action"); +assert.match(orgPermissionTreeSource, /新增人员/, "OrgPermissionTree should expose add-person action for selected org node"); +assert.match(orgPermissionTreeSource, /PaginationBar/, "OrgPermissionTree should use global pagination bar"); +assert.match(orgPermissionTreeSource, /搜索人员姓名或手机号/, "OrgPermissionTree should support personnel search"); + +console.log("system permission management UI static assertions passed"); diff --git a/frontend/scripts/test-warehouse-auxiliary-photo-labels.mjs b/frontend/scripts/test-warehouse-auxiliary-photo-labels.mjs new file mode 100644 index 0000000..5198ebd --- /dev/null +++ b/frontend/scripts/test-warehouse-auxiliary-photo-labels.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +const root = process.cwd(); +const checks = [ + { + label: "仓库页面", + file: path.join(root, "src/views/InventoryLedgerView.vue") + }, + { + label: "发货台账页面", + file: path.join(root, "src/views/DeliveryManagementView.vue") + }, + { + label: "物流校验服务", + file: path.join(root, "../backend/app/services/logistics.py") + }, + { + label: "仓库照片上传接口", + file: path.join(root, "../backend/app/api/routes/inventory.py") + } +]; + +for (const check of checks) { + const source = fs.readFileSync(check.file, "utf8"); + assert.doesNotMatch(source, /订单照片/, `${check.label}不应再显示“订单照片”`); + assert.match(source, /辅助照片/, `${check.label}应显示“辅助照片”`); +} + +for (const relativePath of [ + "src", + "../backend/app" +]) { + const findCommand = `rg -n "订单照片" ${JSON.stringify(path.join(root, relativePath))} --glob '!dist/**' --glob '!**/node_modules/**'`; + const { execSync } = await import("node:child_process"); + let output = ""; + try { + output = execSync(findCommand, { encoding: "utf8" }); + } catch (error) { + output = error.status === 1 ? "" : String(error.stdout || error.message || ""); + } + assert.equal(output.trim(), "", `系统源码不应再残留“订单照片”:${output}`); +} + +console.log("auxiliary photo label checks passed"); diff --git a/frontend/scripts/test-warehouse-drawer-table-overflow.mjs b/frontend/scripts/test-warehouse-drawer-table-overflow.mjs new file mode 100644 index 0000000..1ce3e38 --- /dev/null +++ b/frontend/scripts/test-warehouse-drawer-table-overflow.mjs @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +const root = process.cwd(); +const styles = fs.readFileSync(path.join(root, "src/styles/main.css"), "utf8"); +const inventory = fs.readFileSync(path.join(root, "src/views/InventoryLedgerView.vue"), "utf8"); + +function blockFor(selector) { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = styles.match(new RegExp(`${escaped}\\s*\\{([\\s\\S]*?)\\}`)); + assert.ok(match, `missing style block for ${selector}`); + return match[1]; +} + +const workflowTableWrap = blockFor(".workflow-drawer .table-wrap"); +assert.match( + workflowTableWrap, + /overflow-x:\s*auto/, + "drawer table wrappers must allow horizontal scrolling instead of clipping overflow" +); +assert.doesNotMatch( + workflowTableWrap, + /overflow-x:\s*hidden/, + "drawer table wrappers must not hide overflowing columns" +); + +const drawerDataTableWrap = blockFor(".drawer-data-table-wrap"); +assert.match( + drawerDataTableWrap, + /overflow-x:\s*auto/, + "DrawerDataTable wrapper must allow horizontal scrolling" +); +assert.doesNotMatch( + drawerDataTableWrap, + /overflow:\s*hidden|overflow-x:\s*hidden/, + "DrawerDataTable wrapper must not clip columns" +); + +const workflowTable = blockFor(".workflow-drawer .data-table"); +assert.match( + workflowTable, + /width:\s*max-content/, + "drawer tables should size to their columns when wider than the drawer" +); +assert.match( + workflowTable, + /min-width:\s*100%/, + "drawer tables should still fill the drawer when columns are narrow" +); +assert.match( + workflowTable, + /table-layout:\s*auto/, + "drawer tables should not use fixed layout that squeezes headers and cells" +); + +const drawerDataTable = blockFor(".drawer-data-table"); +assert.match( + drawerDataTable, + /width:\s*max-content/, + "DrawerDataTable should size to column widths when needed" +); +assert.match( + drawerDataTable, + /min-width:\s*100%/, + "DrawerDataTable should still fill the available drawer width" +); +assert.match( + drawerDataTable, + /table-layout:\s*auto/, + "DrawerDataTable should not use fixed layout that cuts off trailing columns" +); + +assert.match( + inventory, + / { + it("uses a searchable system-user select instead of free text input", () => { + assert.match(source, //); + assert.doesNotMatch(source, /placeholder="请输入需要重置密码的账号"/); + }); + + it("loads reset password account options from system permission users", () => { + assert.match(source, /fetchResource\("\/system-permissions\/users", \[\]\)/); + assert.match(source, /resetPasswordUserOptions/); + assert.match(source, /formatResetPasswordUserLabel\(user\)/); + }); +}); diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..d355211 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,1242 @@ + + + diff --git a/frontend/src/components/ActionButton.vue b/frontend/src/components/ActionButton.vue new file mode 100644 index 0000000..069a6eb --- /dev/null +++ b/frontend/src/components/ActionButton.vue @@ -0,0 +1,58 @@ + + + diff --git a/frontend/src/components/AlertPanel.vue b/frontend/src/components/AlertPanel.vue new file mode 100644 index 0000000..e248091 --- /dev/null +++ b/frontend/src/components/AlertPanel.vue @@ -0,0 +1,39 @@ + + + diff --git a/frontend/src/components/AppIcon.vue b/frontend/src/components/AppIcon.vue new file mode 100644 index 0000000..00013d1 --- /dev/null +++ b/frontend/src/components/AppIcon.vue @@ -0,0 +1,27 @@ + + + diff --git a/frontend/src/components/DrawerDataTable.vue b/frontend/src/components/DrawerDataTable.vue new file mode 100644 index 0000000..83068b4 --- /dev/null +++ b/frontend/src/components/DrawerDataTable.vue @@ -0,0 +1,129 @@ + + + diff --git a/frontend/src/components/FieldHint.vue b/frontend/src/components/FieldHint.vue new file mode 100644 index 0000000..26a6682 --- /dev/null +++ b/frontend/src/components/FieldHint.vue @@ -0,0 +1,96 @@ + + + diff --git a/frontend/src/components/FlowBoard.vue b/frontend/src/components/FlowBoard.vue new file mode 100644 index 0000000..c0df00e --- /dev/null +++ b/frontend/src/components/FlowBoard.vue @@ -0,0 +1,40 @@ + + + diff --git a/frontend/src/components/FormDrawer.vue b/frontend/src/components/FormDrawer.vue new file mode 100644 index 0000000..9280060 --- /dev/null +++ b/frontend/src/components/FormDrawer.vue @@ -0,0 +1,79 @@ + + + diff --git a/frontend/src/components/FormSection.vue b/frontend/src/components/FormSection.vue new file mode 100644 index 0000000..2e49634 --- /dev/null +++ b/frontend/src/components/FormSection.vue @@ -0,0 +1,52 @@ + + + diff --git a/frontend/src/components/GlobalSearchableSelect.test.js b/frontend/src/components/GlobalSearchableSelect.test.js new file mode 100644 index 0000000..e8a6688 --- /dev/null +++ b/frontend/src/components/GlobalSearchableSelect.test.js @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const componentSource = readFileSync(join(__dirname, "GlobalSearchableSelect.vue"), "utf8"); +const mainCss = readFileSync(resolve(__dirname, "../styles/main.css"), "utf8"); + +describe("GlobalSearchableSelect option panel", () => { + it("keeps disabled empty placeholder options out of the dropdown list", () => { + assert.match(componentSource, /function isPanelPlaceholderOption\(option\)/); + assert.match(componentSource, /\.filter\(\(\{ option \}\) => !isPanelPlaceholderOption\(option\)\)/); + }); + + it("lays option text out as a compact two-column business row", () => { + assert.match(mainCss, /\.global-select-layer \.global-select-option \{[\s\S]*grid-template-columns:\s*minmax\(0,\s*1fr\) auto;/); + assert.match(mainCss, /\.global-select-layer \.global-select-option-meta \{[\s\S]*grid-column:\s*2;/); + assert.match(mainCss, /\.global-select-layer \.global-select-option-sub \{[\s\S]*grid-column:\s*1 \/ -1;/); + }); +}); diff --git a/frontend/src/components/GlobalSearchableSelect.vue b/frontend/src/components/GlobalSearchableSelect.vue new file mode 100644 index 0000000..7b0ff3d --- /dev/null +++ b/frontend/src/components/GlobalSearchableSelect.vue @@ -0,0 +1,440 @@ + + + + + diff --git a/frontend/src/components/KpiCard.vue b/frontend/src/components/KpiCard.vue new file mode 100644 index 0000000..d2e30ee --- /dev/null +++ b/frontend/src/components/KpiCard.vue @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/components/MetricStrip.vue b/frontend/src/components/MetricStrip.vue new file mode 100644 index 0000000..a7bbc97 --- /dev/null +++ b/frontend/src/components/MetricStrip.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/components/NotificationStack.vue b/frontend/src/components/NotificationStack.vue new file mode 100644 index 0000000..f0c9fc8 --- /dev/null +++ b/frontend/src/components/NotificationStack.vue @@ -0,0 +1,37 @@ + + + diff --git a/frontend/src/components/OrgMindMap.vue b/frontend/src/components/OrgMindMap.vue new file mode 100644 index 0000000..5e99472 --- /dev/null +++ b/frontend/src/components/OrgMindMap.vue @@ -0,0 +1,553 @@ + + + + + diff --git a/frontend/src/components/OrgPermissionTree.vue b/frontend/src/components/OrgPermissionTree.vue new file mode 100644 index 0000000..a5fa858 --- /dev/null +++ b/frontend/src/components/OrgPermissionTree.vue @@ -0,0 +1,865 @@ + + + + + diff --git a/frontend/src/components/PaginationBar.vue b/frontend/src/components/PaginationBar.vue new file mode 100644 index 0000000..e02e341 --- /dev/null +++ b/frontend/src/components/PaginationBar.vue @@ -0,0 +1,111 @@ + + + diff --git a/frontend/src/components/ProcessWorkspace.vue b/frontend/src/components/ProcessWorkspace.vue new file mode 100644 index 0000000..e3d9833 --- /dev/null +++ b/frontend/src/components/ProcessWorkspace.vue @@ -0,0 +1,122 @@ + + + diff --git a/frontend/src/components/ProductWorkflowDrawer.vue b/frontend/src/components/ProductWorkflowDrawer.vue new file mode 100644 index 0000000..e730d34 --- /dev/null +++ b/frontend/src/components/ProductWorkflowDrawer.vue @@ -0,0 +1,1831 @@ + + + diff --git a/frontend/src/components/SmartDrawer.vue b/frontend/src/components/SmartDrawer.vue new file mode 100644 index 0000000..e503442 --- /dev/null +++ b/frontend/src/components/SmartDrawer.vue @@ -0,0 +1,90 @@ + + + diff --git a/frontend/src/components/StatusBadge.vue b/frontend/src/components/StatusBadge.vue new file mode 100644 index 0000000..3bb3391 --- /dev/null +++ b/frontend/src/components/StatusBadge.vue @@ -0,0 +1,26 @@ + + + diff --git a/frontend/src/components/StockLotTagSelect.vue b/frontend/src/components/StockLotTagSelect.vue new file mode 100644 index 0000000..227220f --- /dev/null +++ b/frontend/src/components/StockLotTagSelect.vue @@ -0,0 +1,436 @@ + + + + + diff --git a/frontend/src/components/StocktakeDialog.vue b/frontend/src/components/StocktakeDialog.vue new file mode 100644 index 0000000..5f8f498 --- /dev/null +++ b/frontend/src/components/StocktakeDialog.vue @@ -0,0 +1,550 @@ + + + diff --git a/frontend/src/components/TableControls.vue b/frontend/src/components/TableControls.vue new file mode 100644 index 0000000..426cd3c --- /dev/null +++ b/frontend/src/components/TableControls.vue @@ -0,0 +1,580 @@ + + + diff --git a/frontend/src/components/documentForms/DocumentArchiveActions.vue b/frontend/src/components/documentForms/DocumentArchiveActions.vue new file mode 100644 index 0000000..447319d --- /dev/null +++ b/frontend/src/components/documentForms/DocumentArchiveActions.vue @@ -0,0 +1,75 @@ + + + diff --git a/frontend/src/components/documentForms/DocumentGrid.vue b/frontend/src/components/documentForms/DocumentGrid.vue new file mode 100644 index 0000000..a55a7c4 --- /dev/null +++ b/frontend/src/components/documentForms/DocumentGrid.vue @@ -0,0 +1,42 @@ + + + diff --git a/frontend/src/components/documentForms/DocumentLineTable.vue b/frontend/src/components/documentForms/DocumentLineTable.vue new file mode 100644 index 0000000..8c7ede2 --- /dev/null +++ b/frontend/src/components/documentForms/DocumentLineTable.vue @@ -0,0 +1,44 @@ + + + diff --git a/frontend/src/components/documentForms/DocumentPaper.vue b/frontend/src/components/documentForms/DocumentPaper.vue new file mode 100644 index 0000000..92baf5b --- /dev/null +++ b/frontend/src/components/documentForms/DocumentPaper.vue @@ -0,0 +1,83 @@ + + + diff --git a/frontend/src/components/documentForms/WarehouseDocumentActionBar.vue b/frontend/src/components/documentForms/WarehouseDocumentActionBar.vue new file mode 100644 index 0000000..f7a903b --- /dev/null +++ b/frontend/src/components/documentForms/WarehouseDocumentActionBar.vue @@ -0,0 +1,47 @@ + + + diff --git a/frontend/src/components/documentForms/WarehouseDocumentFormShell.vue b/frontend/src/components/documentForms/WarehouseDocumentFormShell.vue new file mode 100644 index 0000000..6988450 --- /dev/null +++ b/frontend/src/components/documentForms/WarehouseDocumentFormShell.vue @@ -0,0 +1,50 @@ + + + diff --git a/frontend/src/components/documentForms/WarehouseDocumentSection.vue b/frontend/src/components/documentForms/WarehouseDocumentSection.vue new file mode 100644 index 0000000..bec8dc5 --- /dev/null +++ b/frontend/src/components/documentForms/WarehouseDocumentSection.vue @@ -0,0 +1,25 @@ + + + diff --git a/frontend/src/composables/useBodyScrollLock.js b/frontend/src/composables/useBodyScrollLock.js new file mode 100644 index 0000000..43cc41b --- /dev/null +++ b/frontend/src/composables/useBodyScrollLock.js @@ -0,0 +1,63 @@ +import { onUnmounted, watch } from "vue"; + +let lockCount = 0; +let savedOverflow = ""; +let savedPaddingRight = ""; + +function lockBody() { + if (typeof window === "undefined") { + return; + } + + if (lockCount === 0) { + savedOverflow = document.body.style.overflow; + savedPaddingRight = document.body.style.paddingRight; + + const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; + document.body.style.overflow = "hidden"; + if (scrollbarWidth > 0) { + document.body.style.paddingRight = `${scrollbarWidth}px`; + } + } + + lockCount += 1; +} + +function unlockBody() { + if (typeof window === "undefined" || lockCount <= 0) { + return; + } + + lockCount -= 1; + if (lockCount === 0) { + document.body.style.overflow = savedOverflow; + document.body.style.paddingRight = savedPaddingRight; + } +} + +export function useBodyScrollLock(source) { + let active = false; + + const stop = watch( + source, + (locked) => { + if (locked && !active) { + lockBody(); + active = true; + } + if (!locked && active) { + unlockBody(); + active = false; + } + }, + { immediate: true } + ); + + onUnmounted(() => { + stop(); + if (active) { + unlockBody(); + active = false; + } + }); +} diff --git a/frontend/src/data/catalog.js b/frontend/src/data/catalog.js new file mode 100644 index 0000000..11a742e --- /dev/null +++ b/frontend/src/data/catalog.js @@ -0,0 +1,22 @@ +export const moduleMatrix = [ + { + name: "原材料驱动", + description: "原材料入库成本驱动生产出库、成品库存、发货和财务对账。", + tables: ["po_receipt_item", "wh_stock_lot", "pp_work_order_material_issue", "pp_completion_receipt_item", "so_delivery"] + }, + { + name: "物料与库存一体化", + description: "统一物料编码贯穿采购、库存、生产和退货。", + tables: ["md_item", "md_material", "po_purchase_order_item", "wh_stock_lot", "wh_inventory_txn"] + }, + { + name: "工艺与报工联动", + description: "产品工艺路线决定工单工序,报工实时反哺异常预警与成本。", + tables: ["md_process_route", "md_process_route_operation", "pp_work_order_operation", "pp_operation_report", "pp_scrap_record"] + }, + { + name: "财务成本还原", + description: "材料、人工、设备、制造费用、返工和废料收益统一归集。", + tables: ["fi_accounting_period", "fi_overhead_entry", "fi_cost_allocation", "fi_statement_snapshot"] + } +]; diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..ea768ff --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,9 @@ +import { createApp } from "vue"; + +import App from "./App.vue"; +import router from "./router"; +import "./styles/main.css"; +import { setupStatusBadgeEnhancer } from "./utils/statusBadges"; + +createApp(App).use(router).mount("#app"); +setupStatusBadgeEnhancer(); diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..aa2a617 --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,506 @@ +import { createRouter, createWebHistory } from "vue-router"; + +import DashboardView from "../views/DashboardView.vue"; +import SalesPlanningView from "../views/SalesPlanningView.vue"; +import EmployeeManagementView from "../views/EmployeeManagementView.vue"; +import ProductManagementView from "../views/ProductManagementView.vue"; +import BomManagementView from "../views/BomManagementView.vue"; +import ProcessRouteManagementView from "../views/ProcessRouteManagementView.vue"; +import LoginView from "../views/LoginView.vue"; +import SupplierManagementView from "../views/SupplierManagementView.vue"; +import PurchaseOrderView from "../views/PurchaseOrderView.vue"; +import PurchaseReceiptView from "../views/PurchaseReceiptView.vue"; +import InventoryLedgerView from "../views/InventoryLedgerView.vue"; +import ProductionLedgerView from "../views/ProductionLedgerView.vue"; +import OperationReportView from "../views/OperationReportView.vue"; +import DeliveryManagementView from "../views/DeliveryManagementView.vue"; +import EquipmentManagementView from "../views/EquipmentManagementView.vue"; +import QualityInspectionView from "../views/QualityInspectionView.vue"; +import FinanceReportView from "../views/FinanceReportView.vue"; +import ProductSpecLiteView from "../views/ProductSpecLiteView.vue"; +import SupplierMaterialLiteView from "../views/SupplierMaterialLiteView.vue"; +import SystemPermissionView from "../views/SystemPermissionView.vue"; +import SystemExtensionView from "../views/SystemExtensionView.vue"; +import MasterDataImportView from "../views/MasterDataImportView.vue"; +import { isAuthenticated, readSession } from "../services/api"; +import { loadSmartOperationReportConfig } from "../services/systemFeatures"; + +const masterDataPermissionOrder = [ + { + routeName: "supplier-management", + permission: "MENU_SUPPLIER_MANAGEMENT" + }, + { + routeName: "supplier-material-lite", + permissionsAny: ["MENU_SUPPLIER_MANAGEMENT", "MENU_MATERIAL_MANAGEMENT"] + }, + { + routeName: "customer-management", + permission: "MENU_SALES_PLANNING" + }, + { + routeName: "product-spec-lite", + permissionsAny: ["MENU_PRODUCT_MANAGEMENT", "MENU_BOM_MANAGEMENT", "MENU_ROUTE_MANAGEMENT"] + }, + { + routeName: "employee-management", + permission: "MENU_EMPLOYEE_MANAGEMENT" + } +]; + +const procurementPermissionOrder = [ + { + routeName: "purchase-orders", + permission: "MENU_PURCHASE_ORDER" + }, + { + routeName: "purchase-receipts", + permission: "MENU_PURCHASE_RECEIPT" + }, + { + routeName: "quality-inspection", + permission: "MENU_QUALITY_INSPECTION" + }, + { + routeName: "inventory-ledger", + permission: "MENU_INVENTORY_LEDGER" + } +]; + +const productionPermissionOrder = [ + { + routeName: "production-ledger", + permission: "MENU_WORK_ORDER" + }, + { + routeName: "operation-reports", + permission: "MENU_OPERATION_REPORT" + } +]; + +const deliveryPermissionOrder = [ + { + routeName: "delivery-management", + permission: "MENU_DELIVERY_MANAGEMENT" + } +]; + +const systemPermissionOrder = [ + { + routeName: "system-permissions", + permission: "MENU_SYSTEM_PERMISSION" + }, + { + routeName: "system-extension", + permission: "MENU_SYSTEM_EXTENSION" + } +]; + +const navPermissionOrder = [ + { routeName: "dashboard", permission: "MENU_DASHBOARD" }, + { + routeName: "master-data-workspace", + permissionsAny: [ + "MENU_PRODUCT_MANAGEMENT", + "MENU_SUPPLIER_MANAGEMENT", + "MENU_MATERIAL_MANAGEMENT", + "MENU_BOM_MANAGEMENT", + "MENU_ROUTE_MANAGEMENT", + "MENU_EMPLOYEE_MANAGEMENT", + "MENU_SALES_PLANNING" + ] + }, + { routeName: "order-list", permission: "MENU_SALES_PLANNING" }, + { + routeName: "procurement-workspace", + permissionsAny: [ + "MENU_PURCHASE_ORDER", + "MENU_PURCHASE_RECEIPT", + "MENU_QUALITY_INSPECTION", + "MENU_INVENTORY_LEDGER" + ] + }, + { + routeName: "production-workspace", + permissionsAny: [ + "MENU_WORK_ORDER", + "MENU_OPERATION_REPORT" + ] + }, + { + routeName: "delivery-workspace", + permissionsAny: ["MENU_DELIVERY_MANAGEMENT", "MENU_RETURN_MANAGEMENT"] + }, + { routeName: "finance-reports", permission: "MENU_FINANCE_REPORT" }, + { + routeName: "system-management", + permissionsAny: ["MENU_SYSTEM_PERMISSION", "MENU_SYSTEM_EXTENSION"] + } +]; + +function hasSystemAdminFallback(permission, roleCodes = []) { + return String(permission || "").startsWith("MENU_SYSTEM_") && roleCodes.includes("ADMIN"); +} + +function getFirstAllowedMasterDataRoute(permissionCodes = readSession()?.permission_codes || []) { + const matched = masterDataPermissionOrder.find((item) => { + if (item.permission) { + return permissionCodes.includes(item.permission); + } + return item.permissionsAny?.some((permission) => permissionCodes.includes(permission)); + }); + return matched?.routeName || "dashboard"; +} + +function getFirstAllowedProcurementRoute(permissionCodes = readSession()?.permission_codes || []) { + const matched = procurementPermissionOrder.find((item) => permissionCodes.includes(item.permission)); + return matched?.routeName || "dashboard"; +} + +function getFirstAllowedProductionRoute(permissionCodes = readSession()?.permission_codes || []) { + const matched = productionPermissionOrder.find((item) => permissionCodes.includes(item.permission)); + return matched?.routeName || "dashboard"; +} + +function getFirstAllowedDeliveryRoute(permissionCodes = readSession()?.permission_codes || []) { + const matched = deliveryPermissionOrder.find((item) => permissionCodes.includes(item.permission)); + return matched?.routeName || "dashboard"; +} + +function getFirstAllowedSystemRoute( + permissionCodes = readSession()?.permission_codes || [], + roleCodes = readSession()?.role_codes || [] +) { + const matched = systemPermissionOrder.find( + (item) => permissionCodes.includes(item.permission) || hasSystemAdminFallback(item.permission, roleCodes) + ); + return matched?.routeName || "dashboard"; +} + +function getFirstAllowedRoute() { + const session = readSession(); + const permissionCodes = session?.permission_codes || []; + const roleCodes = session?.role_codes || []; + const matched = navPermissionOrder.find((item) => { + if (item.permission) { + return permissionCodes.includes(item.permission) || hasSystemAdminFallback(item.permission, roleCodes); + } + if (item.permissionsAny) { + return item.permissionsAny.some((permission) => permissionCodes.includes(permission) || hasSystemAdminFallback(permission, roleCodes)); + } + return false; + }); + if (matched?.routeName === "master-data-workspace") { + return getFirstAllowedMasterDataRoute(permissionCodes); + } + if (matched?.routeName === "procurement-workspace") { + return getFirstAllowedProcurementRoute(permissionCodes); + } + if (matched?.routeName === "production-workspace") { + return getFirstAllowedProductionRoute(permissionCodes); + } + if (matched?.routeName === "delivery-workspace") { + return getFirstAllowedDeliveryRoute(permissionCodes); + } + if (matched?.routeName === "system-management") { + return getFirstAllowedSystemRoute(permissionCodes, roleCodes); + } + return matched?.routeName || "dashboard"; +} + +function hasRoutePermission(to, permissionCodes, roleCodes = []) { + if (to.meta.permission) { + return permissionCodes.includes(to.meta.permission) || hasSystemAdminFallback(to.meta.permission, roleCodes); + } + + if (Array.isArray(to.meta.permissionsAny)) { + return to.meta.permissionsAny.some((permission) => permissionCodes.includes(permission) || hasSystemAdminFallback(permission, roleCodes)); + } + + return true; +} + +const routes = [ + { + path: "/login", + name: "login", + component: LoginView, + meta: { title: "工业风登录", layout: "auth" } + }, + { + path: "/", + name: "dashboard", + component: DashboardView, + meta: { title: "原材料批次生命周期追踪", requiresAuth: true, permission: "MENU_DASHBOARD" } + }, + { + path: "/order-planning", + alias: "/sales-planning", + name: "sales-planning", + redirect: { name: "order-list" }, + meta: { title: "订单与计划中心", requiresAuth: true, permission: "MENU_SALES_PLANNING" } + }, + { + path: "/customer-management", + name: "customer-management", + component: SalesPlanningView, + meta: { title: "客户名录", requiresAuth: true, permission: "MENU_SALES_PLANNING" } + }, + { + path: "/order-list", + name: "order-list", + component: SalesPlanningView, + meta: { title: "销售订单列表", requiresAuth: true, permission: "MENU_SALES_PLANNING" } + }, + { + path: "/procurement-workspace", + name: "procurement-workspace", + redirect: () => ({ name: getFirstAllowedProcurementRoute() }), + meta: { + title: "采购与库存", + requiresAuth: true, + permissionsAny: [ + "MENU_PURCHASE_ORDER", + "MENU_PURCHASE_RECEIPT", + "MENU_QUALITY_INSPECTION", + "MENU_INVENTORY_LEDGER", + "MENU_COMPLETION_RECEIPT" + ] + } + }, + { + path: "/production-workspace", + name: "production-workspace", + redirect: () => ({ name: getFirstAllowedProductionRoute() }), + meta: { + title: "生产执行", + requiresAuth: true, + permissionsAny: [ + "MENU_WORK_ORDER", + "MENU_OPERATION_REPORT" + ] + } + }, + { + path: "/delivery-workspace", + name: "delivery-workspace", + redirect: () => ({ name: getFirstAllowedDeliveryRoute() }), + meta: { + title: "发货概览", + requiresAuth: true, + permissionsAny: ["MENU_DELIVERY_MANAGEMENT", "MENU_RETURN_MANAGEMENT"] + } + }, + { + path: "/master-data-workspace", + name: "master-data-workspace", + redirect: () => ({ name: getFirstAllowedMasterDataRoute() }), + meta: { + title: "基础资料", + requiresAuth: true, + permissionsAny: [ + "MENU_PRODUCT_MANAGEMENT", + "MENU_SUPPLIER_MANAGEMENT", + "MENU_MATERIAL_MANAGEMENT", + "MENU_BOM_MANAGEMENT", + "MENU_ROUTE_MANAGEMENT", + "MENU_EMPLOYEE_MANAGEMENT", + "MENU_SALES_PLANNING" + ] + } + }, + { + path: "/supplier-material-lite", + name: "supplier-material-lite", + component: SupplierMaterialLiteView, + meta: { + title: "原材料名录", + requiresAuth: true, + permissionsAny: ["MENU_SUPPLIER_MANAGEMENT", "MENU_MATERIAL_MANAGEMENT"] + } + }, + { + path: "/product-spec-lite", + name: "product-spec-lite", + component: ProductSpecLiteView, + meta: { + title: "产品需规清单", + requiresAuth: true, + permissionsAny: ["MENU_PRODUCT_MANAGEMENT", "MENU_BOM_MANAGEMENT", "MENU_ROUTE_MANAGEMENT"] + } + }, + { + path: "/supplier-management", + name: "supplier-management", + component: SupplierManagementView, + meta: { title: "供应商名录", requiresAuth: true, permission: "MENU_SUPPLIER_MANAGEMENT" } + }, + { + path: "/purchase-orders", + name: "purchase-orders", + component: PurchaseOrderView, + meta: { title: "采购订单", requiresAuth: true, permission: "MENU_PURCHASE_ORDER" } + }, + { + path: "/purchase-receipts", + name: "purchase-receipts", + component: PurchaseReceiptView, + meta: { title: "到货入库", requiresAuth: true, permission: "MENU_PURCHASE_RECEIPT" } + }, + { + path: "/inventory-ledger", + name: "inventory-ledger", + component: InventoryLedgerView, + meta: { title: "原材料库存台账与批次", requiresAuth: true, permission: "MENU_INVENTORY_LEDGER" } + }, + { + path: "/production-ledger", + name: "production-ledger", + component: ProductionLedgerView, + meta: { title: "生产台账", requiresAuth: true, permission: "MENU_WORK_ORDER" } + }, + { + path: "/work-order-ledger", + name: "work-order-ledger", + redirect: { name: "production-ledger" }, + meta: { title: "生产台账", requiresAuth: true, permission: "MENU_WORK_ORDER" } + }, + { + path: "/operation-reports", + name: "operation-reports", + component: OperationReportView, + meta: { title: "工序报工", requiresAuth: true, permission: "MENU_OPERATION_REPORT" } + }, + { + path: "/scrap-rework", + name: "scrap-rework", + redirect: { name: "operation-reports" }, + meta: { title: "工序报工", requiresAuth: true, permission: "MENU_OPERATION_REPORT" } + }, + { + path: "/delivery-management", + name: "delivery-management", + component: DeliveryManagementView, + meta: { title: "发货台账", requiresAuth: true, permission: "MENU_DELIVERY_MANAGEMENT" } + }, + { + path: "/equipment-management", + name: "equipment-management", + component: EquipmentManagementView, + meta: { title: "设备管理", requiresAuth: true, permission: "MENU_EQUIPMENT_MANAGEMENT" } + }, + { + path: "/quality-inspection", + name: "quality-inspection", + component: QualityInspectionView, + meta: { title: "质量检验", requiresAuth: true, permission: "MENU_QUALITY_INSPECTION" } + }, + { + path: "/finance-reports", + name: "finance-reports", + component: FinanceReportView, + meta: { title: "冲压产品材料成本核算", requiresAuth: true, permission: "MENU_FINANCE_REPORT" } + }, + { + path: "/return-management", + name: "return-management", + redirect: { name: "inventory-ledger" }, + meta: { title: "退货处理", requiresAuth: true, permission: "MENU_INVENTORY_LEDGER" } + }, + { + path: "/material-management", + name: "material-management", + redirect: { name: "supplier-material-lite" }, + meta: { title: "原材料名录", requiresAuth: true, permission: "MENU_MATERIAL_MANAGEMENT" } + }, + { + path: "/bom-management", + name: "bom-management", + component: BomManagementView, + meta: { title: "BOM管理", requiresAuth: true, permission: "MENU_BOM_MANAGEMENT" } + }, + { + path: "/process-route-management", + name: "process-route-management", + component: ProcessRouteManagementView, + meta: { title: "工艺路线管理", requiresAuth: true, permission: "MENU_ROUTE_MANAGEMENT" } + }, + { + path: "/employee-management", + name: "employee-management", + component: EmployeeManagementView, + meta: { title: "员工管理", requiresAuth: true, permission: "MENU_EMPLOYEE_MANAGEMENT" } + }, + { + path: "/master-data-import", + name: "master-data-import", + component: MasterDataImportView, + meta: { + title: "基础资料导入导出", + requiresAuth: true, + permissionsAny: ["MENU_PRODUCT_MANAGEMENT", "MENU_SUPPLIER_MANAGEMENT", "MENU_MATERIAL_MANAGEMENT", "MENU_BOM_MANAGEMENT", "MENU_ROUTE_MANAGEMENT"] + } + }, + { + path: "/product-management", + name: "product-management", + component: ProductManagementView, + meta: { title: "产品管理", requiresAuth: true, permission: "MENU_PRODUCT_MANAGEMENT" } + }, + { + path: "/system-management", + name: "system-management", + redirect: () => ({ name: getFirstAllowedSystemRoute() }), + meta: { + title: "系统管理", + requiresAuth: true, + permissionsAny: ["MENU_SYSTEM_PERMISSION", "MENU_SYSTEM_EXTENSION"] + } + }, + { + path: "/system-permissions", + name: "system-permissions", + component: SystemPermissionView, + meta: { title: "系统权限管理", requiresAuth: true, permission: "MENU_SYSTEM_PERMISSION" } + }, + { + path: "/system-extension", + name: "system-extension", + component: SystemExtensionView, + meta: { title: "系统拓展", requiresAuth: true, permission: "MENU_SYSTEM_EXTENSION" } + } +]; + +const router = createRouter({ + history: createWebHistory(), + routes +}); + +router.beforeEach(async (to) => { + if (to.meta.requiresAuth && !isAuthenticated()) { + return { name: "login", query: { redirect: to.fullPath } }; + } + + if (to.name === "login" && isAuthenticated()) { + return { name: getFirstAllowedRoute() }; + } + + if (to.meta.requiresAuth) { + const session = readSession(); + const permissionCodes = session?.permission_codes || []; + const roleCodes = session?.role_codes || []; + if (!hasRoutePermission(to, permissionCodes, roleCodes)) { + return { name: getFirstAllowedRoute() }; + } + if (to.name === "operation-reports") { + const config = await loadSmartOperationReportConfig(); + if (config.enabled === false) { + return permissionCodes.includes("MENU_WORK_ORDER") ? { name: "production-ledger" } : { name: "dashboard" }; + } + } + } + + return true; +}); + +export default router; diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js new file mode 100644 index 0000000..c74d7e3 --- /dev/null +++ b/frontend/src/services/api.js @@ -0,0 +1,258 @@ +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000/api"; +const SESSION_KEY = "forgeflow.erp.session"; + +async function parseResponse(response) { + const contentType = response.headers.get("content-type") || ""; + const isJson = contentType.includes("application/json"); + const data = isJson ? await response.json() : await response.text(); + + if (!response.ok) { + const detail = typeof data === "object" && data?.detail ? data.detail : `Request failed: ${response.status}`; + throw new Error(detail); + } + + return data; +} + +function isSessionExpired(session) { + if (!session?.access_token || !session?.expires_at) { + return true; + } + + const expiresAt = Date.parse(session.expires_at); + if (Number.isNaN(expiresAt)) { + return true; + } + + return expiresAt <= Date.now(); +} + +export async function requestJson(path, options = {}) { + const { skipAuthRedirect = false, headers = {}, ...fetchOptions } = options; + const session = readSession(); + const authHeaders = + session?.access_token && !isSessionExpired(session) + ? { Authorization: `Bearer ${session.access_token}` } + : {}; + const response = await fetch(`${API_BASE_URL}${path}`, { + headers: { + "Content-Type": "application/json", + ...authHeaders, + ...headers + }, + ...fetchOptions + }); + + if ((response.status === 401 || response.status === 403) && !skipAuthRedirect) { + clearSession(); + if (typeof window !== "undefined" && window.location.pathname !== "/login") { + window.location.href = "/login"; + } + } + + return parseResponse(response); +} + +export async function fetchResource(path, fallbackData) { + try { + return await requestJson(path, { method: "GET" }); + } catch (error) { + return fallbackData; + } +} + +export async function postResource(path, payload) { + return requestJson(path, { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export async function uploadResource(path, formData) { + const session = readSession(); + const authHeaders = + session?.access_token && !isSessionExpired(session) + ? { Authorization: `Bearer ${session.access_token}` } + : {}; + const response = await fetch(`${API_BASE_URL}${path}`, { + method: "POST", + headers: authHeaders, + body: formData + }); + + if ((response.status === 401 || response.status === 403) && typeof window !== "undefined" && window.location.pathname !== "/login") { + clearSession(); + window.location.href = "/login"; + } + + return parseResponse(response); +} + +function getDownloadFilename(response, fallbackFilename) { + const disposition = response.headers.get("content-disposition") || ""; + const encodedMatch = disposition.match(/filename\*=UTF-8''([^;]+)/i); + if (encodedMatch?.[1]) { + try { + return decodeURIComponent(encodedMatch[1]); + } catch (error) { + return fallbackFilename; + } + } + + const plainMatch = disposition.match(/filename="?([^";]+)"?/i); + return plainMatch?.[1] || fallbackFilename; +} + +export async function downloadResource(path, fallbackFilename = "download.xlsx") { + const session = readSession(); + const authHeaders = + session?.access_token && !isSessionExpired(session) + ? { Authorization: `Bearer ${session.access_token}` } + : {}; + const response = await fetch(`${API_BASE_URL}${path}`, { + method: "GET", + headers: authHeaders + }); + + if ((response.status === 401 || response.status === 403) && typeof window !== "undefined" && window.location.pathname !== "/login") { + clearSession(); + window.location.href = "/login"; + } + + if (!response.ok) { + return parseResponse(response); + } + + const blob = await response.blob(); + const filename = getDownloadFilename(response, fallbackFilename); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + return { filename }; +} + +export async function postDownloadResource(path, payload, fallbackFilename = "download.zip") { + const session = readSession(); + const authHeaders = + session?.access_token && !isSessionExpired(session) + ? { Authorization: `Bearer ${session.access_token}` } + : {}; + const response = await fetch(`${API_BASE_URL}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...authHeaders + }, + body: JSON.stringify(payload) + }); + + if ((response.status === 401 || response.status === 403) && typeof window !== "undefined" && window.location.pathname !== "/login") { + clearSession(); + window.location.href = "/login"; + } + + if (!response.ok) { + return parseResponse(response); + } + + const blob = await response.blob(); + const filename = getDownloadFilename(response, fallbackFilename); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + return { filename }; +} + +export async function openResource(path) { + const session = readSession(); + const authHeaders = + session?.access_token && !isSessionExpired(session) + ? { Authorization: `Bearer ${session.access_token}` } + : {}; + const response = await fetch(`${API_BASE_URL}${path}`, { + method: "GET", + headers: authHeaders + }); + + if ((response.status === 401 || response.status === 403) && typeof window !== "undefined" && window.location.pathname !== "/login") { + clearSession(); + window.location.href = "/login"; + } + + if (!response.ok) { + return parseResponse(response); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + window.open(url, "_blank", "noopener,noreferrer"); + window.setTimeout(() => window.URL.revokeObjectURL(url), 60_000); + return { opened: true }; +} + +export async function putResource(path, payload) { + return requestJson(path, { + method: "PUT", + body: JSON.stringify(payload) + }); +} + +export async function deleteResource(path) { + return requestJson(path, { + method: "DELETE" + }); +} + +export function saveSession(user) { + localStorage.setItem(SESSION_KEY, JSON.stringify(user)); +} + +export function readSession() { + const raw = localStorage.getItem(SESSION_KEY); + if (!raw) { + return null; + } + + try { + const session = JSON.parse(raw); + if (isSessionExpired(session)) { + localStorage.removeItem(SESSION_KEY); + return null; + } + return session; + } catch (error) { + console.warn("Failed to parse session", error); + localStorage.removeItem(SESSION_KEY); + return null; + } +} + +export function clearSession() { + localStorage.removeItem(SESSION_KEY); +} + +export function isAuthenticated() { + return Boolean(readSession()); +} + +export async function login(username, password) { + return requestJson("/auth/login", { + method: "POST", + body: JSON.stringify({ username, password }), + skipAuthRedirect: true + }); +} + +export function buildApiUrl(path) { + return `${API_BASE_URL}${path}`; +} diff --git a/frontend/src/services/employeeOptions.js b/frontend/src/services/employeeOptions.js new file mode 100644 index 0000000..ecdc6c3 --- /dev/null +++ b/frontend/src/services/employeeOptions.js @@ -0,0 +1,48 @@ +import { fetchResource, readSession } from "./api"; + +export const PERSONNEL_PERMISSION_CODES = { + SALES_ORDER: "MENU_SALES_PLANNING", + PURCHASE_ORDER: "MENU_PURCHASE_ORDER", + PURCHASE_RECEIPT: "MENU_PURCHASE_RECEIPT", + INVENTORY_LEDGER: "MENU_INVENTORY_LEDGER", + DELIVERY_MANAGEMENT: "MENU_DELIVERY_MANAGEMENT", + EQUIPMENT_MANAGEMENT: "MENU_EQUIPMENT_MANAGEMENT" +}; + +export function normalizeEmployeeOption(row) { + return { + id: Number(row.employee_id ?? row.id), + employee_id: Number(row.employee_id ?? row.id), + employee_name: row.employee_name || row.name || "", + mobile: row.mobile || "", + dept_name: row.dept_name || "", + job_title: row.job_title || "", + username: row.username || "", + role_names: Array.isArray(row.role_names) ? row.role_names : [], + permission_codes: Array.isArray(row.permission_codes) ? row.permission_codes : [], + has_system_user: Boolean(row.has_system_user) + }; +} + +export async function fetchPermissionEmployeeOptions(permissionCode, limit = 500) { + const params = new URLSearchParams({ + permission_code: permissionCode, + limit: String(limit) + }); + const rows = await fetchResource(`/master-data/employee-options?${params.toString()}`, []); + return Array.isArray(rows) ? rows.map(normalizeEmployeeOption) : []; +} + +export function defaultEmployeeIdFromSession(options) { + const currentEmployeeId = readSession()?.employee_id; + if (!currentEmployeeId) { + return ""; + } + const matched = options.find((employee) => String(employee.employee_id) === String(currentEmployeeId)); + return matched ? matched.employee_id : ""; +} + +export function employeeOptionLabel(employee) { + const extra = [employee.dept_name, employee.role_names?.join("、")].filter(Boolean).join(" · "); + return extra ? `${employee.employee_name} · ${extra}` : employee.employee_name; +} diff --git a/frontend/src/services/systemFeatures.js b/frontend/src/services/systemFeatures.js new file mode 100644 index 0000000..1e48444 --- /dev/null +++ b/frontend/src/services/systemFeatures.js @@ -0,0 +1,34 @@ +import { fetchResource } from "./api"; + +let smartOperationReportConfigCache = null; +let smartOperationReportConfigPromise = null; + +export async function loadSmartOperationReportConfig(force = false) { + if (!force && smartOperationReportConfigCache) { + return smartOperationReportConfigCache; + } + if (!force && smartOperationReportConfigPromise) { + return smartOperationReportConfigPromise; + } + + smartOperationReportConfigPromise = fetchResource("/system-extension/smart-operation-report", { + config_code: "SMART_OPERATION_REPORT_ENABLED", + config_name: "对接智能报工小程序", + enabled: true, + config_value: "开启", + remark: "" + }).then((config) => { + smartOperationReportConfigCache = { + ...config, + enabled: config?.enabled !== false + }; + smartOperationReportConfigPromise = null; + return smartOperationReportConfigCache; + }); + return smartOperationReportConfigPromise; +} + +export function resetSmartOperationReportConfigCache(config = null) { + smartOperationReportConfigCache = config; + smartOperationReportConfigPromise = null; +} diff --git a/frontend/src/styles/actionTableButtons.test.js b/frontend/src/styles/actionTableButtons.test.js new file mode 100644 index 0000000..b99889d --- /dev/null +++ b/frontend/src/styles/actionTableButtons.test.js @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const styles = readFileSync(join(__dirname, "main.css"), "utf8"); +const viewSourceByName = new Map( + [ + "InventoryLedgerView.vue", + "PurchaseOrderView.vue", + "SalesPlanningView.vue", + "SupplierMaterialLiteView.vue", + "SystemExtensionView.vue" + ].map((fileName) => [fileName, readFileSync(join(__dirname, "../views", fileName), "utf8")]) +); + +function cssRuleBlock(selectorStart) { + const selectorIndex = styles.indexOf(selectorStart); + assert.notEqual(selectorIndex, -1, `Missing CSS rule for ${selectorStart}`); + const bodyStart = styles.indexOf("{", selectorIndex); + assert.notEqual(bodyStart, -1, `Missing CSS body for ${selectorStart}`); + const bodyEnd = styles.indexOf("}", bodyStart); + assert.notEqual(bodyEnd, -1, `Missing CSS close brace for ${selectorStart}`); + return styles.slice(bodyStart + 1, bodyEnd); +} + +describe("table action button layout and tones", () => { + it("keeps wide-table action cells as real table cells with enough width", () => { + const wideActionHeaderRule = cssRuleBlock("body .table-view-wide .data-table:has(td.action-row) thead th:last-child"); + + assert.match(styles, /--table-action-column-width:\s*300px;/); + assert.match(styles, /body \.table-view-wide \.data-table td\.action-row,[\s\S]*?display:\s*table-cell !important;/); + assert.match(wideActionHeaderRule, /width:\s*var\(--table-action-column-width\) !important;/); + assert.match(wideActionHeaderRule, /text-align:\s*center !important;/); + }); + + it("centers compact overview action headers over icon-only action buttons", () => { + const overviewActionHeaderRule = cssRuleBlock("body .table-view-overview .data-table:has(td.action-row) thead th:last-child"); + + assert.match(overviewActionHeaderRule, /text-align:\s*center !important;/); + }); + + it("adds stronger table-specific color treatments for edit and delete actions", () => { + assert.match(styles, /body \.data-table button\.semantic-action-button\.semantic-action-button\[data-button-tone="info"\]/); + assert.match(styles, /body \.data-table button\.semantic-action-button\.semantic-action-button\[data-button-tone="danger"\]/); + assert.match(styles, /--button-bg:\s*linear-gradient\(180deg,\s*#eff8ff/); + assert.match(styles, /--button-bg:\s*linear-gradient\(180deg,\s*#fff1f2/); + }); + + it("marks every table operation column with the standard action-row cell class", () => { + viewSourceByName.forEach((source, fileName) => { + const operationTables = source + .split(/ /]*>\s*操作\s*<\/th>/.test(chunk)); + + assert.ok(operationTables.length > 0, `${fileName} should include operation tables for this coverage`); + operationTables.forEach((chunk, index) => { + assert.match(chunk, / { + const transferToolbarSources = [ + readFileSync(join(__dirname, "../views/SupplierManagementView.vue"), "utf8"), + readFileSync(join(__dirname, "../views/SupplierMaterialLiteView.vue"), "utf8"), + readFileSync(join(__dirname, "../views/SalesPlanningView.vue"), "utf8") + ]; + + transferToolbarSources.forEach((source, index) => { + assert.match(source, /supplier-action-import/, `toolbar source ${index + 1} should mark import buttons`); + assert.match(source, /supplier-action-export/, `toolbar source ${index + 1} should mark export buttons`); + }); + + assert.match(styles, /\.supplier-action-import/); + assert.match(styles, /\.supplier-action-export/); + }); +}); diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css new file mode 100644 index 0000000..616ea50 --- /dev/null +++ b/frontend/src/styles/main.css @@ -0,0 +1,17322 @@ +:root { + color-scheme: dark; + --ui-font-display: "Bahnschrift", "DIN Alternate", "Segoe UI", sans-serif; + --ui-font-body: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif; + --ui-bg-canvas: #eef3f7; + --ui-bg-panel: rgba(255, 255, 255, 0.92); + --ui-bg-panel-solid: #ffffff; + --ui-bg-subtle: #f6f8fb; + --ui-ink: #15202b; + --ui-ink-soft: #4f6275; + --ui-ink-muted: #7b8da0; + --ui-line: rgba(58, 82, 105, 0.14); + --ui-line-strong: rgba(58, 82, 105, 0.24); + --ui-steel: #2563eb; + --ui-steel-soft: #dbeafe; + --ui-cyan: #0ea5e9; + --ui-green: #16a34a; + --ui-amber: #d97706; + --ui-orange: #ea580c; + --ui-red: #dc2626; + --ui-gray: #64748b; + --ui-radius-sm: 8px; + --ui-radius-md: 12px; + --ui-radius-lg: 18px; + --ui-radius-xl: 24px; + --ui-shadow-card: 0 16px 42px rgba(15, 23, 42, 0.08); + --ui-shadow-float: 0 28px 80px rgba(15, 23, 42, 0.18); + --ui-focus: 0 0 0 3px rgba(37, 99, 235, 0.18); + --ui-transition-fast: 140ms ease; + --ui-transition-normal: 220ms cubic-bezier(0.2, 0.8, 0.2, 1); + --ui-sidebar-width: 220px; + --ui-sidebar-collapsed-width: 76px; + --ui-z-global-select: 3100; + --ui-z-tooltip: 3200; + --table-action-column-width: 300px; + --bg: #09111b; + --bg-deep: #050a11; + --panel: rgba(14, 27, 42, 0.82); + --panel-strong: rgba(16, 34, 53, 0.92); + --line: rgba(109, 152, 189, 0.22); + --text: #e5eef7; + --muted: #89a1b8; + --steel: #4fb4ff; + --copper: #f29d52; + --warning: #ffd166; + --danger: #ff6f61; + --success: #45d6a8; + --shadow: 0 24px 80px rgba(0, 0, 0, 0.35); + --font-ui: "Bahnschrift", "DIN Alternate", "Segoe UI", sans-serif; +} + +body { + font-family: var(--ui-font-body); + color: var(--ui-ink); + background: + radial-gradient(circle at 16% 8%, rgba(37, 99, 235, 0.12), transparent 28%), + linear-gradient(135deg, #eef3f7 0%, #f8fafc 48%, #e8eef5 100%); +} + +button, +input, +select, +textarea { + font: inherit; +} + +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +a:focus-visible { + outline: none; + box-shadow: var(--ui-focus); +} + +/* ERP visual upgrade action and helper system */ +.app-icon-primary { + color: var(--ui-steel); +} + +.action-button, +.primary-button, +.ghost-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 38px; + border: 1px solid transparent; + border-radius: 12px; + font-weight: 700; + line-height: 1; + cursor: pointer; + transition: + transform var(--ui-transition-fast), + box-shadow var(--ui-transition-fast), + background var(--ui-transition-fast), + border-color var(--ui-transition-fast), + color var(--ui-transition-fast); +} + +.action-button:hover:not(:disabled), +.primary-button:hover:not(:disabled), +.ghost-button:hover:not(:disabled) { + transform: translateY(-1px); +} + +.action-button:active:not(:disabled), +.primary-button:active:not(:disabled), +.ghost-button:active:not(:disabled) { + transform: translateY(0); +} + +.action-button:disabled, +.primary-button:disabled, +.ghost-button:disabled { + cursor: not-allowed; + opacity: 0.54; + transform: none; + box-shadow: none; +} + +.action-button-primary, +.primary-button { + color: #ffffff; + background: linear-gradient(135deg, var(--ui-steel), var(--ui-cyan)); + border-color: rgba(37, 99, 235, 0.18); + box-shadow: 0 12px 28px rgba(37, 99, 235, 0.22); +} + +.action-button-secondary, +.action-button-ghost, +.ghost-button { + color: var(--ui-ink-soft); + background: rgba(255, 255, 255, 0.9); + border-color: var(--ui-line); + box-shadow: 0 8px 18px rgba(15, 23, 42, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.78); +} + +.action-button-success { + color: #ffffff; + background: linear-gradient(135deg, #15803d, var(--ui-green)); + border-color: rgba(22, 163, 74, 0.2); + box-shadow: 0 12px 28px rgba(22, 163, 74, 0.18); +} + +.action-button-warning { + color: #78350f; + background: linear-gradient(135deg, #fef3c7, #fdba74); + border-color: rgba(217, 119, 6, 0.2); + box-shadow: 0 12px 28px rgba(217, 119, 6, 0.16); +} + +.action-button-danger, +.danger-ghost { + color: var(--ui-red); + background: rgba(254, 242, 242, 0.95); + border-color: rgba(220, 38, 38, 0.22); + box-shadow: 0 8px 18px rgba(220, 38, 38, 0.08); +} + +.action-button-sm { + min-height: 32px; + padding: 0 10px; + font-size: 12px; +} + +.action-button-md { + padding: 0 14px; + font-size: 13px; +} + +.action-button-lg { + min-height: 44px; + padding: 0 18px; + font-size: 14px; +} + +.action-button-icon-only { + width: 38px; + min-width: 38px; + padding: 0; +} + +.action-button-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.action-button-spinner { + width: 14px; + height: 14px; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 999px; + animation: action-button-spin 760ms linear infinite; +} + +@keyframes action-button-spin { + to { + transform: rotate(360deg); + } +} + +.icon-button .app-icon { + width: 17px; + height: 17px; + font-size: 17px; +} + +.workflow-drawer-head .icon-button { + flex: 0 0 auto; + width: 36px; + min-width: 36px; + height: 36px; + min-height: 36px !important; + padding: 0 !important; + border-radius: 12px !important; +} + +.form-drawer-description { + max-width: min(680px, 72vw); + margin: 6px 0 0; + color: var(--ui-ink-muted); + font-size: 12px; + font-weight: 500; + line-height: 1.5; +} + +.field-hint { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + margin-left: 6px; + color: var(--ui-ink-muted); + cursor: help; + vertical-align: middle; +} + +.field-hint .app-icon { + width: 16px; + height: 16px; +} + +.field-hint-popover { + position: fixed; + left: 0; + top: 0; + z-index: var(--ui-z-tooltip); + width: max-content; + max-width: min(280px, calc(100vw - 32px)); + transform: translateY(4px); + opacity: 0; + pointer-events: none; + padding: 10px 12px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 12px; + color: #ffffff; + background: rgba(15, 23, 42, 0.94); + box-shadow: var(--ui-shadow-float); + font-size: 12px; + font-weight: 500; + line-height: 1.5; + white-space: normal; + transition: opacity var(--ui-transition-fast), transform var(--ui-transition-fast); +} + +.field-hint-popover::after { + content: ""; + position: absolute; + left: var(--field-hint-arrow-left, 50%); + top: 100%; + width: 8px; + height: 8px; + transform: translate(-50%, -4px) rotate(45deg); + background: rgba(15, 23, 42, 0.94); +} + +.field-hint-popover-bottom::after { + top: auto; + bottom: 100%; + transform: translate(-50%, 4px) rotate(45deg); +} + +.field-hint-popover-open { + opacity: 1; + transform: translateY(0); +} + +/* ERP visual upgrade shell and icon system */ +.app-icon { + display: block; + width: 1em; + height: 1em; + color: currentColor; + fill: none; + flex: 0 0 auto; +} + +.app-icon-default { + color: currentColor; +} + +.app-icon-muted { + color: var(--ui-ink-muted); +} + +.app-icon-accent { + color: var(--ui-steel); +} + +.app-icon-warning { + color: var(--ui-amber); +} + +.app-icon-danger { + color: var(--ui-red); +} + +.app-icon-success { + color: var(--ui-green); +} + +.shell.admin-shell.app-shell { + grid-template-columns: var(--ui-sidebar-width) minmax(0, 1fr); + background: + radial-gradient(circle at 8% 0%, rgba(14, 165, 233, 0.1), transparent 32%), + linear-gradient(135deg, #eef3f7 0%, #f7fafc 46%, #e8eef5 100%); +} + +.shell.admin-shell.app-shell-collapsed, +.shell.admin-shell.app-shell.sidebar-collapsed { + grid-template-columns: var(--ui-sidebar-collapsed-width) minmax(0, 1fr); +} + +.admin-shell.app-shell .sidebar { + background: + linear-gradient(180deg, #243447 0%, #1e2b3b 52%, #162331 100%); + color: #c8d5e4; + box-shadow: + 1px 0 0 rgba(255, 255, 255, 0.05), + 14px 0 34px rgba(15, 23, 42, 0.16); +} + +.admin-shell.app-shell .brand { + background: linear-gradient(135deg, rgba(37, 99, 235, 0.18), rgba(14, 165, 233, 0.06)); + box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.08); +} + +.admin-shell.app-shell .nav-stage-list { + padding: 12px 8px; +} + +.admin-shell.app-shell .nav-stage { + grid-template-columns: 30px minmax(0, 1fr); + gap: 14px; + min-height: 54px; + padding: 0 14px; + border-radius: 14px; + color: #c8d5e4; + transition: + background var(--ui-transition-fast), + color var(--ui-transition-fast), + transform var(--ui-transition-fast), + box-shadow var(--ui-transition-fast); +} + +.admin-shell.app-shell .nav-stage-has-children { + grid-template-columns: 30px minmax(0, 1fr) 18px; +} + +.admin-shell.app-shell .nav-stage-step, +.admin-shell.app-shell .nav-subitem-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.06); + color: inherit; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.admin-shell.app-shell .nav-stage-step .app-icon, +.admin-shell.app-shell .nav-subitem-icon .app-icon { + width: 22px; + height: 22px; +} + +.admin-shell.app-shell .nav-stage:hover { + transform: translateX(2px); + background: rgba(255, 255, 255, 0.08); + color: #ffffff; + box-shadow: inset 3px 0 0 rgba(14, 165, 233, 0.54); +} + +.admin-shell.app-shell .router-link-active.nav-stage, +.admin-shell.app-shell .nav-stage-active { + transform: none; + background: + linear-gradient(135deg, rgba(37, 99, 235, 0.34), rgba(14, 165, 233, 0.16)); + color: #ffffff; + box-shadow: + inset 3px 0 0 #4fb4ff, + 0 12px 28px rgba(15, 23, 42, 0.18); +} + +.admin-shell.app-shell .nav-stage-active .nav-stage-step, +.admin-shell.app-shell .router-link-active.nav-stage .nav-stage-step, +.admin-shell.app-shell .nav-subitem-active .nav-subitem-icon, +.admin-shell.app-shell .router-link-active.nav-subitem .nav-subitem-icon { + background: rgba(79, 180, 255, 0.18); + color: #ffffff; +} + +.admin-shell.app-shell .nav-stage-body { + min-width: 0; +} + +.admin-shell.app-shell .nav-stage-body strong { + color: inherit; + font-size: 15px; + font-weight: 650; + letter-spacing: 0.01em; +} + +.admin-shell.app-shell .nav-stage-body p { + display: none; +} + +.admin-shell.app-shell .nav-stage-chevron { + color: rgba(200, 213, 228, 0.82); +} + +.admin-shell.app-shell .nav-submenu { + margin: 4px 0 8px; + padding: 6px 0 6px 12px; + border-radius: 16px; + background: rgba(15, 23, 42, 0.28); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +.admin-shell.app-shell .nav-subitem { + grid-template-columns: 28px minmax(0, 1fr); + gap: 12px; + min-height: 44px; + margin: 2px 6px; + padding: 0 10px 0 18px; + border-radius: 12px; + color: #b9c7d7; + font-size: 14px; + transition: + background var(--ui-transition-fast), + color var(--ui-transition-fast), + transform var(--ui-transition-fast); +} + +.admin-shell.app-shell .nav-subitem:hover, +.admin-shell.app-shell .nav-subitem-active, +.admin-shell.app-shell .router-link-active.nav-subitem { + transform: translateX(2px); + background: rgba(79, 180, 255, 0.12); + color: #ffffff; +} + +.admin-shell.app-shell .nav-subitem-active::before, +.admin-shell.app-shell .router-link-active.nav-subitem::before { + inset: 12px auto 12px 6px; + width: 3px; + background: #4fb4ff; +} + +.admin-shell.app-shell .main-panel { + min-width: 0; + overflow: hidden; + background: + radial-gradient(circle at 18% 2%, rgba(37, 99, 235, 0.09), transparent 28%), + linear-gradient(180deg, #f8fafc 0%, #eef3f7 100%); +} + +.admin-shell.app-shell .topbar, +.admin-shell.app-shell .tags-view-container, +.admin-shell.app-shell .app-main { + min-width: 0; +} + +.admin-shell.app-shell-collapsed .brand, +.admin-shell.app-shell.sidebar-collapsed .brand { + grid-template-columns: 1fr; + justify-items: center; + padding: 0; +} + +.admin-shell.app-shell-collapsed .brand h1, +.admin-shell.app-shell-collapsed .nav-stage-body, +.admin-shell.app-shell.sidebar-collapsed .brand h1, +.admin-shell.app-shell.sidebar-collapsed .nav-stage-body { + display: none; +} + +.admin-shell.app-shell-collapsed .nav-stage, +.admin-shell.app-shell.sidebar-collapsed .nav-stage { + grid-template-columns: 1fr; + justify-items: center; + padding: 0; +} + +.admin-shell.app-shell-collapsed .nav-stage-has-children, +.admin-shell.app-shell.sidebar-collapsed .nav-stage-has-children { + grid-template-columns: 1fr; +} + +.admin-shell.app-shell-collapsed .nav-stage-chevron, +.admin-shell.app-shell-collapsed .nav-submenu, +.admin-shell.app-shell.sidebar-collapsed .nav-stage-chevron, +.admin-shell.app-shell.sidebar-collapsed .nav-submenu { + display: none; +} + +@media (max-width: 1024px) { + .shell.admin-shell.app-shell { + grid-template-columns: var(--ui-sidebar-collapsed-width) minmax(0, 1fr); + } + + .admin-shell.app-shell .sidebar { + width: var(--ui-sidebar-collapsed-width); + } + + .admin-shell.app-shell .brand { + grid-template-columns: 1fr; + justify-items: center; + padding: 0; + } + + .admin-shell.app-shell .brand h1, + .admin-shell.app-shell .nav-stage-body { + display: none; + } + + .admin-shell.app-shell .nav-stage, + .admin-shell.app-shell .nav-stage-has-children { + grid-template-columns: 1fr; + justify-items: center; + padding: 0; + } + + .admin-shell.app-shell .nav-stage-chevron, + .admin-shell.app-shell .nav-submenu { + display: none; + } + + .admin-shell.app-shell .main-panel { + width: 100%; + min-width: 0; + } +} + +@media (max-width: 768px) { + .shell.admin-shell.app-shell { + grid-template-columns: 64px minmax(0, 1fr); + } + + .admin-shell.app-shell .sidebar { + width: 64px; + } + + .admin-shell.app-shell .nav-stage-list { + padding: 8px 5px; + } + + .admin-shell.app-shell .nav-stage-step { + width: 32px; + height: 32px; + } + + .admin-shell.app-shell .topbar { + min-width: 0; + padding-inline: 8px; + } + + .admin-shell.app-shell .app-main { + min-width: 0; + overflow-x: auto; + } +} + +.stocktake-summary-card svg { + width: 100%; + height: 100%; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.workflow-drawer-mask.stocktake-mask { + align-items: center; + justify-content: center; +} + +.stocktake-shell { + position: relative; + width: min(1500px, 92vw); + height: min(860px, 90vh); + min-height: 0; + display: grid; + grid-template-rows: auto auto 48px minmax(0, 1fr); + border: 1px solid rgba(148, 163, 184, 0.55); + border-radius: 26px; + background: rgba(248, 250, 252, 0.94); + box-shadow: 0 30px 90px rgba(15, 23, 42, 0.28); + overflow: hidden; +} + +.stocktake-head, +.stocktake-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px 22px; + border-bottom: 1px solid rgba(148, 163, 184, 0.35); +} + +.stocktake-head { + grid-row: 1; +} + +.stocktake-steps { + grid-row: 2; + display: flex; + gap: 10px; + padding: 14px 20px; + background: rgba(241, 245, 249, 0.78); +} + +.stocktake-step, +.stocktake-warehouse-card, +.stocktake-summary-card, +.stocktake-action-card { + border: 1px solid rgba(148, 163, 184, 0.42); + border-radius: 18px; + background: rgba(255, 255, 255, 0.86); + box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08); +} + +.stocktake-step { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + cursor: pointer; +} + +.stocktake-step.active { + border-color: rgba(37, 99, 235, 0.7); + color: #1d4ed8; + box-shadow: 0 16px 36px rgba(37, 99, 235, 0.16); +} + +.stocktake-panel { + grid-row: auto; + min-height: 0; + overflow: auto; + padding: 20px; + align-content: start; +} + +.stocktake-notice-row { + grid-row: 3; + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + padding: 8px 20px 0; +} + +.stocktake-notice-row.empty { + visibility: hidden; + pointer-events: none; +} + +.stocktake-notice-row .feedback-banner { + flex: 1; + min-width: 0; + margin: 0; + padding: 9px 14px; + border-radius: 12px; +} + +.stocktake-main { + grid-row: 4; + display: grid; + grid-template-columns: minmax(380px, 42%) minmax(0, 1fr); + gap: 18px; + min-height: 0; + padding: 20px; + overflow: hidden; +} + +.stocktake-flow-column { + min-height: 0; + overflow: auto; +} + +.stocktake-panel-select, +.stocktake-panel-export, +.stocktake-panel-import, +.stocktake-single-panel, +.stocktake-panel-history { + display: grid; + gap: 16px; + align-content: start; +} + +.stocktake-single-panel { + display: grid; + gap: 16px; + align-content: start; +} + +.stocktake-diff-modal-mask { + position: absolute; + inset: 0; + z-index: 3; + display: grid; + place-items: center; + box-sizing: border-box; + padding: 22px; + background: rgba(15, 23, 42, 0.34); +} + +.stocktake-diff-modal { + display: grid; + grid-template-rows: auto auto auto minmax(0, 1fr) 72px; + width: min(1320px, calc(100vw - 64px)); + height: min(820px, 100%); + max-height: 100%; + min-height: 0; + overflow: hidden; + border: 1px solid rgba(148, 163, 184, 0.5); + border-radius: 24px; + background: rgba(248, 250, 252, 0.98); + box-shadow: 0 28px 80px rgba(15, 23, 42, 0.3); +} + +.stocktake-diff-modal-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px 22px; + border-bottom: 1px solid rgba(148, 163, 184, 0.35); +} + +.stocktake-diff-modal .stocktake-summary-grid, +.stocktake-diff-modal .stocktake-empty-state, +.stocktake-diff-modal .stocktake-diff-toolbar { + margin: 14px 18px 0; +} + +.stocktake-diff-modal .stocktake-diff-table-v2 { + margin: 14px 18px 0; + min-height: 0; +} + +.stocktake-diff-modal .stocktake-footer { + justify-content: flex-end; + align-items: center; + min-height: 72px; + padding: 12px 18px 16px; + border-top: 1px solid rgba(148, 163, 184, 0.35); + background: rgba(248, 250, 252, 0.98); + z-index: 2; +} + +.stocktake-panel .stocktake-footer { + margin-top: 0; + padding: 16px 0 0; + border: 0; + border-top: 1px solid rgba(148, 163, 184, 0.35); + background: transparent; + justify-content: flex-end; +} + +.stocktake-panel-import .stocktake-action-card-wide { + max-width: 620px; + padding: 16px 18px; +} + +.stocktake-panel-import .import-dropzone { + min-height: 150px; + padding: 18px; +} + +.stocktake-panel-history { + grid-template-rows: minmax(0, 1fr) auto; + min-width: 0; + min-height: 0; + padding: 0; + overflow: hidden; +} + +.stocktake-panel-history .table-wrap { + margin-top: 0; + min-height: 0; + overflow: auto; + max-height: none; +} + +.stocktake-panel-history .pagination-bar { + margin-top: 10px; + padding: 12px 16px; +} + +.stocktake-main .stocktake-panel-import, +.stocktake-main .stocktake-single-panel { + padding: 0; + overflow: visible; +} + +.stocktake-warehouse-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 14px; +} + +.stocktake-warehouse-card { + display: grid; + gap: 8px; + padding: 16px; + cursor: pointer; +} + +.stocktake-warehouse-card.active { + border-color: rgba(22, 163, 74, 0.72); + background: rgba(240, 253, 244, 0.9); +} + +.stocktake-action-card { + display: grid; + gap: 12px; + max-width: 520px; + margin: 0; + padding: 18px; +} + +.import-dropzone { + display: grid; + place-items: center; + gap: 10px; + min-height: 260px; + border: 2px dashed rgba(37, 99, 235, 0.42); + border-radius: 22px; + background: rgba(239, 246, 255, 0.72); + cursor: pointer; +} + +.import-dropzone input { + display: none; +} + +.stocktake-summary-grid { + display: grid; + grid-template-columns: repeat(7, minmax(120px, 1fr)); + gap: 12px; + margin-bottom: 14px; +} + +.stocktake-summary-card { + padding: 12px 14px; +} + +.stocktake-summary-card strong { + display: block; + margin-top: 6px; + font-size: 22px; +} + +.stocktake-summary-card.gain, +.stocktake-summary-card.new { + background: rgba(240, 253, 244, 0.92); + color: #166534; +} + +.stocktake-summary-card.loss, +.stocktake-summary-card.missing { + background: rgba(254, 242, 242, 0.92); + color: #991b1b; +} + +.stocktake-summary-card.error { + background: rgba(127, 29, 29, 0.92); + color: #fff; +} + +.stocktake-empty-state { + display: grid; + gap: 6px; + margin-bottom: 12px; + padding: 14px 16px; + border: 1px solid rgba(64, 158, 255, 0.3); + border-radius: 16px; + background: linear-gradient(135deg, rgba(236, 245, 255, 0.96), rgba(255, 255, 255, 0.92)); + color: #303133; + box-shadow: + 0 8px 18px rgba(64, 158, 255, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.94); +} + +.stocktake-empty-state strong { + color: #1d4ed8; + font-size: 16px; +} + +.stocktake-empty-state span { + color: #606266; +} + +.stocktake-diff-toolbar { + display: flex; + gap: 10px; + margin-bottom: 12px; +} + +.stocktake-diff-toolbar .active { + border-color: rgba(37, 99, 235, 0.7); + color: #1d4ed8; +} + +.stocktake-diff-table { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.stocktake-diff-column { + border: 1px solid rgba(148, 163, 184, 0.42); + border-radius: 18px; + overflow: hidden; + background: rgba(255, 255, 255, 0.86); +} + +.stocktake-diff-column h4 { + margin: 0; + padding: 12px 14px; + background: rgba(226, 232, 240, 0.8); +} + +.stocktake-diff-row { + display: grid; + gap: 4px; + min-height: 76px; + padding: 12px 14px; + border-top: 1px solid rgba(226, 232, 240, 0.9); +} + +.stocktake-diff-row.diff-gain, +.stocktake-diff-row.diff-new_lot { + background: rgba(220, 252, 231, 0.86); +} + +.stocktake-diff-row.diff-loss, +.stocktake-diff-row.diff-missing { + background: rgba(254, 226, 226, 0.9); +} + +.stocktake-diff-row.diff-error { + background: #7f1d1d; + color: #fff; +} + +.stocktake-diff-table-v2 { + display: grid; + grid-template-columns: minmax(280px, 1fr) 180px minmax(280px, 1fr); + gap: 0; + overflow: auto; + border: 1px solid rgba(148, 163, 184, 0.42); + border-radius: 18px; + background: rgba(255, 255, 255, 0.9); +} + +.stocktake-diff-header, +.stocktake-diff-change-header { + position: sticky; + top: 0; + z-index: 1; + padding: 12px 14px; + background: rgba(241, 245, 249, 0.96); + color: #334155; + font-weight: 800; + border-bottom: 1px solid rgba(148, 163, 184, 0.36); +} + +.stocktake-diff-change-header { + text-align: center; + border-left: 1px solid rgba(148, 163, 184, 0.28); + border-right: 1px solid rgba(148, 163, 184, 0.28); +} + +.stocktake-diff-cell, +.stocktake-diff-change { + display: grid; + gap: 5px; + align-content: start; + min-height: 140px; + min-width: 0; + padding: 12px 14px; + color: #1f2937; + border-bottom: 1px solid rgba(226, 232, 240, 0.92); + overflow-wrap: anywhere; +} + +.stocktake-diff-change { + align-content: center; + text-align: center; + border-left: 1px solid rgba(148, 163, 184, 0.22); + border-right: 1px solid rgba(148, 163, 184, 0.22); +} + +.stocktake-diff-cell span, +.stocktake-diff-change span { + color: #64748b; + font-size: 13px; + line-height: 1.55; + min-width: 0; + overflow-wrap: anywhere; +} + +.stocktake-diff-cell strong, +.stocktake-diff-change strong { + min-width: 0; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.stocktake-diff-change em { + color: #991b1b; + font-style: normal; + font-weight: 700; +} + +.stocktake-diff-cell.diff-gain, +.stocktake-diff-change.diff-gain { + background: rgba(220, 252, 231, 0.86); +} + +.stocktake-diff-cell.diff-loss, +.stocktake-diff-change.diff-loss { + background: rgba(254, 226, 226, 0.9); +} + +.stocktake-diff-cell.diff-new, +.stocktake-diff-change.diff-new { + background: rgba(219, 234, 254, 0.9); +} + +.stocktake-diff-cell.diff-missing, +.stocktake-diff-change.diff-missing { + background: rgba(255, 237, 213, 0.9); +} + +.stocktake-diff-cell.diff-error, +.stocktake-diff-change.diff-error { + background: rgba(127, 29, 29, 0.94); + color: #fff; +} + +.stocktake-diff-cell.diff-error span, +.stocktake-diff-change.diff-error span, +.stocktake-diff-change.diff-error em { + color: #fee2e2; +} + +.stocktake-diff-empty-v2 { + grid-column: 1 / -1; + display: grid; + gap: 6px; + padding: 24px; + text-align: center; + color: #475569; +} + +.stocktake-confirm-mask { + position: absolute; + inset: 0; + z-index: 4; + display: grid; + place-items: center; + padding: 24px; + background: rgba(15, 23, 42, 0.28); +} + +.stocktake-confirm-dialog { + display: grid; + gap: 12px; + width: min(430px, calc(100vw - 64px)); + padding: 22px; + border: 1px solid rgba(64, 158, 255, 0.26); + border-radius: 18px; + background: #ffffff; + color: #303133; + box-shadow: 0 24px 60px rgba(15, 23, 42, 0.24); +} + +.stocktake-confirm-dialog strong { + color: #1f2d3d; + font-size: 18px; +} + +.stocktake-confirm-dialog span { + color: #606266; + line-height: 1.7; +} + +.stocktake-confirm-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 6px; +} + +.stocktake-progress-rail { + align-items: stretch; +} + +.stocktake-progress-rail .stocktake-step { + cursor: default; +} + +.stocktake-progress-rail .stocktake-step.done { + border-color: rgba(34, 197, 94, 0.58); + color: #15803d; + background: rgba(240, 253, 244, 0.92); +} + +.stocktake-panel-overview { + display: grid; + gap: 16px; +} + +.stocktake-overview-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 14px; +} + +.stocktake-overview-card { + display: grid; + gap: 12px; + min-height: 178px; + padding: 18px; + border: 1px solid rgba(148, 163, 184, 0.38); + border-radius: 20px; + background: rgba(255, 255, 255, 0.9); + color: #1f2937; + box-shadow: 0 14px 34px rgba(15, 23, 42, 0.1); +} + +.stocktake-overview-card.selected { + border-color: rgba(37, 99, 235, 0.65); + box-shadow: 0 18px 40px rgba(37, 99, 235, 0.18); +} + +.stocktake-overview-card.locked { + border-color: rgba(245, 158, 11, 0.62); + background: linear-gradient(135deg, rgba(255, 251, 235, 0.96), rgba(255, 255, 255, 0.92)); +} + +.stocktake-overview-card-head { + display: flex; + justify-content: space-between; + gap: 12px; +} + +.stocktake-overview-card-head div { + display: grid; + gap: 4px; +} + +.stocktake-overview-card-head span, +.stocktake-overview-card p { + color: #64748b; +} + +.stocktake-overview-card mark { + height: fit-content; + border-radius: 999px; + padding: 4px 10px; + background: rgba(219, 234, 254, 0.9); + color: #1d4ed8; + font-weight: 800; +} + +.stocktake-overview-card.locked mark { + background: rgba(254, 243, 199, 0.92); + color: #92400e; +} + +.stocktake-card-actions, +.stocktake-inline-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: auto; +} + +.stocktake-action-card-wide { + max-width: 760px; +} + +.stocktake-stage-tag { + width: fit-content; + border-radius: 999px; + padding: 5px 11px; + background: rgba(219, 234, 254, 0.9); + color: #1d4ed8; + font-weight: 800; +} + +* { + box-sizing: border-box; +} + +html, +body, +#app { + min-height: 100%; + margin: 0; +} + +body { + font-family: var(--font-ui); + color: var(--text); + background: + radial-gradient(circle at top left, rgba(79, 180, 255, 0.18), transparent 24%), + radial-gradient(circle at top right, rgba(242, 157, 82, 0.12), transparent 22%), + linear-gradient(145deg, var(--bg-deep), var(--bg)); + background-attachment: fixed; +} + +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px); + background-size: 40px 40px; + mask-image: radial-gradient(circle at center, black 40%, transparent 95%); +} + +a { + color: inherit; + text-decoration: none; +} + +.shell { + position: relative; + isolation: isolate; + display: grid; + grid-template-columns: 320px minmax(0, 1fr); + min-height: 100vh; + overflow: hidden; + background: transparent; +} + +.shell::before { + content: none; +} + +.shell::after { + content: none; +} + +.app-photo-backdrop { + position: fixed; + inset: -34px; + z-index: 0; + pointer-events: none; + background: + linear-gradient(120deg, rgba(4, 10, 18, 0.28), rgba(7, 17, 28, 0.36)), + radial-gradient(circle at 50% 40%, rgba(79, 180, 255, 0.12), transparent 32%); + filter: blur(14px) saturate(0.98) brightness(0.82); + transform: scale(1.06); +} + +.app-glass-tint { + position: fixed; + inset: 0; + z-index: 1; + pointer-events: none; + background: + linear-gradient(rgba(255, 255, 255, 0.025) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px), + radial-gradient(circle at 18% 15%, rgba(79, 180, 255, 0.13), transparent 28%), + radial-gradient(circle at 86% 20%, rgba(242, 157, 82, 0.1), transparent 24%), + linear-gradient(135deg, rgba(5, 10, 17, 0.34), rgba(5, 12, 19, 0.52)); + background-size: 42px 42px, 42px 42px, auto, auto, auto; +} + +.sidebar { + position: relative; + z-index: 2; + padding: 22px 18px; + border-right: 2px solid rgba(126, 198, 255, 0.42); + background: rgba(255, 255, 255, 0.018); + backdrop-filter: blur(14px) saturate(1.08); + box-shadow: + inset -1px 0 0 rgba(255, 255, 255, 0.08), + inset -4px 0 18px rgba(79, 180, 255, 0.08), + 12px 0 36px rgba(0, 0, 0, 0.1); + overflow: visible; +} + +.brand { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + gap: 14px; + align-items: center; + margin-bottom: 18px; +} + +.brand-logo { + width: 72px; + height: 72px; + object-fit: contain; + filter: drop-shadow(0 10px 22px rgba(79, 180, 255, 0.22)); +} + +.brand h1, +.topbar h2, +.panel h3, +.hero-copy h3, +.matrix-card h3 { + margin: 0; + letter-spacing: 0.04em; +} + +.brand-subtitle, +.eyebrow { + margin: 0 0 6px; + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 12px; + color: var(--steel); +} + +.brand-caption, +.panel p, +.hero-copy p, +.kpi-note, +.nav-item p, +.doc-note { + margin: 0; + color: var(--muted); + line-height: 1.6; +} + +.nav-list { + display: grid; + gap: 14px; +} + +.nav-stage-list { + gap: 8px; +} + +.nav-stage { + position: relative; + display: grid; + grid-template-columns: 54px minmax(0, 1fr); + gap: 12px; + align-items: center; + width: 100%; + padding: 14px; + border-radius: 20px; + border: 1px solid rgba(127, 170, 205, 0.14); + appearance: none; + background: rgba(8, 18, 30, 0.56); + color: inherit; + cursor: pointer; + font: inherit; + text-align: left; + text-decoration: none; + transition: transform 180ms ease, border-color 180ms ease, background 180ms ease; +} + +.nav-stage:hover, +.router-link-active.nav-stage, +.nav-stage-active { + transform: translateY(-1px); + border-color: rgba(79, 180, 255, 0.56); + background: + linear-gradient(90deg, rgba(79, 180, 255, 0.18), transparent 42%), + linear-gradient(180deg, rgba(17, 40, 64, 0.96), rgba(7, 17, 28, 0.9)); +} + +.router-link-active.nav-stage, +.nav-stage-active { + box-shadow: + inset 0 0 0 1px rgba(79, 180, 255, 0.16), + 0 14px 34px rgba(31, 122, 184, 0.14); +} + +.router-link-active.nav-stage::before, +.nav-stage-active::before { + content: ""; + position: absolute; + inset: 10px auto 10px 0; + width: 4px; + border-radius: 999px; + background: linear-gradient(180deg, #7fd2ff, #45d6a8); +} + +.nav-stage-step { + display: inline-flex; + align-items: center; + justify-content: center; + width: 54px; + height: 54px; + border-radius: 18px; + border: 1px solid rgba(79, 180, 255, 0.16); + background: linear-gradient(180deg, rgba(79, 180, 255, 0.14), rgba(79, 180, 255, 0.04)); + color: #d6ecff; + font-size: 12px; + letter-spacing: 0.08em; +} + +.nav-stage-body { + display: grid; + gap: 4px; +} + +.nav-stage-body strong { + display: block; + margin-bottom: 4px; + font-size: 15px; + letter-spacing: 0.02em; +} + +.nav-stage-body p { + margin: 0; + color: var(--muted); + line-height: 1.5; + font-size: 13px; +} + +.nav-group { + border: 1px solid rgba(127, 170, 205, 0.14); + border-radius: 24px; + background: linear-gradient(180deg, rgba(10, 20, 32, 0.82), rgba(6, 13, 22, 0.88)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); + overflow: hidden; + transition: border-color 180ms ease, background 180ms ease, transform 180ms ease; +} + +.nav-group:hover { + border-color: rgba(79, 180, 255, 0.22); +} + +.nav-group-open { + background: linear-gradient(180deg, rgba(12, 24, 37, 0.92), rgba(7, 16, 26, 0.96)); +} + +.nav-group-active { + border-color: rgba(79, 180, 255, 0.56); + box-shadow: + inset 4px 0 0 rgba(79, 180, 255, 0.78), + inset 0 1px 0 rgba(255, 255, 255, 0.03), + 0 0 0 1px rgba(79, 180, 255, 0.18), + 0 14px 34px rgba(31, 122, 184, 0.14); +} + +.nav-group-button { + width: 100%; + display: grid; + grid-template-columns: 48px minmax(0, 1fr) 28px; + gap: 14px; + align-items: center; + padding: 16px; + border: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; + font: inherit; +} + +.nav-group-step { + display: inline-flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: 16px; + border: 1px solid rgba(79, 180, 255, 0.16); + background: linear-gradient(180deg, rgba(79, 180, 255, 0.14), rgba(79, 180, 255, 0.04)); + color: #d6ecff; + font-size: 12px; + letter-spacing: 0.14em; +} + +.nav-group-body { + display: grid; + gap: 6px; +} + +.nav-group-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.nav-group-title-row strong { + font-size: 15px; + letter-spacing: 0.03em; +} + +.nav-group-button p, +.nav-subitem p { + margin: 0; + color: var(--muted); + line-height: 1.5; + font-size: 13px; +} + +.nav-group-count { + display: inline-flex; + align-items: center; + justify-content: center; + width: fit-content; + min-width: 48px; + padding: 5px 10px; + border-radius: 999px; + border: 1px solid rgba(79, 180, 255, 0.14); + background: rgba(79, 180, 255, 0.08); + color: #b9dbf7; + font-size: 12px; +} + +.nav-group-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + color: #cfe8fb; + background: rgba(79, 180, 255, 0.08); + border: 1px solid rgba(79, 180, 255, 0.14); + font-size: 18px; + line-height: 1; +} + +.nav-sublist { + display: grid; + gap: 10px; + padding: 0 16px 16px 16px; +} + +.nav-subitem { + display: grid; + grid-template-columns: 10px minmax(0, 1fr); + gap: 12px; + align-items: start; + padding: 14px 14px 14px 12px; + border: 1px solid transparent; + border-radius: 18px; + background: rgba(13, 25, 39, 0.6); + transition: transform 180ms ease, border-color 180ms ease, background 180ms ease; +} + +.nav-subitem strong { + display: block; + margin-bottom: 4px; +} + +.nav-subitem:hover, +.router-link-active.nav-subitem { + transform: translateX(4px); + border-color: rgba(79, 180, 255, 0.28); + background: rgba(16, 31, 49, 0.92); +} + +.nav-submark { + width: 10px; + min-height: 100%; + border-radius: 999px; + background: linear-gradient(180deg, var(--steel), rgba(79, 180, 255, 0.08)); +} + +.nav-item { + display: grid; + grid-template-columns: 12px minmax(0, 1fr); + gap: 14px; + padding: 16px; + border: 1px solid transparent; + border-radius: 20px; + background: rgba(14, 24, 37, 0.5); + transition: transform 180ms ease, border-color 180ms ease, background 180ms ease; +} + +.nav-item:hover, +.router-link-active.nav-item { + transform: translateX(4px); + border-color: rgba(79, 180, 255, 0.36); + background: rgba(15, 31, 49, 0.8); +} + +.nav-mark { + width: 12px; + border-radius: 999px; + background: linear-gradient(180deg, var(--steel), transparent); +} + +.sidebar-footer { + margin-top: 16px; +} + +.status-chip, +.topbar-tag, +.panel-tag, +.table-chip, +.table-badge, +.required-chip, +.optional-chip, +.primary-button, +.ghost-button { + display: inline-flex; + align-items: center; + gap: 8px; + border-radius: 999px; + padding: 8px 12px; + font-size: 12px; +} + +.status-chip, +.topbar-tag, +.panel-tag { + background: rgba(79, 180, 255, 0.08); + border: 1px solid rgba(79, 180, 255, 0.16); + color: #b9dbf7; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--success); + box-shadow: 0 0 0 6px rgba(69, 214, 168, 0.12); +} + +.main-panel { + position: relative; + z-index: 2; + padding: 28px; +} + +.topbar { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; + margin-bottom: 22px; +} + +.topbar-meta { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: flex-end; + align-items: center; +} + +.topbar-right { + display: grid; + justify-items: end; + gap: 10px; + min-width: min(680px, 52vw); +} + +.broadcast-banner { + display: inline-flex; + align-items: center; + gap: 9px; + max-width: min(720px, 52vw); + padding: 8px 12px; + border-radius: 999px; + border: 1px solid rgba(126, 198, 255, 0.32); + background: linear-gradient(135deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.05)); + color: #f4fbff; + box-shadow: 0 12px 34px rgba(0, 0, 0, 0.16), inset 0 0 0 1px rgba(255, 255, 255, 0.08); + backdrop-filter: blur(16px); + white-space: nowrap; + overflow: hidden; +} + +.broadcast-banner strong, +.broadcast-banner span:last-child { + overflow: hidden; + text-overflow: ellipsis; +} + +.broadcast-speaker { + display: inline-grid; + place-items: center; + width: 24px; + height: 24px; + flex: 0 0 auto; + border-radius: 999px; + background: rgba(79, 180, 255, 0.18); + color: #9be3ff; + animation: speakerPulse 1.8s ease-in-out infinite; +} + +.broadcast-speaker svg { + width: 17px; + height: 17px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2; +} + +.broadcast-success { + border-color: rgba(69, 214, 168, 0.44); +} + +.broadcast-warning { + border-color: rgba(255, 209, 102, 0.5); +} + +.broadcast-danger { + border-color: rgba(255, 111, 97, 0.52); +} + +.assistant-bot-button { + position: relative; + display: inline-grid; + place-items: center; + width: 46px; + height: 46px; + border: 1px solid rgba(126, 198, 255, 0.42); + border-radius: 16px; + background: + radial-gradient(circle at 50% 18%, rgba(69, 214, 168, 0.22), transparent 34%), + linear-gradient(180deg, rgba(79, 180, 255, 0.2), rgba(255, 255, 255, 0.05)); + color: #e8f8ff; + cursor: pointer; + animation: botHop 1.7s ease-in-out infinite; + box-shadow: 0 14px 34px rgba(31, 122, 184, 0.18); +} + +.assistant-bot-button:hover { + border-color: rgba(69, 214, 168, 0.74); +} + +.assistant-bot-wave { + position: absolute; + top: -14px; + right: -12px; + padding: 2px 6px; + border-radius: 999px; + background: rgba(69, 214, 168, 0.92); + color: #06131c; + font-size: 10px; + font-weight: 800; + transform-origin: left bottom; + animation: botWave 1.7s ease-in-out infinite; +} + +.assistant-bot-face { + position: relative; + display: flex; + justify-content: center; + gap: 7px; + width: 28px; + height: 22px; + border-radius: 10px; + border: 1px solid rgba(205, 239, 255, 0.5); + background: linear-gradient(180deg, #dff7ff, #8edcff); +} + +.assistant-bot-face::before { + content: ""; + position: absolute; + top: -7px; + left: 50%; + width: 2px; + height: 7px; + background: rgba(205, 239, 255, 0.74); +} + +.assistant-bot-face::after { + content: ""; + position: absolute; + top: -10px; + left: calc(50% - 3px); + width: 6px; + height: 6px; + border-radius: 999px; + background: #45d6a8; + box-shadow: 0 0 12px rgba(69, 214, 168, 0.9); +} + +.assistant-bot-eye { + width: 5px; + height: 5px; + margin-top: 8px; + border-radius: 999px; + background: #081b2a; + animation: botBlink 4s ease-in-out infinite; +} + +.page-grid { + display: grid; + gap: 20px; +} + +.panel { + position: relative; + overflow: hidden; + padding: 22px; + border: 2px solid rgba(126, 198, 255, 0.38); + border-radius: 28px; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.025)); + backdrop-filter: blur(20px) saturate(1.12); + box-shadow: + 0 24px 76px rgba(0, 0, 0, 0.16), + 0 0 0 1px rgba(79, 180, 255, 0.12), + inset 0 0 0 1px rgba(255, 255, 255, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.extension-hero { + align-items: center; +} + +.extension-status-card { + display: grid; + gap: 8px; + align-content: center; + padding: 20px; + border-radius: 24px; + border: 1px solid rgba(126, 198, 255, 0.24); + background: rgba(255, 255, 255, 0.06); +} + +.extension-status-card strong { + color: #f5fbff; + font-size: 18px; +} + +.compact-extension-form { + max-width: 1180px; +} + +.extension-preview-field { + justify-content: end; +} + +.extension-preview-banner { + max-width: 100%; +} + +.panel::after { + content: ""; + position: absolute; + inset: auto 0 0 0; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(79, 180, 255, 0.32), transparent); +} + +.panel-hero { + display: grid; + grid-template-columns: minmax(0, 1.6fr) minmax(280px, 1fr); + gap: 20px; + align-items: stretch; +} + +.master-compact-hero { + display: block; + padding: 18px 22px; +} + +.compact-page-hero { + display: block; + padding: 18px 22px; +} + +.master-compact-hero .hero-copy h3, +.compact-page-hero .hero-copy h3 { + max-width: none; + margin-bottom: 8px; + font-size: clamp(22px, 3vw, 28px); +} + +.master-compact-hero .hero-copy p, +.compact-page-hero .hero-copy p { + max-width: 86ch; +} + +.hero-copy h3 { + font-size: clamp(28px, 4vw, 40px); + max-width: 15ch; + margin-bottom: 14px; +} + +.hero-copy p { + max-width: 62ch; +} + +.hero-stats, +.summary-grid, +.kpi-grid, +.matrix-grid, +.field-grid { + display: grid; + gap: 16px; +} + +.hero-stats { + grid-template-columns: repeat(1, minmax(0, 1fr)); +} + +.hero-stat, +.summary-card, +.highlight-panel, +.matrix-card, +.field-card { + padding: 18px; + border: 1px solid rgba(209, 229, 246, 0.16); + border-radius: 22px; + background: rgba(255, 255, 255, 0.045); + backdrop-filter: blur(14px); +} + +.hero-stat span, +.summary-card span, +.cost-number span, +.alert-footer span, +.flow-step-top span, +.field-card code, +.doc-card code { + color: var(--muted); +} + +.hero-stat strong, +.summary-card strong { + display: block; + margin-top: 10px; + font-size: 22px; +} + +.kpi-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.kpi-card { + padding: 18px; + border-radius: 24px; + border: 1px solid rgba(127, 170, 205, 0.14); + background: linear-gradient(180deg, rgba(16, 31, 48, 0.84), rgba(7, 17, 28, 0.86)); +} + +.kpi-warning { + box-shadow: inset 0 0 0 1px rgba(255, 209, 102, 0.18); +} + +.kpi-danger { + box-shadow: inset 0 0 0 1px rgba(255, 111, 97, 0.2); +} + +.kpi-label { + margin: 0 0 14px; + color: var(--muted); +} + +.kpi-value-row { + display: flex; + align-items: flex-end; + gap: 8px; + margin-bottom: 12px; +} + +.kpi-value { + font-size: 40px; + line-height: 1; +} + +.kpi-unit { + padding-bottom: 6px; + color: var(--muted); +} + +.panel-header, +.alert-top, +.alert-footer, +.flow-step-top, +.field-top { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; +} + +.alert-list, +.cost-stack, +.table-chip-list { + display: grid; + gap: 14px; +} + +.alert-item { + padding: 16px; + border-radius: 22px; + border: 1px solid transparent; + background: rgba(6, 12, 20, 0.56); +} + +.alert-warning { + border-color: rgba(255, 209, 102, 0.24); +} + +.alert-danger { + border-color: rgba(255, 111, 97, 0.3); +} + +.alert-item p, +.flow-step p { + margin: 10px 0 0; +} + +.flow-grid, +.two-column-panel { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; +} + +.flow-group { + display: grid; + gap: 14px; +} + +.flow-group-head h4 { + margin: 0; +} + +.flow-step { + padding: 16px; + border-radius: 22px; + border: 1px solid rgba(126, 159, 190, 0.14); + background: rgba(8, 18, 30, 0.58); +} + +.flow-done { + border-color: rgba(69, 214, 168, 0.24); +} + +.flow-active { + border-color: rgba(79, 180, 255, 0.26); +} + +.flow-queued { + border-color: rgba(242, 157, 82, 0.22); +} + +.cost-row { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; + padding: 14px 0; + border-bottom: 1px solid rgba(126, 159, 190, 0.1); +} + +.cost-row:last-child { + border-bottom: none; +} + +.cost-number { + text-align: right; +} + +.cost-number strong { + display: block; + margin-top: 6px; +} + +.feature-list, +.ordered-flow { + display: grid; + gap: 14px; + margin: 18px 0 0; + padding-left: 18px; + color: var(--muted); +} + +.process-hero { + align-items: center; +} + +.formula-box, +.material-banner, +.doc-card { + padding: 18px; + border-radius: 22px; + border: 1px solid rgba(79, 180, 255, 0.18); + background: linear-gradient(180deg, rgba(16, 32, 49, 0.82), rgba(7, 16, 26, 0.9)); +} + +.formula-box strong, +.material-banner strong, +.doc-card strong { + display: block; + margin-top: 12px; + margin-bottom: 8px; +} + +.summary-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-top: 18px; +} + +.emphasis-card { + box-shadow: inset 0 0 0 1px rgba(242, 157, 82, 0.24); +} + +.material-banner { + margin-top: 16px; +} + +.feedback-banner, +.form-error { + display: none !important; +} + +.notification-stack { + position: fixed; + top: 22px; + right: 24px; + z-index: 4200; + width: min(420px, calc(100vw - 32px)); + pointer-events: none; +} + +.notification-stack-list { + display: grid; + gap: 12px; +} + +.notification-toast-card { + display: grid; + grid-template-columns: 5px 1fr auto; + gap: 12px; + align-items: stretch; + overflow: hidden; + border-radius: 18px; + border: 1px solid rgba(126, 159, 190, 0.22); + background: linear-gradient(135deg, rgba(8, 18, 30, 0.96), rgba(13, 31, 48, 0.94)); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.36), inset 0 0 0 1px rgba(255, 255, 255, 0.03); + color: #eef8ff; + pointer-events: auto; +} + +.notification-toast-indicator { + background: #4fb4ff; +} + +.notification-toast-success .notification-toast-indicator { + background: linear-gradient(180deg, #62e6a2, #4fb4ff); +} + +.notification-toast-error .notification-toast-indicator { + background: linear-gradient(180deg, #ff6f61, #f29d52); +} + +.notification-toast-info .notification-toast-indicator { + background: linear-gradient(180deg, #4fb4ff, #b9dbf7); +} + +.notification-toast-body { + padding: 14px 0; +} + +.notification-toast-body strong { + display: block; + color: #ffffff; + font-size: 14px; + margin-bottom: 4px; +} + +.notification-toast-body p { + margin: 0; + color: #b9c7d6; + font-size: 13px; + line-height: 1.55; + word-break: break-word; +} + +.notification-toast-close { + align-self: start; + margin: 10px 10px 0 0; + border: 0; + border-radius: 999px; + width: 28px; + height: 28px; + background: rgba(255, 255, 255, 0.06); + color: #b9dbf7; + cursor: pointer; + font-size: 18px; + line-height: 1; +} + +.notification-toast-close:hover { + background: rgba(255, 255, 255, 0.12); + color: #ffffff; +} + +.notification-toast-enter-active, +.notification-toast-leave-active { + transition: transform 0.24s ease, opacity 0.24s ease; +} + +.notification-toast-enter-from, +.notification-toast-leave-to { + opacity: 0; + transform: translateX(24px); +} + +.notification-toast-move { + transition: transform 0.24s ease; +} + +.table-wrap { + overflow-x: auto; + margin-top: 16px; +} + +.table-control-bar { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: end; + margin-top: 14px; + padding: 12px; + border-radius: 18px; + border: 1px solid rgba(209, 229, 246, 0.14); + background: rgba(255, 255, 255, 0.045); + backdrop-filter: blur(14px); +} + +.table-search-field, +.table-sort-field { + display: grid; + gap: 6px; +} + +.table-search-field { + min-width: min(360px, 100%); + flex: 1; +} + +.table-sort-field { + min-width: 180px; +} + +.table-search-field span, +.table-sort-field span { + color: var(--muted); + font-size: 12px; +} + +.table-search-field input, +.table-sort-field select { + min-height: 38px; + border-radius: 14px; + border: 1px solid rgba(79, 180, 255, 0.16); + background: rgba(4, 11, 19, 0.5); + backdrop-filter: blur(10px); + color: #eef8ff; + font: inherit; + padding: 8px 12px; +} + +.data-table { + width: 100%; + border-collapse: collapse; + min-width: 880px; +} + +.data-table th, +.data-table td { + padding: 14px 12px; + text-align: left; + border-bottom: 1px solid rgba(126, 159, 190, 0.12); +} + +.data-table th { + color: #b9dbf7; + font-weight: 600; +} + +.sortable-th { + user-select: none; +} + +.sortable-th-button { + display: inline-flex; + align-items: center; + gap: 7px; + width: 100%; + border: 0; + background: transparent; + color: inherit; + font: inherit; + font-weight: inherit; + text-align: left; + cursor: pointer; + padding: 0; +} + +.sortable-th-button:hover { + color: #eef8ff; +} + +.sort-arrow { + display: inline-grid; + place-items: center; + min-width: 18px; + height: 18px; + border-radius: 999px; + border: 1px solid rgba(79, 180, 255, 0.18); + background: rgba(79, 180, 255, 0.08); + color: rgba(185, 219, 247, 0.72); + font-size: 11px; + line-height: 1; + transition: color 0.16s ease, background 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease; +} + +.sortable-th-button:hover .sort-arrow { + border-color: rgba(79, 180, 255, 0.42); + color: #eef8ff; +} + +.sortable-th-active { + color: #eef8ff; +} + +.sort-arrow-active { + border-color: rgba(242, 157, 82, 0.66); + background: linear-gradient(135deg, rgba(242, 157, 82, 0.95), rgba(79, 180, 255, 0.82)); + color: #08121e; + box-shadow: 0 0 0 3px rgba(242, 157, 82, 0.12); +} + +.row-danger { + background: rgba(255, 111, 97, 0.08); +} + +.dashboard-order-panel { + min-height: calc(100vh - 148px); +} + +.dashboard-order-header p:not(.eyebrow) { + max-width: 860px; + margin: 6px 0 0 0; + color: var(--muted); +} + +.dashboard-order-table { + min-width: 1480px; +} + +.dashboard-order-table td strong { + display: block; + color: #eef8ff; + font-size: 13px; +} + +.dashboard-order-table td small { + display: block; + margin-top: 3px; + color: var(--muted); + font-size: 11px; +} + +.strong-link { + font-weight: 700; + letter-spacing: 0.01em; +} + +.workflow-drawer-mask.lifecycle-modal-mask { + align-items: center; + justify-content: center; + padding: 4px; +} + +.lifecycle-modal { + display: grid; + grid-template-rows: auto 1fr; + width: min(99vw, 1980px, calc(100vw - 8px)); + height: min(97dvh, 1180px, calc(100dvh - 8px)); + min-height: 0; + border-radius: 22px; + border: 2px solid rgba(79, 180, 255, 0.26); + background: + radial-gradient(circle at 18% 12%, rgba(79, 180, 255, 0.18), transparent 28%), + linear-gradient(135deg, rgba(7, 16, 26, 0.98), rgba(10, 28, 43, 0.96)); + box-shadow: 0 34px 90px rgba(0, 0, 0, 0.46), inset 0 0 0 1px rgba(255, 255, 255, 0.03); + overflow: hidden; +} + +.lifecycle-modal-head { + display: flex; + justify-content: space-between; + gap: 18px; + padding: 18px 24px; + border-bottom: 1px solid rgba(126, 159, 190, 0.14); +} + +.lifecycle-modal-head h3 { + margin: 3px 0; + color: #ffffff; +} + +.lifecycle-modal-head p:not(.eyebrow) { + margin: 0; + color: var(--muted); +} + +.dag-click-lock-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 36px; + padding: 8px 12px; + border-radius: 999px; + border: 1px solid rgba(144, 215, 255, 0.24); + background: rgba(79, 180, 255, 0.08); + color: #d9edff; + font-size: 13px; + font-weight: 700; + cursor: pointer; + user-select: none; +} + +.dag-click-lock-toggle input { + width: 15px; + height: 15px; + accent-color: #4fb4ff; +} + +.dag-click-lock-toggle:has(input:checked) { + border-color: rgba(247, 253, 255, 0.62); + background: + radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.22), transparent 38%), + rgba(79, 180, 255, 0.2); + color: #ffffff; + box-shadow: 0 0 0 4px rgba(79, 180, 255, 0.12), 0 0 18px rgba(79, 180, 255, 0.28); +} + +.dag-loading-card { + margin: 24px; + display: grid; + place-items: center; + border-radius: 24px; + border: 1px dashed rgba(79, 180, 255, 0.24); + color: #b9dbf7; + background: rgba(8, 18, 30, 0.46); +} + +.lifecycle-content { + display: grid; + grid-template-columns: minmax(0, 1fr) 390px; + min-height: 0; +} + +.dag-viewport { + position: relative; + overflow: hidden; + cursor: grab; + background: + radial-gradient(circle at 12% 18%, rgba(79, 180, 255, 0.12), transparent 28%), + linear-gradient(rgba(79, 180, 255, 0.065) 1px, transparent 1px), + linear-gradient(90deg, rgba(79, 180, 255, 0.065) 1px, transparent 1px); + background-size: auto, 30px 30px, 30px 30px; +} + +.dag-viewport:active { + cursor: grabbing; +} + +.dag-transform { + transform-origin: 0 0; +} + +.dag-canvas { + position: relative; +} + +.dag-stage-band { + position: absolute; + top: 18px; + bottom: 18px; + border-radius: 26px; + border: 1px solid rgba(79, 180, 255, 0.12); + background: linear-gradient(180deg, rgba(79, 180, 255, 0.075), rgba(79, 180, 255, 0.018)); +} + +.dag-stage-band span { + position: absolute; + top: 14px; + left: 16px; + right: 16px; + color: rgba(185, 219, 247, 0.72); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + writing-mode: horizontal-tb; + white-space: nowrap; +} + +.dag-edge-layer { + position: absolute; + inset: 0; + pointer-events: none; +} + +.dag-edge-hit { + fill: none; + stroke: transparent; + stroke-width: 24; + stroke-linecap: round; + stroke-linejoin: round; + pointer-events: stroke; + cursor: pointer; +} + +.dag-edge-path { + fill: none; + stroke: rgba(144, 215, 255, 0.92); + stroke-width: 3.1; + stroke-linecap: round; + stroke-linejoin: bevel; + filter: drop-shadow(0 0 7px rgba(79, 180, 255, 0.34)); + opacity: 0.92; + transition: stroke 0.14s ease, stroke-width 0.14s ease, opacity 0.14s ease, filter 0.14s ease; +} + +.dag-edge-highlighted { + stroke: #f7fdff; + stroke-width: 7.4; + opacity: 1; + filter: + drop-shadow(0 0 7px rgba(255, 255, 255, 0.95)) + drop-shadow(0 0 18px rgba(79, 180, 255, 0.95)) + drop-shadow(0 0 32px rgba(79, 180, 255, 0.62)); +} + +.dag-edge-warning.dag-edge-highlighted { + stroke: #ffe0a6; + filter: + drop-shadow(0 0 7px rgba(255, 244, 218, 0.92)) + drop-shadow(0 0 18px rgba(255, 174, 86, 0.96)) + drop-shadow(0 0 32px rgba(242, 157, 82, 0.62)); +} + +.dag-edge-danger.dag-edge-highlighted { + stroke: #ffd2cc; + filter: + drop-shadow(0 0 7px rgba(255, 238, 235, 0.92)) + drop-shadow(0 0 18px rgba(255, 112, 97, 0.98)) + drop-shadow(0 0 32px rgba(255, 116, 102, 0.66)); +} + +.dag-edge-dimmed { + opacity: 0.16; + filter: none; +} + +.dag-edge-warning { + stroke: rgba(255, 174, 86, 0.98); + filter: drop-shadow(0 0 8px rgba(242, 157, 82, 0.3)); +} + +.dag-edge-danger { + stroke: rgba(255, 112, 97, 1); + filter: drop-shadow(0 0 8px rgba(255, 116, 102, 0.34)); +} + +.dag-edge-terminal { + fill: #d8f2ff; + stroke: rgba(6, 21, 34, 0.96); + stroke-width: 1.6; + filter: drop-shadow(0 0 8px rgba(79, 180, 255, 0.5)); + transition: r 0.14s ease, opacity 0.14s ease, filter 0.14s ease, stroke-width 0.14s ease; +} + +.dag-edge-terminal-warning { + fill: #ffca81; +} + +.dag-edge-terminal-danger { + fill: #ff7a70; +} + +.dag-edge-start { + fill: rgba(7, 16, 26, 0.98); + stroke: rgba(144, 215, 255, 0.96); + stroke-width: 2.4; + filter: drop-shadow(0 0 8px rgba(79, 180, 255, 0.5)); + transition: r 0.14s ease, opacity 0.14s ease, filter 0.14s ease, stroke-width 0.14s ease; +} + +.dag-edge-start-warning { + stroke: rgba(255, 174, 86, 0.98); +} + +.dag-edge-start-danger { + stroke: rgba(255, 112, 97, 1); +} + +.dag-edge-dot-highlighted { + stroke-width: 3.6; + filter: + drop-shadow(0 0 8px rgba(255, 255, 255, 0.86)) + drop-shadow(0 0 18px rgba(79, 180, 255, 0.86)); +} + +.dag-edge-dot-dimmed { + opacity: 0.18; + filter: none; +} + +.dag-edge-label { + fill: rgba(219, 239, 255, 0.9); + font-size: 11.5px; + font-weight: 700; + paint-order: stroke; + stroke: rgba(7, 16, 26, 0.96); + stroke-width: 5px; + transition: opacity 0.14s ease, fill 0.14s ease, font-size 0.14s ease; +} + +.dag-edge-label-highlighted { + fill: #ffffff; + font-size: 13px; +} + +.dag-edge-label-dimmed { + opacity: 0.2; +} + +.dag-node-card { + position: absolute; + width: 250px; + min-height: 122px; + padding: 13px; + display: grid; + gap: 7px; + border-radius: 18px; + border: 1px solid rgba(79, 180, 255, 0.22); + background: linear-gradient(145deg, rgba(9, 24, 39, 0.98), rgba(12, 42, 64, 0.94)); + color: #eef8ff; + text-align: left; + box-shadow: 0 18px 36px rgba(0, 0, 0, 0.26), inset 0 0 0 1px rgba(255, 255, 255, 0.03); + cursor: pointer; + transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; +} + +.dag-node-card:hover, +.dag-node-active { + transform: translateY(-2px); + border-color: rgba(242, 157, 82, 0.82); + box-shadow: 0 22px 46px rgba(0, 0, 0, 0.34), 0 0 0 5px rgba(242, 157, 82, 0.14); +} + +.dag-node-locked { + border-color: rgba(247, 253, 255, 0.74); + box-shadow: + 0 22px 46px rgba(0, 0, 0, 0.34), + 0 0 0 5px rgba(79, 180, 255, 0.2), + 0 0 28px rgba(79, 180, 255, 0.42); +} + +.dag-node-stage { + width: fit-content; + padding: 4px 8px; + border-radius: 999px; + background: rgba(79, 180, 255, 0.1); + color: #b9dbf7; + font-size: 11px; +} + +.dag-node-card strong { + font-size: 14px; + line-height: 1.35; +} + +.dag-node-card small { + color: var(--muted); + line-height: 1.35; +} + +.dag-node-footer { + display: flex; + justify-content: space-between; + gap: 8px; + color: #b9dbf7; + font-size: 12px; +} + +.dag-node-success { + border-color: rgba(98, 230, 162, 0.24); +} + +.dag-node-warning { + border-color: rgba(242, 157, 82, 0.32); +} + +.dag-node-danger { + border-color: rgba(255, 111, 97, 0.38); + background: linear-gradient(145deg, rgba(42, 20, 24, 0.98), rgba(42, 31, 23, 0.94)); +} + +.dag-detail-panel { + min-width: 0; + padding: 22px; + border-left: 1px solid rgba(126, 159, 190, 0.14); + background: rgba(7, 16, 26, 0.62); + overflow: auto; +} + +.dag-detail-panel h3 { + margin: 4px 0 10px 0; + color: #ffffff; + line-height: 1.3; +} + +.dag-detail-summary { + margin: 14px 0; + color: #b9c7d6; + line-height: 1.65; +} + +.dag-detail-metrics { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin: 16px 0; +} + +.dag-detail-metrics article { + padding: 12px; + border-radius: 16px; + border: 1px solid rgba(79, 180, 255, 0.12); + background: rgba(79, 180, 255, 0.06); +} + +.dag-detail-metrics span, +.dag-detail-list dt { + color: var(--muted); + font-size: 12px; +} + +.dag-detail-metrics strong { + display: block; + margin-top: 6px; + color: #ffffff; +} + +.dag-detail-list { + display: grid; + grid-template-columns: 96px 1fr; + gap: 10px 12px; + margin: 0; +} + +.dag-detail-list dd { + margin: 0; + color: #eef8ff; + word-break: break-word; +} + +.node-chip-success, +.node-chip-normal { + background: rgba(79, 180, 255, 0.08); +} + +.node-chip-warning { + background: rgba(242, 157, 82, 0.13); + color: #ffd6ad; + border-color: rgba(242, 157, 82, 0.22); +} + +.node-chip-danger { + background: rgba(255, 111, 97, 0.13); + color: #ffd4ce; + border-color: rgba(255, 111, 97, 0.22); +} + +.table-badge, +.table-chip, +.required-chip, +.optional-chip { + width: fit-content; +} + +.badge-normal, +.table-chip, +.optional-chip { + background: rgba(79, 180, 255, 0.08); + color: #b9dbf7; + border: 1px solid rgba(79, 180, 255, 0.14); +} + +.badge-danger, +.required-chip { + background: rgba(255, 111, 97, 0.12); + color: #ffd4ce; + border: 1px solid rgba(255, 111, 97, 0.18); +} + +.field-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.field-card code { + display: inline-block; + margin: 10px 0; + font-size: 13px; +} + +.matrix-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.doc-card + .doc-card { + margin-top: 14px; +} + +.ghost-button, +.primary-button, +.link-button { + cursor: pointer; + border: 1px solid transparent; + font-family: inherit; + transition: transform 180ms ease, border-color 180ms ease, background 180ms ease; +} + +.ghost-button { + background: rgba(79, 180, 255, 0.08); + color: #b9dbf7; + border-color: rgba(79, 180, 255, 0.16); +} + +.primary-button { + justify-content: center; + background: linear-gradient(135deg, rgba(79, 180, 255, 0.92), rgba(31, 122, 184, 0.92)); + color: #07111b; + font-weight: 700; + border-color: rgba(79, 180, 255, 0.28); + padding: 12px 18px; +} + +.ghost-button:hover, +.primary-button:hover { + transform: translateY(-1px); +} + +.danger-ghost { + color: #ffd4ce; + border-color: rgba(255, 111, 97, 0.18); + background: rgba(255, 111, 97, 0.08); +} + +.small-button { + padding: 8px 14px; + font-size: 12px; +} + +.link-button { + padding: 0; + border: 0; + background: transparent; + color: #d7efff; + font: inherit; + font-weight: 700; + text-align: left; + text-decoration: underline; + text-decoration-color: rgba(79, 180, 255, 0.42); + text-underline-offset: 4px; +} + +.link-button:hover { + color: #ffffff; + text-decoration-color: rgba(79, 180, 255, 0.9); +} + +.planning-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; +} + +.customer-info-list { + display: grid; + gap: 12px; + margin-top: 16px; +} + +.customer-info-card { + display: grid; + grid-template-columns: minmax(220px, 1.2fr) minmax(120px, 0.6fr) minmax(140px, 0.7fr) minmax(220px, 1fr); + gap: 14px; + align-items: center; + padding: 14px 16px; + border-radius: 18px; + border: 1px solid rgba(209, 229, 246, 0.14); + background: rgba(255, 255, 255, 0.04); + backdrop-filter: blur(12px); +} + +.customer-info-card p, +.customer-info-card span { + margin: 0; + color: var(--muted); + font-size: 12px; +} + +.customer-info-card strong { + display: block; + color: #eef8ff; +} + +.workflow-drawer-mask.dual-drawer-mask { + padding: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) + max(12px, env(safe-area-inset-bottom)) max(24px, env(safe-area-inset-left)); +} + +.dual-drawer-shell { + display: flex; + justify-content: flex-end; + gap: 12px; + width: min(1180px, 100%); + height: 100%; + max-height: calc(100dvh - 24px); + min-height: 0; +} + +.compact-side-drawer { + width: min(560px, calc(50vw - 18px)); + height: 100%; + max-height: 100%; + min-height: 0; + overflow: auto; + padding: 18px; +} + +.planning-hero { + align-items: center; +} + +.workspace-hero { + align-items: center; +} + +.workspace-panel { + display: grid; + gap: 18px; +} + +.workspace-tabbar { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; +} + +.workspace-tab { + position: relative; + display: grid; + grid-template-columns: 54px minmax(0, 1fr); + gap: 12px; + align-items: center; + padding: 14px; + border-radius: 20px; + border: 1px solid rgba(127, 170, 205, 0.14); + background: rgba(8, 18, 30, 0.56); + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + transition: transform 180ms ease, border-color 180ms ease, background 180ms ease; +} + +.workspace-tab:hover, +.workspace-tab-active { + transform: translateY(-1px); + border-color: rgba(79, 180, 255, 0.56); + background: + linear-gradient(90deg, rgba(79, 180, 255, 0.18), transparent 42%), + linear-gradient(180deg, rgba(17, 40, 64, 0.96), rgba(7, 17, 28, 0.9)); +} + +.workspace-tab-active { + box-shadow: + inset 0 0 0 1px rgba(79, 180, 255, 0.16), + 0 14px 34px rgba(31, 122, 184, 0.14); +} + +.workspace-tab-active::before { + content: ""; + position: absolute; + inset: 10px auto 10px 0; + width: 4px; + border-radius: 999px; + background: linear-gradient(180deg, #7fd2ff, #45d6a8); +} + +.workspace-tab-step { + display: inline-flex; + align-items: center; + justify-content: center; + width: 54px; + height: 54px; + border-radius: 18px; + border: 1px solid rgba(79, 180, 255, 0.16); + background: linear-gradient(180deg, rgba(79, 180, 255, 0.14), rgba(79, 180, 255, 0.04)); + color: #d6ecff; + font-size: 12px; + letter-spacing: 0.08em; +} + +.workspace-tab strong { + display: block; + margin-bottom: 4px; +} + +.workspace-tab p { + margin: 0; + color: var(--muted); + line-height: 1.5; + font-size: 13px; +} + +.workspace-section-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.workspace-section-head h3 { + margin: 0; +} + +.workspace-section-note { + padding: 14px 16px; + border-radius: 18px; + border: 1px solid rgba(79, 180, 255, 0.14); + background: rgba(79, 180, 255, 0.06); + color: var(--muted); + line-height: 1.6; +} + +.stack-form, +.order-item-stack { + display: grid; + gap: 14px; +} + +.form-field { + display: grid; + gap: 8px; +} + +.compact-select-field { + min-width: min(360px, 100%); +} + +.form-toolbar { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.form-field span { + font-size: 13px; + color: #b9dbf7; +} + +.required-mark { + color: #ff9f8f; + font-weight: 700; +} + +.form-field input, +.form-field select, +.form-field textarea { + width: 100%; + padding: 14px 16px; + border-radius: 18px; + border: 1px solid rgba(209, 229, 246, 0.16); + background: rgba(4, 11, 19, 0.54); + backdrop-filter: blur(10px); + color: var(--text); + font: inherit; + outline: none; + transition: border-color 180ms ease, box-shadow 180ms ease; +} + +.muted-option { + color: #8a8f98; +} + +.form-field input:focus, +.form-field select:focus, +.form-field textarea:focus { + border-color: rgba(79, 180, 255, 0.42); + box-shadow: 0 0 0 4px rgba(79, 180, 255, 0.08); +} + +.double-field, +.triple-field, +.checkbox-grid { + display: grid; + gap: 14px; +} + +.double-field { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.triple-field { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.checkbox-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.checkbox-field { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 16px; + border-radius: 18px; + border: 1px solid rgba(209, 229, 246, 0.16); + background: rgba(255, 255, 255, 0.045); + backdrop-filter: blur(12px); + color: var(--text); +} + +.checkbox-field input { + width: 16px; + height: 16px; +} + +.subsection-head, +.action-row { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; +} + +.drawer-title-row, +.title-with-help, +.section-title-with-help { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.drawer-title-row h3, +.title-with-help h3, +.section-title-with-help h3 { + margin: 0; +} + +.help-tip { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border: 1px solid #c6e2ff; + border-radius: 50%; + background: #ecf5ff; + color: #409eff; + font-size: 12px; + font-weight: 800; + line-height: 1; + cursor: default; + user-select: none; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.86); +} + +.help-tip:hover, +.help-tip:focus-visible { + border-color: #409eff; + background: #409eff; + color: #fff; +} + +.help-tip[title]::after { + content: attr(title); + position: absolute; + left: 50%; + bottom: calc(100% + 10px); + z-index: 20000; + width: max-content; + max-width: min(360px, calc(100vw - 32px)); + padding: 9px 12px; + border: 1px solid #d9ecff; + border-radius: 8px; + background: rgba(255, 255, 255, 0.98); + color: #303133; + font-size: 12px; + font-weight: 500; + line-height: 1.55; + text-align: left; + white-space: normal; + box-shadow: 0 10px 28px rgba(31, 45, 61, 0.18); + opacity: 0; + pointer-events: none; + transform: translate(-50%, 6px); + transition: opacity 0.12s ease, transform 0.12s ease; +} + +.help-tip[title]::before { + content: ""; + position: absolute; + left: 50%; + bottom: calc(100% + 4px); + z-index: 20001; + width: 10px; + height: 10px; + border-right: 1px solid #d9ecff; + border-bottom: 1px solid #d9ecff; + background: rgba(255, 255, 255, 0.98); + opacity: 0; + pointer-events: none; + transform: translate(-50%, 6px) rotate(45deg); + transition: opacity 0.12s ease, transform 0.12s ease; +} + +.help-tip[title]:hover::after, +.help-tip[title]:hover::before, +.help-tip[title]:focus-visible::after, +.help-tip[title]:focus-visible::before { + opacity: 1; + transform: translate(-50%, 0); +} + +.help-tip[title]:hover::before, +.help-tip[title]:focus-visible::before { + transform: translate(-50%, 0) rotate(45deg); +} + +.order-item-card, +.login-stat-card { + padding: 18px; + border-radius: 22px; + border: 1px solid rgba(209, 229, 246, 0.16); + background: rgba(255, 255, 255, 0.045); + backdrop-filter: blur(12px); +} + +.purchase-order-compose { + display: grid; + grid-template-columns: minmax(320px, 0.42fr) minmax(0, 1fr); + gap: 18px; + align-items: start; +} + +.purchase-order-form-body { + display: grid; + gap: 14px; + min-width: 0; +} + +.purchase-demand-calculator { + position: sticky; + top: 0; + display: grid; + gap: 14px; + max-height: min(50dvh, 520px); + overflow-y: auto; + overscroll-behavior: contain; + padding: 18px; + border-radius: 24px; + border: 1px solid rgba(79, 180, 255, 0.28); + background: linear-gradient(155deg, rgba(7, 20, 32, 0.86), rgba(12, 34, 48, 0.58)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 18px 50px rgba(0, 0, 0, 0.2); +} + +.calculator-hint { + margin: 0; + color: var(--muted); + line-height: 1.55; +} + +.calculator-warning { + margin: -4px 0 0; + padding: 10px 12px; + border-radius: 14px; + border: 1px solid rgba(255, 178, 107, 0.22); + background: rgba(255, 178, 107, 0.08); + color: #ffe0b7; + line-height: 1.45; +} + +.calculator-selected-material { + display: grid; + gap: 6px; + padding: 12px 14px; + border-radius: 18px; + border: 1px solid rgba(209, 229, 246, 0.13); + background: rgba(255, 255, 255, 0.04); +} + +.calculator-selected-material span, +.calculator-result-grid span { + color: #b9dbf7; + font-size: 12px; +} + +.calculator-selected-material strong { + line-height: 1.45; +} + +.calculator-result-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.calculator-result-grid article { + display: grid; + gap: 6px; + padding: 12px; + border-radius: 18px; + border: 1px solid rgba(209, 229, 246, 0.12); + background: rgba(255, 255, 255, 0.045); +} + +.calculator-result-grid strong { + font-size: 17px; +} + +.calculator-focus-result { + border-color: rgba(79, 180, 255, 0.34) !important; + background: rgba(79, 180, 255, 0.1) !important; +} + +.feedback-banner { + margin-top: 14px; + margin-bottom: 8px; + padding: 12px 16px; + border-radius: 18px; + border: 1px solid rgba(69, 214, 168, 0.16); + background: rgba(69, 214, 168, 0.08); + color: #d5fff1; +} + +.error-banner { + border-color: rgba(255, 111, 97, 0.16); + background: rgba(255, 111, 97, 0.08); + color: #ffd4ce; +} + +.compact-table { + min-width: 100%; +} + +.inventory-lot-summary-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin: 12px 0; +} + +.inventory-lot-summary-grid article { + padding: 12px; + border: 1px solid #c6e2ff; + border-radius: 16px; + background: linear-gradient(145deg, #f8fbff 0%, #eef7ff 100%); + box-shadow: 0 12px 28px rgba(64, 158, 255, 0.16); +} + +.inventory-lot-summary-grid span, +.inventory-source-lots > span { + display: block; + color: #606266; + font-size: 12px; + font-weight: 600; +} + +.inventory-lot-summary-grid strong { + display: block; + margin-top: 6px; + color: #1677c7; + font-size: 18px; + font-weight: 800; +} + +.inventory-source-lots { + display: grid; + gap: 8px; + margin-bottom: 12px; +} + +.inventory-source-lots div { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.inventory-source-lots b { + padding: 6px 10px; + border-radius: 999px; + border: 1px solid #91e5c8; + background: #e9fbf4; + color: #0f7660; + font-size: 12px; + font-weight: 700; + box-shadow: 0 6px 16px rgba(15, 118, 96, 0.1); +} + +.inventory-source-lots em { + color: #909399; + font-style: normal; + font-size: 12px; +} + +.inventory-lot-row-unavailable { + opacity: 0.46; + filter: grayscale(0.45); +} + +.empty-row { + text-align: center; + color: var(--muted); +} + +.row-selected { + background: + linear-gradient(90deg, rgba(79, 180, 255, 0.18), rgba(79, 180, 255, 0.05)); + box-shadow: inset 4px 0 0 rgba(79, 180, 255, 0.82); +} + +.pagination-bar { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + margin-top: 14px; + padding: 12px 14px; + border-radius: 18px; + border: 1px solid rgba(209, 229, 246, 0.14); + background: rgba(255, 255, 255, 0.04); + backdrop-filter: blur(12px); + color: var(--muted); +} + +.pagination-actions { + display: flex; + gap: 10px; + align-items: center; + color: #d7efff; +} + +.pagination-size-field { + display: inline-flex; + gap: 8px; + align-items: center; +} + +.pagination-size-field select { + min-width: 96px; + padding: 8px 12px; + border-radius: 12px; + border: 1px solid rgba(79, 180, 255, 0.18); + background: rgba(7, 16, 26, 0.88); + color: #d7efff; + font: inherit; +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.detail-item, +.detail-block { + padding: 14px 16px; + border-radius: 18px; + border: 1px solid rgba(127, 170, 205, 0.14); + background: rgba(7, 16, 26, 0.72); +} + +.detail-item span, +.detail-block span { + display: block; + margin-bottom: 8px; + color: var(--muted); + font-size: 12px; +} + +.detail-item strong, +.detail-block strong { + color: #eef8ff; +} + +.detail-block { + grid-column: 1 / -1; +} + +.login-shell { + position: relative; + min-height: 100vh; + display: grid; + place-items: center; + padding: 36px; + overflow: hidden; + background: #07111a; + cursor: pointer; +} + +.login-revealed { + cursor: default; +} + +.login-photo-bg { + position: absolute; + inset: 0; + background: + linear-gradient(120deg, rgba(4, 10, 18, 0.34), rgba(6, 15, 25, 0.66)), + radial-gradient(circle at 50% 40%, rgba(79, 180, 255, 0.12), transparent 32%); + transform: scale(1.02); +} + +.login-photo-vignette { + position: absolute; + inset: 0; + background: + radial-gradient(circle at 50% 42%, rgba(255, 255, 255, 0.12), transparent 25%), + radial-gradient(circle at 16% 18%, rgba(79, 180, 255, 0.22), transparent 28%), + linear-gradient(180deg, rgba(3, 8, 14, 0.08), rgba(3, 8, 14, 0.76)); + pointer-events: none; +} + +.login-ambient { + position: absolute; + inset: 0; + background: + radial-gradient(circle at 15% 20%, rgba(79, 180, 255, 0.14), transparent 22%), + radial-gradient(circle at 80% 18%, rgba(242, 157, 82, 0.15), transparent 20%), + radial-gradient(circle at 50% 85%, rgba(69, 214, 168, 0.08), transparent 24%); + pointer-events: none; +} + +.login-entry, +.login-panel { + position: relative; + z-index: 1; +} + +.login-entry { + display: grid; + justify-items: center; + gap: 18px; + transform-origin: center; + animation: loginBrandPop 760ms cubic-bezier(0.2, 1, 0.22, 1) both; + transition: transform 520ms cubic-bezier(0.2, 1, 0.22, 1); +} + +.login-panel { + position: absolute; + top: 53%; + left: 50%; + z-index: 2; + width: min(462px, calc(100vw - 42px)); + padding: 30px; + border-radius: 30px; + border: 1px solid rgba(209, 229, 246, 0.24); + background: linear-gradient(180deg, rgba(7, 18, 31, 0.72), rgba(4, 11, 19, 0.68)); + box-shadow: 0 28px 90px rgba(0, 0, 0, 0.42), inset 0 0 0 1px rgba(255, 255, 255, 0.035); + backdrop-filter: blur(22px); + opacity: 0; + pointer-events: none; + transform: translate(-50%, -32%) translateY(42px) scale(0.96); + transition: + opacity 360ms ease, + transform 520ms cubic-bezier(0.2, 1, 0.22, 1); +} + +.login-revealed .login-entry { + animation: none; + transform: translateY(clamp(-250px, -27vh, -176px)) scale(0.68); +} + +.login-revealed .login-panel { + opacity: 1; + pointer-events: auto; + transform: translate(-50%, -30%) translateY(0) scale(1); + transition-delay: 180ms; +} + +.login-brand { + display: grid; + grid-template-columns: 132px minmax(0, auto); + gap: 26px; + align-items: center; + padding: 30px 36px; + border-radius: 34px; + border: 1px solid rgba(209, 229, 246, 0.22); + background: linear-gradient(135deg, rgba(6, 16, 27, 0.62), rgba(7, 19, 31, 0.38)); + box-shadow: 0 28px 90px rgba(0, 0, 0, 0.38), inset 0 0 0 1px rgba(255, 255, 255, 0.035); + backdrop-filter: blur(12px); +} + +.login-logo { + width: 132px; + height: 132px; + object-fit: contain; + filter: drop-shadow(0 18px 32px rgba(79, 180, 255, 0.22)); +} + +.login-brand h1, +.login-panel h2 { + margin: 0 0 12px; + font-size: clamp(34px, 4vw, 54px); + letter-spacing: 0.05em; +} + +.login-stat-card span { + color: var(--muted); +} + +.login-stat-card strong { + display: block; + margin-top: 10px; + font-size: 20px; +} + +.login-click-tip { + margin: 0; + padding: 10px 16px; + border-radius: 999px; + border: 1px solid rgba(79, 180, 255, 0.18); + background: rgba(4, 11, 19, 0.5); + color: #d7efff; + letter-spacing: 0.08em; + backdrop-filter: blur(10px); + transition: opacity 320ms ease, transform 320ms ease; +} + +.login-revealed .login-click-tip { + opacity: 0; + transform: translateY(-8px); + pointer-events: none; +} + +@keyframes loginBrandPop { + 0% { + opacity: 0; + transform: translateY(34px) scale(0.78); + filter: blur(8px); + } + 64% { + opacity: 1; + transform: translateY(-4px) scale(1.04); + filter: blur(0); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + filter: blur(0); + } +} + +@keyframes speakerPulse { + 0%, + 100% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(79, 180, 255, 0.24); + } + 50% { + transform: scale(1.08); + box-shadow: 0 0 0 8px rgba(79, 180, 255, 0); + } +} + +@keyframes botHop { + 0%, + 100% { + transform: translateY(0); + } + 42% { + transform: translateY(-8px); + } + 58% { + transform: translateY(-5px); + } +} + +@keyframes botWave { + 0%, + 100% { + transform: rotate(-8deg); + } + 42% { + transform: rotate(14deg) translateY(-2px); + } +} + +@keyframes botBlink { + 0%, + 92%, + 100% { + transform: scaleY(1); + } + 96% { + transform: scaleY(0.15); + } +} + +@keyframes broadcastMarquee { + from { + transform: translateX(var(--broadcast-marquee-width)); + } + to { + transform: translateX(-100%); + } +} + +@keyframes assistantDrawerPop { + from { + opacity: 0; + transform: translate(18px, -12px) scale(0.94); + } + to { + opacity: 1; + transform: translate(0, 0) scale(1); + } +} + +.login-panel-head p { + color: var(--muted); + margin: 0; +} + +.login-form { + display: grid; + gap: 16px; + margin-top: 20px; + margin-bottom: 20px; +} + +.login-actions { + display: grid; + gap: 10px; +} + +.form-message, +.form-error { + margin: 0; + font-size: 13px; +} + +.form-message { + color: #d5fff1; +} + +.form-error { + color: #ffd4ce; +} + +.workflow-inline-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + +.workflow-drawer-mask { + position: fixed; + inset: 0; + z-index: 60; + display: flex; + align-items: stretch; + justify-content: flex-end; + padding: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) + max(12px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left)); + overflow: hidden; + overscroll-behavior: contain; + background: rgba(4, 10, 17, 0.72); + backdrop-filter: blur(10px); +} + +.workflow-drawer { + width: min(1460px, calc(100vw - 24px)); + height: auto; + max-height: calc(100dvh - 24px); + min-height: 0; + overflow: auto; + overscroll-behavior: contain; + padding: 24px; + background: + radial-gradient(circle at top right, rgba(79, 180, 255, 0.14), transparent 24%), + linear-gradient(180deg, rgba(9, 18, 29, 0.98), rgba(5, 11, 18, 0.98)); + border-left: 1px solid rgba(79, 180, 255, 0.2); + border-radius: 28px 0 0 28px; + box-shadow: -24px 0 70px rgba(0, 0, 0, 0.32); +} + +.assistant-chat-mask { + justify-content: flex-end; + align-items: flex-start; + padding-top: 84px; +} + +.assistant-chat-drawer { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + width: min(560px, calc(100vw - 24px)); + height: min(720px, calc(100dvh - 108px)); + min-height: 0; + overflow: hidden; + border-radius: 28px; + border: 1px solid rgba(126, 198, 255, 0.34); + background: + radial-gradient(circle at top left, rgba(79, 180, 255, 0.16), transparent 30%), + linear-gradient(180deg, rgba(9, 18, 29, 0.98), rgba(5, 11, 18, 0.98)); + box-shadow: -18px 20px 70px rgba(0, 0, 0, 0.36); + transform-origin: top right; + animation: assistantDrawerPop 180ms ease-out; +} + +.assistant-chat-head { + display: flex; + justify-content: space-between; + gap: 14px; + padding: 18px; + border-bottom: 1px solid rgba(126, 198, 255, 0.16); +} + +.assistant-chat-title { + display: flex; + gap: 12px; + align-items: center; +} + +.assistant-chat-title h3, +.assistant-chat-title p { + margin: 0; +} + +.assistant-chat-avatar { + display: inline-grid; + place-items: center; + width: 46px; + height: 46px; + flex: 0 0 auto; + border-radius: 16px; + background: rgba(79, 180, 255, 0.13); +} + +.assistant-chat-body { + display: grid; + align-content: start; + gap: 12px; + min-height: 0; + overflow-y: auto; + padding: 18px; +} + +.assistant-message { + display: flex; +} + +.assistant-message-user { + justify-content: flex-end; +} + +.assistant-message-bubble { + max-width: 82%; + padding: 12px 14px; + border-radius: 18px; + color: #eef8ff; + line-height: 1.65; + background: rgba(255, 255, 255, 0.08); +} + +.assistant-message-user .assistant-message-bubble { + background: linear-gradient(135deg, rgba(79, 180, 255, 0.34), rgba(69, 214, 168, 0.18)); +} + +.assistant-message-assistant .assistant-message-bubble { + border: 1px solid rgba(126, 198, 255, 0.16); +} + +.assistant-chat-input { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + padding: 14px; + border-top: 1px solid rgba(126, 198, 255, 0.16); +} + +.assistant-chat-input textarea { + resize: none; +} + +.product-workflow-drawer { + position: fixed; + top: max(12px, env(safe-area-inset-top)); + right: max(12px, env(safe-area-inset-right)); + bottom: max(12px, env(safe-area-inset-bottom)); + display: flex; + flex-direction: column; + width: min(1460px, calc(100vw - 24px)); + height: auto; + max-height: none; + margin: 0; + overflow: hidden; + border-radius: 28px 0 0 28px; +} + +.workflow-drawer-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + padding-right: 4px; +} + +.workflow-drawer-scroll::-webkit-scrollbar { + width: 10px; +} + +.workflow-drawer-scroll::-webkit-scrollbar-thumb { + border-radius: 999px; + background: rgba(126, 198, 255, 0.28); +} + +.workflow-drawer-head { + display: flex; + justify-content: space-between; + gap: 18px; + align-items: flex-start; + margin-bottom: 18px; +} + +.workflow-drawer-head-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px; + align-items: center; +} + +.workflow-stepper { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 18px; +} + +.workflow-step { + position: relative; + display: grid; + grid-template-columns: 48px minmax(0, 1fr); + gap: 12px; + align-items: start; + padding: 14px; + border-radius: 20px; + border: 1px solid rgba(79, 180, 255, 0.14); + background: rgba(13, 25, 39, 0.82); + color: inherit; + cursor: pointer; + text-align: left; + font: inherit; + transition: transform 180ms ease, border-color 180ms ease, background 180ms ease, opacity 180ms ease, box-shadow 180ms ease; +} + +.workflow-step:hover { + transform: translateY(-1px); + border-color: rgba(79, 180, 255, 0.26); +} + +.workflow-step-active { + transform: translateY(-1px); + border-color: rgba(79, 180, 255, 0.62); + background: + linear-gradient(90deg, rgba(79, 180, 255, 0.2), transparent 42%), + linear-gradient(180deg, rgba(17, 42, 66, 0.98), rgba(10, 19, 31, 0.98)); + box-shadow: + inset 4px 0 0 rgba(79, 180, 255, 0.84), + 0 0 0 1px rgba(79, 180, 255, 0.18), + 0 14px 34px rgba(31, 122, 184, 0.18); +} + +.workflow-step-active .workflow-step-index { + border-color: rgba(127, 210, 255, 0.48); + background: linear-gradient(180deg, rgba(127, 210, 255, 0.36), rgba(69, 214, 168, 0.14)); + color: #ffffff; + box-shadow: 0 0 0 5px rgba(79, 180, 255, 0.1); +} + +.workflow-step-completed .workflow-step-index { + background: linear-gradient(180deg, rgba(69, 214, 168, 0.26), rgba(69, 214, 168, 0.08)); + border-color: rgba(69, 214, 168, 0.24); + color: #dffcf4; +} + +.workflow-step-locked { + opacity: 0.58; +} + +.workflow-step-index { + display: inline-flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: 16px; + border: 1px solid rgba(79, 180, 255, 0.16); + background: linear-gradient(180deg, rgba(79, 180, 255, 0.12), rgba(79, 180, 255, 0.04)); + color: #d8edff; + letter-spacing: 0.12em; + font-size: 12px; +} + +.workflow-step-body { + display: grid; + gap: 5px; +} + +.workflow-step-body strong { + font-size: 14px; + letter-spacing: 0.03em; +} + +.workflow-step-body p { + margin: 0; + color: var(--muted); + line-height: 1.5; + font-size: 12px; +} + +.workflow-drawer-grid { + display: grid; + grid-template-columns: minmax(0, 2.1fr) minmax(300px, 0.9fr); + gap: 18px; + align-items: start; +} + +.workflow-drawer-main, +.workflow-drawer-side { + display: grid; + gap: 18px; +} + +.workflow-section-card, +.workflow-summary-card { + padding: 22px; + border-radius: 24px; + border: 1px solid rgba(209, 229, 246, 0.16); + background: rgba(255, 255, 255, 0.045); + backdrop-filter: blur(14px); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02); +} + +.workflow-tip, +.workflow-empty-card { + padding: 14px 16px; + border-radius: 18px; + border: 1px solid rgba(79, 180, 255, 0.14); + background: rgba(11, 22, 35, 0.7); + color: var(--muted); + line-height: 1.7; +} + +.workflow-selection-bar { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + margin-bottom: 14px; +} + +.workflow-picker-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 12px; +} + +.workflow-picker-card { + display: grid; + grid-template-columns: 20px minmax(0, 1fr); + gap: 12px; + align-items: start; + padding: 14px; + border-radius: 18px; + border: 1px solid rgba(109, 152, 189, 0.16); + background: rgba(10, 19, 31, 0.8); + cursor: pointer; + transition: transform 180ms ease, border-color 180ms ease, background 180ms ease; +} + +.workflow-picker-card:hover { + transform: translateY(-1px); + border-color: rgba(79, 180, 255, 0.24); +} + +.workflow-picker-card-selected { + border-color: rgba(79, 180, 255, 0.56); + background: + linear-gradient(90deg, rgba(79, 180, 255, 0.18), transparent 42%), + linear-gradient(180deg, rgba(16, 34, 53, 0.95), rgba(10, 19, 31, 0.95)); + box-shadow: + inset 4px 0 0 rgba(79, 180, 255, 0.78), + 0 0 0 1px rgba(79, 180, 255, 0.16); +} + +.workflow-picker-card input { + margin-top: 4px; +} + +.workflow-picker-content { + display: grid; + gap: 6px; +} + +.workflow-picker-content strong { + font-size: 14px; +} + +.workflow-picker-content p { + margin: 0; + color: var(--muted); + line-height: 1.55; + font-size: 13px; +} + +.workflow-inline-form { + margin-top: 18px; +} + +.workflow-inline-checkbox { + align-self: end; + min-height: 44px; +} + +.workflow-summary-list { + display: grid; + gap: 12px; + margin-top: 14px; +} + +.workflow-summary-list div { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + padding-bottom: 10px; + border-bottom: 1px solid rgba(109, 152, 189, 0.12); +} + +.workflow-summary-list div:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.workflow-summary-list span { + color: var(--muted); +} + +.workflow-summary-list strong { + font-size: 14px; +} + +.workflow-chip-row, +.workflow-chip-column { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.workflow-chip-column { + margin-top: 14px; +} + +.workflow-muted-note { + color: var(--muted); + line-height: 1.6; +} + +.workflow-divider { + margin: 16px 0; + border: 0; + border-top: 1px solid rgba(109, 152, 189, 0.12); +} + +.workflow-side-actions { + display: flex; + gap: 10px; + margin-top: 12px; +} + +.workflow-close-button { + margin-top: 12px; +} + +.finance-board-panel { + padding: 0; +} + +.finance-board-header { + display: flex; + justify-content: space-between; + gap: 20px; + align-items: flex-start; + padding: 24px 24px 0 24px; +} + +.finance-board-title h3 { + font-size: clamp(24px, 3vw, 34px); + margin-bottom: 12px; +} + +.finance-board-title p:last-child { + max-width: 64ch; +} + +.finance-board-toolbar { + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: flex-end; + align-items: flex-end; +} + +.finance-compact-field { + min-width: 168px; +} + +.finance-board-panel > .feedback-banner { + margin: 0 24px 16px 24px; +} + +.finance-inline-stats { + display: flex; + flex-wrap: wrap; + gap: 12px; + padding: 0 24px 18px 24px; + margin-top: 18px; +} + +.finance-inline-stat { + flex: 1 1 180px; + min-width: 180px; + padding: 14px 16px; +} + +.finance-inline-stat span { + display: block; + margin-bottom: 8px; +} + +.finance-inline-stat strong { + display: block; + font-size: 22px; + letter-spacing: 0.02em; +} + +.finance-board-tags { + display: flex; + flex-wrap: wrap; + gap: 10px; + padding: 0 24px 18px 24px; +} + +.finance-sheet-wrap { + padding: 0 24px 24px 24px; + overflow: auto; +} + +.finance-sheet-table { + width: max-content; + min-width: 100%; + border-collapse: separate; + border-spacing: 0; +} + +.finance-sheet-table th, +.finance-sheet-table td { + padding: 11px 10px; + white-space: nowrap; +} + +.finance-sheet-title-row th { + position: sticky; + padding: 18px 16px; + text-align: center; +} + +.finance-sheet-group-row th { + position: sticky; + padding: 12px 10px; + text-align: center; +} + +.finance-sheet-group-single { + white-space: normal; + line-height: 1.45; +} + +.finance-sheet-column-row th { + position: sticky; + line-height: 1.45; +} + +.finance-sticky-cell { + position: sticky; +} + +.finance-sheet-table tbody .finance-sticky-cell { + z-index: 8; +} + +.finance-sheet-column-row .finance-sticky-cell, +.finance-sheet-group-row .finance-sticky-cell, +.finance-sheet-title-row .finance-sticky-cell { + z-index: 20; +} + +.finance-sticky-1 { + left: 0; +} + +.finance-sticky-2 { + left: 84px; +} + +.finance-sticky-3 { + left: 200px; +} + +.finance-sticky-4 { + left: 360px; +} + +.finance-sticky-5 { + left: 520px; +} + +.finance-number-cell { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.finance-code-cell { + display: grid; + gap: 4px; +} + +.finance-code-cell strong { + font-size: 13px; + letter-spacing: 0.03em; +} + +.finance-code-cell small, +.finance-note-cell span { + line-height: 1.5; +} + +.finance-note-cell { + max-width: 240px; + white-space: normal; +} + +.finance-mode-chip { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 78px; + padding: 6px 10px; + border-radius: 999px; + font-size: 12px; +} + +.negative-value { + color: #ffb0a7; +} + +.finance-empty-cell { + padding: 28px 16px !important; + text-align: center; +} + +.finance-footer-stack { + display: grid; + gap: 16px; + padding: 0 24px 24px 24px; +} + +.finance-rollup-table, +.finance-expense-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; +} + +.finance-rollup-table th, +.finance-rollup-table td, +.finance-expense-table th, +.finance-expense-table td { + padding: 14px 16px; +} + +.finance-rollup-table th, +.finance-expense-table th { + text-align: left; +} + +.finance-rollup-table td, +.finance-expense-table td { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.finance-expense-profit-row th, +.finance-expense-profit-row td { + font-size: 16px; + background: rgba(79, 180, 255, 0.06); +} + +.finance-expense-drawer { + width: min(1120px, calc(100vw - 24px)); + overflow: auto; +} + +.form-drawer { + width: min(860px, calc(100vw - 24px)); +} + +.form-drawer-wide { + width: min(1180px, calc(100vw - 24px)); +} + +.form-drawer-xl { + width: min(1280px, calc(100vw - 40px)); + max-width: calc(100vw - 40px); +} + +.form-drawer-fullscreen { + width: min(1680px, calc(100vw - 28px)); + max-width: calc(100vw - 28px); + height: min(940px, calc(100vh - 28px)); +} + +.form-drawer-fullscreen .form-drawer-body, +.form-drawer-xl .form-drawer-body { + min-width: 0; +} + +.form-drawer-body { + margin-top: 18px; +} + +.finance-expense-grid { + display: grid; + grid-template-columns: minmax(0, 1.3fr) minmax(340px, 0.7fr); + gap: 18px; +} + +@media (max-width: 1180px) { + .shell { + grid-template-columns: 1fr; + } + + .sidebar { + border-right: none; + border-bottom: 1px solid var(--line); + } + + .panel-hero, + .flow-grid, + .planning-grid, + .two-column-panel, + .customer-info-card, + .matrix-grid, + .field-grid, + .summary-grid, + .kpi-grid, + .double-field, + .triple-field, + .purchase-order-compose, + .login-shell { + grid-template-columns: 1fr; + } + + .purchase-demand-calculator { + position: relative; + max-height: none; + } + + .login-brand { + grid-template-columns: 1fr; + } + + .workflow-stepper { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .workflow-drawer-grid, + .finance-expense-grid { + grid-template-columns: 1fr; + } + + .dual-drawer-shell { + width: min(760px, 100%); + flex-direction: column; + overflow-y: auto; + overscroll-behavior: contain; + } + + .compact-side-drawer { + width: 100%; + min-height: 50vh; + } + + .finance-board-header { + flex-direction: column; + } + + .finance-board-toolbar { + justify-content: flex-start; + } + +} + +@media (max-width: 720px) { + .main-panel, + .sidebar { + padding: 18px; + } + + .topbar { + flex-direction: column; + } + + .brand { + grid-template-columns: 64px minmax(0, 1fr); + } + + .brand-logo { + width: 64px; + height: 64px; + } + + .nav-stage { + grid-template-columns: 42px minmax(0, 1fr); + } + + .nav-stage-step { + width: 42px; + height: 42px; + } + + .nav-stage-tail { + display: none; + } + + .nav-group-button { + grid-template-columns: 42px minmax(0, 1fr) 24px; + gap: 12px; + padding: 14px; + } + + .nav-group-step { + width: 42px; + height: 42px; + } + + .nav-group-title-row { + flex-direction: column; + align-items: flex-start; + } + + .workspace-tabbar { + grid-template-columns: 1fr; + } + + .workspace-tab { + grid-template-columns: 44px minmax(0, 1fr); + } + + .workspace-tab-step { + width: 44px; + height: 44px; + } + + .workspace-section-head { + flex-direction: column; + } + + .login-shell { + padding: 18px; + } + + .login-entry, + .login-panel { + padding: 22px; + } + + .login-brand { + gap: 16px; + padding: 22px; + text-align: center; + } + + .login-logo { + width: 84px; + height: 84px; + } + + .login-revealed .login-entry { + animation: none; + transform: translateY(-200px) scale(0.58); + } + + .login-panel { + top: 60%; + } + + .workflow-drawer { + width: 100%; + max-height: calc(100dvh - 20px); + padding: 18px; + } + + .workflow-drawer-mask.dual-drawer-mask { + padding: 10px; + } + + .dual-drawer-shell { + width: 100%; + } + + .workflow-drawer-head, + .workflow-selection-bar { + flex-direction: column; + align-items: flex-start; + } + + .finance-board-header, + .finance-inline-stats, + .finance-board-tags, + .finance-sheet-wrap, + .finance-footer-stack { + padding-left: 18px; + padding-right: 18px; + } + + .finance-board-toolbar { + width: 100%; + } + + .finance-compact-field { + width: 100%; + } + + .finance-precision-panel .finance-sheet-wrap { + max-height: 72vh; + } + + .finance-precision-panel .finance-sticky-cell { + position: sticky; + } + + .finance-precision-panel .finance-sheet-title-row th, + .finance-precision-panel .finance-sheet-group-row th, + .finance-precision-panel .finance-sheet-column-row th { + position: sticky; + } + + .workflow-stepper { + grid-template-columns: 1fr; + } + + .workflow-step { + grid-template-columns: 42px minmax(0, 1fr); + } + + .workflow-step-index { + width: 42px; + height: 42px; + } + + .workflow-picker-grid { + grid-template-columns: 1fr; + } + + .workflow-side-actions { + flex-direction: column; + } +} + +/* RuoYi/Vfadmin inspired layout and light admin theme */ +:root { + color-scheme: light; + --bg: #f0f2f5; + --bg-deep: #e9edf3; + --panel: #ffffff; + --panel-strong: #ffffff; + --line: #ebeef5; + --text: #303133; + --muted: #909399; + --steel: #409eff; + --copper: #e6a23c; + --warning: #e6a23c; + --danger: #f56c6c; + --success: #67c23a; + --shadow: 0 2px 12px rgba(0, 0, 0, 0.06); + --font-ui: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif; +} + +body { + color: var(--text); + background: #f0f2f5; + font-family: var(--font-ui); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +body::before, +.app-photo-backdrop, +.app-glass-tint { + content: none; + display: none; +} + +.shell.admin-shell { + display: grid; + grid-template-columns: 200px minmax(0, 1fr); + height: 100vh; + min-height: 100vh; + overflow: hidden; + background: #f0f2f5; +} + +.shell.admin-shell.sidebar-collapsed { + grid-template-columns: 64px minmax(0, 1fr); +} + +.admin-shell .sidebar { + z-index: 10; + display: flex; + flex-direction: column; + min-height: 0; + height: 100vh; + padding: 0; + border: 0; + background: #304156; + color: #bfcbd9; + box-shadow: 2px 0 6px rgba(0, 21, 41, 0.18); + backdrop-filter: none; + overflow: hidden; +} + +.admin-shell .brand { + grid-template-columns: 36px minmax(0, 1fr); + gap: 10px; + align-items: center; + height: 50px; + min-height: 50px; + margin: 0; + padding: 0 14px; + overflow: hidden; + background: #2b2f3a; + color: #ffffff; +} + +.admin-shell .brand-logo { + width: 32px; + height: 32px; + filter: none; +} + +.admin-shell .brand h1 { + overflow: hidden; + color: #ffffff; + font-size: 15px; + font-weight: 600; + letter-spacing: 0; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-shell.sidebar-collapsed .brand { + grid-template-columns: 1fr; + justify-items: center; + padding: 0; +} + +.admin-shell.sidebar-collapsed .brand h1, +.admin-shell.sidebar-collapsed .nav-stage-body { + display: none; +} + +.admin-shell.sidebar-collapsed .nav-stage { + grid-template-columns: 1fr; + justify-items: center; + padding: 0; +} + +.admin-shell .brand-subtitle, +.admin-shell .brand-caption { + display: none; +} + +.admin-shell .nav-stage-list { + flex: 1; + min-height: 0; + display: block; + overflow-y: auto; + padding: 8px 0; +} + +.admin-shell .nav-stage-list::-webkit-scrollbar { + width: 6px; +} + +.admin-shell .nav-stage-list::-webkit-scrollbar-thumb { + border-radius: 999px; + background: rgba(191, 203, 217, 0.28); +} + +.admin-shell .nav-stage { + display: grid; + grid-template-columns: 22px minmax(0, 1fr); + gap: 10px; + align-items: center; + min-height: 50px; + padding: 0 20px; + border: 0; + border-radius: 0; + background: transparent; + color: #bfcbd9; + transition: color 0.18s ease, background 0.18s ease; +} + +.admin-shell .nav-stage-has-children { + grid-template-columns: 22px minmax(0, 1fr) 14px; +} + +.admin-shell .nav-stage:hover { + transform: none; + background: #263445; + color: #ffffff; +} + +.admin-shell .router-link-active.nav-stage, +.admin-shell .nav-stage-active { + transform: none; + background: #263445; + color: #409eff; + box-shadow: none; +} + +.admin-shell .router-link-active.nav-stage::before, +.admin-shell .nav-stage-active::before { + inset: 0 auto 0 0; + width: 3px; + border-radius: 0; + background: #409eff; +} + +.admin-shell .nav-stage-step { + width: 22px; + height: 22px; + border: 0; + border-radius: 0; + background: transparent; + color: inherit; +} + +.admin-shell .nav-stage-step svg { + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.admin-shell .nav-stage-body { + min-width: 0; +} + +.admin-shell .nav-stage-body strong { + margin: 0; + overflow: hidden; + font-size: 14px; + font-weight: 500; + letter-spacing: 0; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-shell .nav-stage-body p, +.admin-shell .sidebar-footer { + display: none; +} + +.admin-shell .nav-stage-chevron { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + color: inherit; + transition: transform 0.18s ease; +} + +.admin-shell .nav-stage-chevron svg { + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.admin-shell .nav-stage-group-expanded .nav-stage-chevron { + transform: rotate(180deg); +} + +.admin-shell .nav-submenu { + margin: -2px 8px 8px 8px; + padding: 4px 0 4px 34px; + border-radius: 0 0 10px 10px; + background: #1f2d3d; +} + +.admin-shell .nav-subitem { + position: relative; + display: flex; + align-items: center; + gap: 9px; + min-height: 38px; + padding: 0 12px; + border-radius: 8px; + color: #bfcbd9; + font-size: 13px; + transition: color 0.18s ease, background 0.18s ease, transform 0.18s ease; +} + +.admin-shell .nav-subitem:hover, +.admin-shell .nav-subitem-active, +.admin-shell .router-link-active.nav-subitem { + transform: translateX(2px); + background: rgba(64, 158, 255, 0.12); + color: #409eff; +} + +.admin-shell .nav-subitem-dot { + width: 5px; + height: 5px; + border-radius: 999px; + background: currentColor; + opacity: 0.72; +} + +.admin-shell.sidebar-collapsed .nav-stage-has-children { + grid-template-columns: 1fr; +} + +.admin-shell.sidebar-collapsed .nav-stage-chevron, +.admin-shell.sidebar-collapsed .nav-submenu { + display: none; +} + +.admin-shell .main-panel { + z-index: 1; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + height: 100vh; + padding: 0; + overflow: hidden; + background: #f0f2f5; +} + +.admin-shell .topbar { + display: flex; + align-items: center; + justify-content: space-between; + flex: 0 0 50px; + height: 50px; + margin: 0; + padding: 0 14px; + border-bottom: 1px solid #e6e6e6; + background: #ffffff; + color: #303133; + box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08); +} + +.topbar-left { + display: flex; + align-items: center; + min-width: 0; + height: 100%; +} + +.hamburger-container { + display: inline-grid; + place-items: center; + gap: 3px; + width: 44px; + height: 50px; + margin: 0 8px 0 -10px; + border: 0; + background: transparent; + cursor: pointer; +} + +.hamburger-container:hover { + background: rgba(0, 0, 0, 0.025); +} + +.hamburger-container span { + display: block; + width: 18px; + height: 2px; + border-radius: 99px; + background: #606266; +} + +.breadcrumb-container { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + color: #606266; + font-size: 14px; + white-space: nowrap; +} + +.breadcrumb-container strong { + overflow: hidden; + color: #303133; + font-weight: 500; + text-overflow: ellipsis; +} + +.breadcrumb-separator { + color: #c0c4cc; +} + +.admin-shell .topbar-right { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + min-width: 0; +} + +.admin-shell .topbar-meta { + flex-wrap: nowrap; + gap: 8px; + align-items: center; +} + +.admin-shell .topbar-tag { + min-height: 28px; + padding: 5px 9px; + border: 1px solid #dcdfe6; + background: #ffffff; + color: #606266; +} + +.admin-shell .broadcast-banner { + max-width: min(520px, 42vw); + min-height: 30px; + padding: 5px 10px; + border: 1px solid #dcdfe6; + border-radius: 4px; + background: #f4f4f5; + color: #606266; + box-shadow: none; + backdrop-filter: none; +} + +.admin-shell .broadcast-banner strong { + color: #303133; +} + +.broadcast-marquee { + position: relative; + min-width: 0; + width: min(460px, 36vw); + overflow: hidden; + --broadcast-marquee-width: min(460px, 36vw); +} + +.broadcast-marquee-track { + display: inline-flex; + align-items: center; + gap: 12px; + min-width: max-content; + white-space: nowrap; + will-change: transform; + animation: broadcastMarquee 18s linear infinite; +} + +.broadcast-marquee-track strong, +.broadcast-marquee-track span { + overflow: visible; + text-overflow: clip; +} + +.broadcast-marquee:hover .broadcast-marquee-track { + animation-play-state: paused; +} + +.admin-shell .broadcast-speaker { + width: 22px; + height: 22px; + background: #ecf5ff; + color: #409eff; +} + +.admin-shell .assistant-bot-button { + width: 36px; + height: 36px; + border-color: transparent; + border-radius: 999px; + background: transparent; + color: #409eff; + box-shadow: none; +} + +.admin-shell .assistant-bot-button:hover { + border-color: transparent; + background: transparent; +} + +.admin-shell .assistant-bot-wave { + top: -11px; + right: -9px; + background: #409eff; + color: #ffffff; +} + +.admin-shell .assistant-bot-face { + border-color: #b3d8ff; + background: #ecf5ff; +} + +.admin-shell .tags-view-container { + display: flex; + align-items: center; + gap: 4px; + flex: 0 0 34px; + min-height: 34px; + padding: 0 10px; + overflow-x: auto; + border-bottom: 1px solid #d8dce5; + background: #ffffff; + box-shadow: 0 1px 3px rgba(0, 21, 41, 0.04); +} + +.admin-shell .tags-view-container::-webkit-scrollbar { + height: 4px; +} + +.admin-shell .tags-view-container::-webkit-scrollbar-thumb { + border-radius: 999px; + background: #c0c4cc; +} + +.admin-shell .tags-view-item { + display: inline-flex; + align-items: center; + gap: 6px; + height: 26px; + padding: 0 10px; + border: 1px solid #d8dce5; + border-radius: 2px; + background: #ffffff; + color: #495060; + font-size: 12px; + white-space: nowrap; + cursor: pointer; + font-family: inherit; +} + +.admin-shell .tags-view-open { + display: inline-flex; + align-items: center; + min-width: 0; + max-width: 180px; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; +} + +.admin-shell .tags-view-item.active { + border-color: #409eff; + background: #409eff; + color: #ffffff; +} + +.admin-shell .tags-view-title { + overflow: hidden; + max-width: 180px; + text-overflow: ellipsis; +} + +.admin-shell .tags-view-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + padding: 0; + border: 0; + border-radius: 50%; + background: transparent; + color: inherit; + font-size: 14px; + line-height: 1; + cursor: pointer; + opacity: 0.72; + transition: background 0.15s ease, color 0.15s ease, opacity 0.15s ease; +} + +.admin-shell .tags-view-close:hover { + background: #b4bccc; + color: #ffffff; + opacity: 1; +} + +.admin-shell .tags-view-item.active .tags-view-close:hover { + background: rgba(255, 255, 255, 0.28); +} + +.admin-shell .app-main { + flex: 1; + min-height: 0; + overflow: auto; + padding: 16px; + background: #f0f2f5; +} + +.page-grid { + gap: 16px; +} + +.panel, +.workspace-panel { + overflow: visible; + padding: 18px; + border: 1px solid #e6ebf5; + border-radius: 4px; + background: #ffffff; + color: #303133; + box-shadow: 0 1px 4px rgba(0, 21, 41, 0.04); + backdrop-filter: none; +} + +.panel::after { + content: none; +} + +.panel-header, +.workspace-section-head, +.subsection-head, +.action-row { + align-items: center; +} + +.panel h3, +.workspace-section-head h3, +.flow-group-head h4, +.workflow-drawer-head h3, +.workflow-section-card h3, +.finance-board-title h3, +.login-panel h2 { + color: #303133; + letter-spacing: 0; +} + +.brand-subtitle, +.eyebrow { + margin-bottom: 4px; + color: #909399; + font-size: 12px; + letter-spacing: 0; + text-transform: none; +} + +.panel p, +.hero-copy p, +.kpi-note, +.doc-note, +.workspace-section-note, +.workflow-muted-note, +.workflow-tip, +.workflow-empty-card { + color: #909399; +} + +.panel-tag, +.table-chip, +.table-badge, +.required-chip, +.optional-chip, +.status-chip, +.topbar-tag { + min-height: 26px; + padding: 5px 9px; + border-radius: 4px; + border-color: #d9ecff; + background: #ecf5ff; + color: #409eff; +} + +.required-chip, +.badge-danger { + border-color: #fde2e2; + background: #fef0f0; + color: #f56c6c; +} + +.badge-normal, +.table-chip, +.optional-chip { + border-color: #d9ecff; + background: #ecf5ff; + color: #409eff; +} + +.kpi-card, +.hero-stat, +.summary-card, +.highlight-panel, +.matrix-card, +.field-card, +.flow-step, +.workflow-section-card, +.workflow-summary-card, +.order-item-card, +.customer-info-card, +.detail-item, +.detail-block, +.calculator-selected-material, +.calculator-result-grid article, +.extension-status-card { + border: 1px solid #ebeef5; + border-radius: 4px; + background: #ffffff; + color: #303133; + box-shadow: none; + backdrop-filter: none; +} + +.kpi-value, +.hero-stat strong, +.summary-card strong, +.detail-item strong, +.detail-block strong, +.customer-info-card strong { + color: #303133; +} + +.kpi-label, +.hero-stat span, +.summary-card span, +.detail-item span, +.detail-block span, +.customer-info-card p, +.customer-info-card span { + color: #909399; +} + +.table-wrap { + margin-top: 14px; + border: 1px solid #ebeef5; + border-radius: 4px; + background: #ffffff; +} + +.data-table { + color: #606266; +} + +.data-table th, +.data-table td { + padding: 11px 12px; + border-bottom: 1px solid #ebeef5; +} + +.data-table th { + background: #f5f7fa; + color: #606266; + font-weight: 600; +} + +.data-table tbody tr:hover td { + background: #f5f7fa; +} + +.dashboard-order-table td strong, +.link-button, +.strong-link { + color: #409eff; +} + +.dashboard-order-table td small, +.empty-row { + color: #909399; +} + +.sortable-th-button:hover, +.sortable-th-active { + color: #409eff; +} + +.sort-arrow { + border-color: #dcdfe6; + background: #ffffff; + color: #909399; +} + +.sort-arrow-active { + border-color: #409eff; + background: #ecf5ff; + color: #409eff; + box-shadow: none; +} + +.table-control-bar { + margin-top: 14px; + padding: 12px; + border: 1px solid #ebeef5; + border-radius: 4px; + background: #fafafa; + backdrop-filter: none; +} + +.table-search-field span, +.table-sort-field span, +.form-field span, +.calculator-selected-material span, +.calculator-result-grid span, +.workflow-summary-list span { + color: #606266; +} + +.table-search-field input, +.table-sort-field select, +.form-field input, +.form-field select, +.form-field textarea, +.pagination-size-field select, +.assistant-chat-input textarea { + min-height: 36px; + padding: 0 12px; + border: 1px solid #dcdfe6; + border-radius: 4px; + background: #ffffff; + color: #606266; + box-shadow: none; + backdrop-filter: none; +} + +.form-field textarea, +.assistant-chat-input textarea { + padding-top: 8px; + padding-bottom: 8px; +} + +.table-search-field input:focus, +.table-sort-field select:focus, +.form-field input:focus, +.form-field select:focus, +.form-field textarea:focus, +.assistant-chat-input textarea:focus { + border-color: #409eff; + box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.12); +} + +select option { + background: #ffffff; + color: #303133; +} + +.ghost-button, +.primary-button, +.link-button { + border-radius: 4px; + font-weight: 500; + transition: color 0.18s ease, background 0.18s ease, border-color 0.18s ease; +} + +.ghost-button { + background: #ffffff; + color: #606266; + border-color: #dcdfe6; +} + +.ghost-button:hover { + transform: none; + color: #409eff; + border-color: #c6e2ff; + background: #ecf5ff; +} + +.icon-button { + display: inline-grid !important; + place-items: center; + width: 34px; + min-width: 34px; + height: 34px; + min-height: 34px !important; + padding: 0 !important; + border-radius: 9px !important; + font-size: 0 !important; + line-height: 1 !important; +} + +.icon-button::before { + font-size: 17px; + font-weight: 800; + line-height: 1; +} + +.icon-add::before { + content: "+"; + font-size: 22px; +} + +.icon-edit::before { + content: "✎"; +} + +.icon-delete::before { + content: "×"; + font-size: 22px; +} + +.icon-close::before { + content: "×"; + font-size: 22px; +} + +.icon-refresh::before { + content: "↻"; +} + +.icon-reset::before { + content: "⟲"; +} + +.icon-save::before { + content: "✓"; +} + +.icon-pass::before { + content: "✓"; +} + +.icon-reject::before { + content: "!"; +} + +.icon-dispose::before { + content: "↪"; +} + +.icon-records::before { + content: "≡"; +} + +.icon-cost::before { + content: "¥"; +} + +.icon-upload::before { + content: "↑"; + font-size: 22px; +} + +.icon-download::before { + content: "↓"; + font-size: 22px; +} + +.icon-prev::before { + content: "‹"; + font-size: 24px; +} + +.icon-next::before { + content: "›"; + font-size: 24px; +} + +.icon-open::before { + content: "□"; +} + +.icon-generate::before { + content: "⚙"; +} + +.hidden-file-input { + display: none !important; +} + +.import-dropzone input[type="file"], +.stocktake-panel-import .import-dropzone input[type="file"] { + display: none !important; +} + +.workflow-mode-switch { + display: inline-grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 5px; + width: min(100%, 460px); + padding: 5px; + border: 1px solid rgba(37, 99, 235, 0.14); + border-radius: 16px; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(239, 246, 255, 0.86)), + #ffffff; + box-shadow: + 0 12px 26px rgba(15, 23, 42, 0.07), + inset 0 1px 0 rgba(255, 255, 255, 0.9); +} + +.workflow-mode-option { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-width: 0; + min-height: 42px; + padding: 0 12px; + overflow: hidden; + border: 1px solid transparent; + border-radius: 12px; + background: transparent; + color: var(--ui-ink-soft); + cursor: pointer; + font-weight: 800; + transition: + transform var(--ui-transition-fast), + box-shadow var(--ui-transition-fast), + background var(--ui-transition-fast), + color var(--ui-transition-fast), + border-color var(--ui-transition-fast); +} + +.workflow-mode-option:hover { + color: var(--ui-steel); + background: rgba(255, 255, 255, 0.74); + transform: translateY(-1px); +} + +.workflow-mode-option.active { + color: #ffffff; + border-color: rgba(37, 99, 235, 0.26); + background: linear-gradient(135deg, var(--ui-steel), var(--ui-cyan)); + box-shadow: 0 12px 24px rgba(37, 99, 235, 0.22); +} + +.workflow-mode-option strong { + min-width: 0; + overflow: hidden; + font-family: var(--ui-font-display); + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workflow-mode-option small { + flex: 0 0 auto; + padding: 3px 7px; + border-radius: 999px; + background: rgba(37, 99, 235, 0.08); + color: var(--ui-ink-muted); + font-size: 11px; + font-weight: 800; + line-height: 1; +} + +.workflow-mode-option.active small { + background: rgba(255, 255, 255, 0.22); + color: rgba(255, 255, 255, 0.9); +} + +.mode-option-dot { + flex: 0 0 auto; + width: 8px; + height: 8px; + border-radius: 999px; + background: rgba(37, 99, 235, 0.28); + box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.07); +} + +.workflow-mode-option.active .mode-option-dot { + background: #ffffff; + box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.22); +} + +.delivery-mode-switch { + margin-bottom: 4px; +} + +.upload-form-field { + align-content: start; +} + +.modern-upload-pill { + position: relative; + display: flex; + align-items: center; + justify-content: center; + gap: 9px; + min-height: 42px; + padding: 0 12px; + overflow: hidden; + border: 1px dashed rgba(37, 99, 235, 0.36); + border-radius: 14px; + background: + linear-gradient(135deg, rgba(239, 246, 255, 0.9), rgba(255, 255, 255, 0.92)), + #ffffff; + color: var(--ui-steel); + cursor: pointer; + font-weight: 800; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.78); + transition: + transform var(--ui-transition-fast), + box-shadow var(--ui-transition-fast), + background var(--ui-transition-fast), + border-color var(--ui-transition-fast), + color var(--ui-transition-fast); +} + +.modern-upload-pill:hover:not(.is-busy) { + border-color: rgba(14, 165, 233, 0.7); + background: + linear-gradient(135deg, rgba(219, 234, 254, 0.96), rgba(236, 253, 245, 0.84)), + #ffffff; + box-shadow: 0 10px 24px rgba(37, 99, 235, 0.12); + transform: translateY(-1px); +} + +.modern-upload-pill.is-uploaded { + border-color: rgba(22, 163, 74, 0.46); + background: + linear-gradient(135deg, rgba(220, 252, 231, 0.9), rgba(255, 255, 255, 0.92)), + #ffffff; + color: var(--ui-green); +} + +.modern-upload-pill.is-busy { + cursor: wait; + opacity: 0.72; +} + +.modern-upload-pill strong { + overflow: hidden; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.modern-upload-icon { + display: inline-grid; + flex: 0 0 auto; + place-items: center; + width: 26px; + height: 26px; + border-radius: 9px; + background: rgba(37, 99, 235, 0.1); + font-family: var(--ui-font-display); + font-size: 16px; + line-height: 1; +} + +.modern-upload-pill.is-uploaded .modern-upload-icon { + background: rgba(22, 163, 74, 0.12); +} + +.modern-upload-input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; +} + +.modern-upload-input:disabled { + cursor: wait; +} + +.modern-upload-note { + color: var(--ui-green); + font-size: 11px; + font-weight: 700; +} + +.supplier-action-bar { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 4px; + border: 1px solid rgba(220, 223, 230, 0.88); + border-radius: 14px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(245, 247, 250, 0.92)), + #ffffff; + box-shadow: + 0 8px 18px rgba(31, 45, 61, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.96); +} + +.supplier-exchange-group { + display: inline-flex; + align-items: center; + gap: 4px; + padding-left: 8px; + border-left: 1px solid rgba(220, 223, 230, 0.82); +} + +.supplier-action-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + min-height: 36px; + padding: 0 12px; + border: 1px solid transparent; + border-radius: 10px; + background: transparent; + color: #606266; + font: inherit; + font-size: 14px; + font-weight: 600; + line-height: 1; + cursor: pointer; + white-space: nowrap; + transition: + transform 0.18s ease, + box-shadow 0.18s ease, + border-color 0.18s ease, + background 0.18s ease, + color 0.18s ease; +} + +.supplier-action-button:hover:not(:disabled) { + transform: translateY(-1px); + border-color: #c6e2ff; + background: #ecf5ff; + color: #409eff; + box-shadow: 0 8px 16px rgba(64, 158, 255, 0.13); +} + +.supplier-action-button:disabled { + cursor: not-allowed; + opacity: 0.58; +} + +.supplier-action-primary { + padding: 0 14px; + border-color: rgba(64, 158, 255, 0.5); + background: linear-gradient(180deg, #66b1ff 0%, #409eff 100%); + color: #ffffff; + box-shadow: + 0 8px 16px rgba(64, 158, 255, 0.22), + inset 0 1px 0 rgba(255, 255, 255, 0.28); +} + +.supplier-action-primary:hover:not(:disabled) { + border-color: rgba(64, 158, 255, 0.66); + background: linear-gradient(180deg, #79bbff 0%, #409eff 100%); + color: #ffffff; + box-shadow: + 0 10px 20px rgba(64, 158, 255, 0.28), + inset 0 1px 0 rgba(255, 255, 255, 0.32); +} + +.supplier-action-import { + border-color: rgba(37, 99, 235, 0.28); + background: linear-gradient(180deg, #eff8ff 0%, #dbeafe 100%); + color: #1d4ed8; + box-shadow: + 0 8px 16px rgba(37, 99, 235, 0.11), + inset 0 1px 0 rgba(255, 255, 255, 0.82); +} + +.supplier-action-import:hover:not(:disabled) { + border-color: rgba(37, 99, 235, 0.42); + background: linear-gradient(180deg, #dbeafe 0%, #bfdbfe 100%); + color: #1e40af; + box-shadow: + 0 10px 20px rgba(37, 99, 235, 0.16), + inset 0 1px 0 rgba(255, 255, 255, 0.86); +} + +.supplier-action-export { + border-color: rgba(22, 163, 74, 0.3); + background: linear-gradient(180deg, #ecfdf5 0%, #d1fae5 100%); + color: #166534; + box-shadow: + 0 8px 16px rgba(22, 163, 74, 0.12), + inset 0 1px 0 rgba(255, 255, 255, 0.84); +} + +.supplier-action-export:hover:not(:disabled) { + border-color: rgba(22, 163, 74, 0.46); + background: linear-gradient(180deg, #d1fae5 0%, #bbf7d0 100%); + color: #14532d; + box-shadow: + 0 10px 20px rgba(22, 163, 74, 0.18), + inset 0 1px 0 rgba(255, 255, 255, 0.88); +} + +.supplier-action-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 8px; + background: rgba(64, 158, 255, 0.09); + color: currentColor; + font-size: 19px; + font-weight: 800; + line-height: 1; +} + +.supplier-action-primary .supplier-action-icon { + background: rgba(255, 255, 255, 0.2); +} + +.supplier-action-import .supplier-action-icon { + background: rgba(37, 99, 235, 0.12); +} + +.supplier-action-export .supplier-action-icon { + background: rgba(22, 163, 74, 0.14); +} + +.supplier-action-icon svg { + width: 15px; + height: 15px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.icon-button.primary-button { + color: #ffffff; + box-shadow: 0 8px 20px rgba(64, 158, 255, 0.18); +} + +.icon-button.danger-ghost { + color: #f56c6c; + border-color: #fde2e2; + background: #fef0f0; +} + +.action-row { + gap: 6px; +} + +.admin-shell .panel-header > div > .eyebrow, +.admin-shell .panel-header > div > h3, +.admin-shell .panel-header > div > .title-with-help, +.admin-shell .panel-header > div > p:not(.eyebrow), +.admin-shell .panel-header > .panel-tag, +.admin-shell .panel-header .panel-tag, +.admin-shell .workflow-drawer-head .eyebrow, +.admin-shell .workflow-drawer-head p:not(.eyebrow), +.admin-shell .workflow-drawer-head .panel-tag, +.admin-shell .workspace-section-head, +.admin-shell .workspace-section-note, +.admin-shell .workspace-tab p, +.admin-shell .workflow-step-button p, +.admin-shell .workflow-muted-note, +.admin-shell .workflow-tip, +.admin-shell .table-search-field > span { + display: none !important; +} + +.admin-shell .panel-header { + justify-content: flex-end; + min-height: 40px; + margin-bottom: 14px; +} + +.admin-shell .workflow-drawer-head { + align-items: center; +} + +.admin-shell .workflow-drawer-head > div:first-child { + min-width: 0; +} + +.user-menu-shell { + position: relative; + flex: 0 0 auto; +} + +.user-avatar-button { + display: inline-flex; + align-items: center; + gap: 9px; + min-height: 38px; + padding: 4px 9px 4px 5px; + border: 1px solid #dcdfe6; + border-radius: 999px; + background: #ffffff; + color: #303133; + cursor: pointer; + transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease; +} + +.user-avatar-button:hover { + border-color: #409eff; + box-shadow: 0 8px 20px rgba(64, 158, 255, 0.16); + transform: translateY(-1px); +} + +.user-avatar { + display: inline-grid; + place-items: center; + width: 30px; + height: 30px; + border-radius: 999px; + background: linear-gradient(135deg, #409eff, #67c23a); + color: #ffffff; + font-size: 14px; + font-weight: 800; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.32); +} + +.user-avatar-meta { + display: grid; + gap: 1px; + line-height: 1.1; + text-align: left; +} + +.user-avatar-meta strong { + font-size: 13px; + font-weight: 600; +} + +.user-avatar-meta small { + max-width: 128px; + overflow: hidden; + color: #909399; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-menu-dropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 80; + display: grid; + min-width: 150px; + padding: 6px; + border: 1px solid #ebeef5; + border-radius: 8px; + background: #ffffff; + box-shadow: 0 14px 34px rgba(0, 21, 41, 0.16); +} + +.user-menu-dropdown::before { + content: ""; + position: absolute; + top: -6px; + right: 24px; + width: 10px; + height: 10px; + border-top: 1px solid #ebeef5; + border-left: 1px solid #ebeef5; + background: #ffffff; + transform: rotate(45deg); +} + +.user-menu-dropdown button { + position: relative; + z-index: 1; + width: 100%; + padding: 9px 10px; + border: 0; + border-radius: 6px; + background: transparent; + color: #303133; + font: inherit; + text-align: left; + cursor: pointer; +} + +.user-menu-dropdown button:hover { + background: #ecf5ff; + color: #409eff; +} + +.user-menu-dropdown .danger-menu-item:hover { + background: #fef0f0; + color: #f56c6c; +} + +.password-modal-mask { + align-items: center; + justify-content: center; +} + +.password-modal-card { + display: grid; + gap: 18px; + width: min(480px, calc(100vw - 28px)); + max-height: calc(100dvh - 28px); + overflow: auto; + padding: 22px; + border: 1px solid rgba(126, 198, 255, 0.34); + border-radius: 20px; + background: #ffffff; + color: #303133; + box-shadow: 0 28px 76px rgba(0, 21, 41, 0.24); +} + +.password-modal-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + padding-bottom: 14px; + border-bottom: 1px solid #ebeef5; +} + +.password-modal-head h3, +.password-modal-head p { + margin: 0; +} + +.password-modal-card .form-field span { + color: #606266; +} + +.password-modal-card .form-field input { + border-radius: 8px; + border-color: #dcdfe6; + background: #ffffff; + color: #303133; +} + +.import-layout { + display: grid; + grid-template-columns: minmax(280px, 0.8fr) minmax(0, 1.2fr); + gap: 18px; + align-items: start; +} + +.import-card, +.import-result-card { + display: grid; + gap: 14px; + padding: 18px; + border: 1px solid rgba(126, 198, 255, 0.28); + border-radius: 20px; + background: rgba(255, 255, 255, 0.06); + box-shadow: 0 14px 34px rgba(0, 0, 0, 0.12); +} + +.import-dropzone { + display: grid; + place-items: center; + gap: 10px; + min-height: 220px; + padding: 22px; + border: 1px dashed rgba(126, 198, 255, 0.56); + border-radius: 18px; + background: rgba(64, 158, 255, 0.08); + text-align: center; + cursor: pointer; + transition: border-color 0.18s ease, background 0.18s ease, transform 0.18s ease; +} + +.import-dropzone:hover { + border-color: rgba(64, 158, 255, 0.95); + background: rgba(64, 158, 255, 0.14); + transform: translateY(-2px); +} + +.import-dropzone input { + display: none; +} + +.import-dropzone strong, +.import-dropzone p { + margin: 0; +} + +.import-icon { + display: inline-grid; + place-items: center; + width: 66px; + height: 66px; + border-radius: 18px; + background: linear-gradient(135deg, #67c23a, #409eff); + color: #ffffff; + font-size: 13px; + font-weight: 900; + letter-spacing: 0.08em; + box-shadow: 0 16px 28px rgba(64, 158, 255, 0.24); +} + +.compact-summary-grid { + grid-template-columns: repeat(5, minmax(0, 1fr)); +} + +.primary-button { + min-height: 36px; + padding: 9px 15px; + background: #409eff; + color: #ffffff; + border-color: #409eff; +} + +.primary-button:hover { + transform: none; + background: #66b1ff; + border-color: #66b1ff; +} + +.danger-ghost { + color: #f56c6c; + border-color: #fde2e2; + background: #fef0f0; +} + +.link-button { + color: #409eff; + text-decoration-color: rgba(64, 158, 255, 0.4); +} + +.link-button:hover { + color: #66b1ff; + text-decoration-color: rgba(64, 158, 255, 0.8); +} + +.workspace-tabbar { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; +} + +.workspace-tab { + grid-template-columns: 38px minmax(0, 1fr); + min-height: 72px; + padding: 12px; + border: 1px solid #ebeef5; + border-radius: 4px; + background: #ffffff; + color: #303133; +} + +.workspace-tab:hover, +.workspace-tab-active { + transform: none; + border-color: #409eff; + background: #ecf5ff; + color: #409eff; +} + +.workspace-tab-active { + box-shadow: inset 3px 0 0 #409eff; +} + +.workspace-tab-active::before { + content: none; +} + +.workspace-tab-step { + width: 38px; + height: 38px; + border-color: #d9ecff; + border-radius: 4px; + background: #ecf5ff; + color: #409eff; +} + +.workspace-tab p { + color: #909399; +} + +.workspace-section-note, +.feedback-banner, +.workflow-tip, +.workflow-empty-card { + border-color: #d9ecff; + border-radius: 4px; + background: #ecf5ff; + color: #606266; +} + +.form-message { + color: #67c23a; +} + +.form-error { + color: #f56c6c; +} + +.login-panel .form-error { + display: block !important; +} + +.error-banner { + border-color: #fde2e2; + background: #fef0f0; + color: #f56c6c; +} + +.row-selected { + background: #ecf5ff; + box-shadow: inset 3px 0 0 #409eff; +} + +.row-danger { + background: #fef0f0; +} + +.pagination-bar { + padding: 10px 12px; + border: 0; + border-radius: 0; + background: #ffffff; + color: #606266; + box-shadow: none; + backdrop-filter: none; +} + +.pagination-actions { + color: #606266; +} + +.workflow-drawer-mask { + z-index: 2600; + background: rgba(0, 0, 0, 0.45); + backdrop-filter: none; +} + +.workflow-drawer, +.assistant-chat-drawer { + border: 0; + border-radius: 4px; + background: #ffffff; + color: #303133; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18); +} + +.workflow-drawer { + border-left: 1px solid #ebeef5; +} + +.workflow-drawer-head, +.assistant-chat-head { + border-bottom-color: #ebeef5; +} + +.workflow-drawer-head p, +.assistant-chat-title p, +.workflow-step-body p, +.workflow-picker-content p { + color: #909399; +} + +.workflow-drawer-scroll::-webkit-scrollbar-thumb { + background: #c0c4cc; +} + +.workflow-step, +.workflow-picker-card { + border-color: #ebeef5; + border-radius: 4px; + background: #ffffff; + color: #303133; +} + +.workflow-step:hover, +.workflow-step-active, +.workflow-picker-card:hover, +.workflow-picker-card-selected { + transform: none; + border-color: #409eff; + background: #ecf5ff; + box-shadow: inset 3px 0 0 #409eff; +} + +.workflow-step-active .workflow-step-index, +.workflow-step-index { + border-color: #d9ecff; + border-radius: 4px; + background: #ecf5ff; + color: #409eff; + box-shadow: none; +} + +.checkbox-field { + border-color: #dcdfe6; + border-radius: 4px; + background: #ffffff; + color: #606266; +} + +.purchase-demand-calculator { + border-color: #d9ecff; + border-radius: 4px; + background: #f8fbff; + box-shadow: none; +} + +.calculator-warning { + border-color: #faecd8; + border-radius: 4px; + background: #fdf6ec; + color: #e6a23c; +} + +.assistant-chat-body { + background: #f5f7fa; +} + +.assistant-message-bubble { + color: #303133; + background: #ffffff; + border: 1px solid #ebeef5; +} + +.assistant-message-user .assistant-message-bubble { + color: #ffffff; + background: #409eff; + border-color: #409eff; +} + +.assistant-message-assistant .assistant-message-bubble { + border-color: #ebeef5; +} + +.assistant-chat-input { + border-top-color: #ebeef5; + background: #ffffff; +} + +.notification-toast-card { + border-color: #ebeef5; + border-radius: 4px; + background: #ffffff; + color: #303133; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); +} + +.status-badge, +.status-badge-auto { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-width: 54px; + min-height: 24px; + padding: 2px 8px; + border: 1px solid #dcdfe6; + border-radius: 4px; + background: #f4f4f5; + color: #909399; + font-size: 12px; + font-weight: 600; + line-height: 1.45; + white-space: nowrap; +} + +.status-badge-dot { + width: 6px; + height: 6px; + flex: 0 0 auto; + border-radius: 999px; + background: currentColor; + box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 16%, transparent); +} + +.status-value-cell { + font-weight: 600; +} + +.status-badge-success, +.status-tone-success { + border-color: #e1f3d8 !important; + background: #f0f9eb !important; + color: #67c23a !important; +} + +.status-badge-primary, +.status-tone-primary { + border-color: #d9ecff !important; + background: #ecf5ff !important; + color: #409eff !important; +} + +.status-badge-warning, +.status-tone-warning { + border-color: #faecd8 !important; + background: #fdf6ec !important; + color: #e6a23c !important; +} + +.status-badge-orange, +.status-tone-orange { + border-color: #ffe0b2 !important; + background: #fff4e5 !important; + color: #d97706 !important; +} + +.status-badge-danger, +.status-tone-danger { + border-color: #fde2e2 !important; + background: #fef0f0 !important; + color: #f56c6c !important; +} + +.status-badge-neutral, +.status-tone-neutral { + border-color: #e4e7ed !important; + background: #f5f7fa !important; + color: #606266 !important; +} + +.status-badge-info, +.status-tone-info { + border-color: #e9e9eb !important; + background: #f4f4f5 !important; + color: #909399 !important; +} + +.notification-toast-body strong { + color: #303133; +} + +.notification-toast-body p { + color: #606266; +} + +.notification-toast-close { + background: #f5f7fa; + color: #909399; +} + +.login-shell { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 24px; + background: #f0f2f5; + cursor: default; +} + +.login-shell::after { + content: ""; + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.08); +} + +.login-photo-bg { + display: block; + position: absolute; + inset: 0; + background: radial-gradient(circle at 50% 40%, rgba(79, 180, 255, 0.12), transparent 32%); + filter: none; + transform: none; +} + +.login-panel { + position: relative; + top: auto; + left: auto; + z-index: 1; + width: 400px; + max-width: calc(100vw - 32px); + padding: 25px 25px 12px; + border: 0; + border-radius: 6px; + background: #ffffff; + color: #303133; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.14); + opacity: 1; + pointer-events: auto; + transform: none; + transition: none; + backdrop-filter: none; +} + +.login-logo-row { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin-bottom: 14px; + color: #303133; + font-size: 15px; + font-weight: 600; +} + +.login-logo { + width: 34px; + height: 34px; + filter: none; +} + +.login-panel-head { + text-align: center; +} + +.login-panel-head h2 { + margin: 0 0 22px; + color: #707070; + font-size: 22px; + font-weight: 600; +} + +.login-panel-head p { + display: none; +} + +.login-form { + gap: 18px; + margin: 0; +} + +.login-actions { + gap: 10px; +} + +.login-actions .primary-button { + width: 100%; + min-height: 40px; + font-size: 15px; +} + +.login-photo-vignette, +.login-ambient, +.login-entry, +.login-click-tip { + display: none; +} + +@media (max-width: 1180px) { + .shell.admin-shell { + grid-template-columns: 200px minmax(0, 1fr); + } + + .admin-shell .sidebar { + border-bottom: 0; + } +} + +@media (max-width: 860px) { + .shell.admin-shell { + grid-template-columns: 64px minmax(0, 1fr); + } + + .admin-shell .brand { + grid-template-columns: 1fr; + justify-items: center; + padding: 0; + } + + .admin-shell .brand h1, + .admin-shell .nav-stage-body { + display: none; + } + + .admin-shell .nav-stage { + grid-template-columns: 1fr; + justify-items: center; + padding: 0; + } + + .admin-shell .topbar { + padding-right: 8px; + } + + .breadcrumb-container span:first-child, + .breadcrumb-separator, + .admin-shell .topbar-tag:nth-of-type(2), + .admin-shell .broadcast-banner { + display: none; + } +} + +@media (max-width: 720px) { + .admin-shell .app-main { + padding: 10px; + } + + .panel, + .workspace-panel, + .finance-board-panel { + padding: 14px; + } + + .admin-shell .topbar { + flex-direction: row; + align-items: center; + } + + .workflow-drawer { + border-radius: 4px; + } +} + +@media (max-width: 1180px) { + .lifecycle-modal { + width: min(96vw, 100%); + } + + .lifecycle-content { + grid-template-columns: 1fr; + } + + .dag-detail-panel { + max-height: 34vh; + border-left: 0; + border-top: 1px solid rgba(126, 159, 190, 0.14); + } +} + +/* 3D card elevation layer */ +:root { + --card-radius: 14px; + --card-border: rgba(217, 225, 236, 0.92); + --card-shadow-low: 0 6px 16px rgba(31, 45, 61, 0.08), 0 1px 2px rgba(31, 45, 61, 0.06); + --card-shadow-mid: 0 12px 28px rgba(31, 45, 61, 0.12), 0 3px 8px rgba(31, 45, 61, 0.08); + --card-shadow-high: 0 20px 46px rgba(31, 45, 61, 0.16), 0 8px 18px rgba(31, 45, 61, 0.1); + --card-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.92), inset 0 -1px 0 rgba(217, 225, 236, 0.42); +} + +.admin-shell .app-main { + background: + radial-gradient(circle at 18% 12%, rgba(64, 158, 255, 0.06), transparent 28%), + linear-gradient(180deg, #f5f7fb 0%, #edf1f6 100%); +} + +.panel, +.workspace-panel, +.workflow-drawer, +.assistant-chat-drawer, +.login-panel { + border: 1px solid var(--card-border); + border-radius: var(--card-radius); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(252, 254, 255, 0.96)); + box-shadow: var(--card-shadow-mid), var(--card-highlight); + transform: translateZ(0); +} + +.panel, +.workspace-panel { + transition: + transform 0.2s ease, + box-shadow 0.2s ease, + border-color 0.2s ease, + background 0.2s ease; +} + +.panel:hover, +.workspace-panel:hover { + transform: translateY(-2px); + border-color: rgba(188, 208, 235, 0.98); + box-shadow: var(--card-shadow-high), var(--card-highlight); +} + +.panel-header, +.workspace-section-head { + padding-bottom: 12px; + border-bottom: 1px solid rgba(232, 237, 246, 0.9); +} + +.table-wrap, +.table-control-bar, +.pagination-bar, +.kpi-card, +.hero-stat, +.summary-card, +.highlight-panel, +.matrix-card, +.field-card, +.flow-step, +.workflow-section-card, +.workflow-summary-card, +.order-item-card, +.customer-info-card, +.detail-item, +.detail-block, +.calculator-selected-material, +.calculator-result-grid article, +.extension-status-card { + border-color: rgba(222, 230, 241, 0.98); + border-radius: 12px; + background: + linear-gradient(180deg, #ffffff 0%, #fbfdff 100%); + box-shadow: var(--card-shadow-low), var(--card-highlight); +} + +.table-wrap, +.workflow-section-card, +.workflow-summary-card, +.order-item-card, +.detail-item, +.detail-block { + transition: + transform 0.18s ease, + box-shadow 0.18s ease, + border-color 0.18s ease, + background 0.18s ease; +} + +.workflow-section-card:hover, +.workflow-summary-card:hover, +.order-item-card:hover, +.detail-item:hover, +.detail-block:hover, +.customer-info-card:hover, +.kpi-card:hover, +.hero-stat:hover, +.summary-card:hover, +.matrix-card:hover, +.field-card:hover { + transform: translateY(-2px); + border-color: rgba(188, 208, 235, 0.98); + box-shadow: var(--card-shadow-mid), var(--card-highlight); +} + +.data-table th { + background: + linear-gradient(180deg, #f8fafd 0%, #eef3f8 100%); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.92); +} + +.data-table tbody tr { + transition: transform 0.16s ease, box-shadow 0.16s ease, background 0.16s ease; +} + +.data-table tbody tr:hover td { + background: #f7fbff; +} + +.row-selected, +.workflow-tab-active, +.workspace-tab-active, +.workflow-step-active, +.workflow-picker-card-selected { + position: relative; + transform: translateY(-2px); + border-color: rgba(64, 158, 255, 0.72) !important; + background: + linear-gradient(180deg, #f7fbff 0%, #ecf5ff 100%) !important; + box-shadow: + 0 14px 30px rgba(64, 158, 255, 0.16), + 0 4px 10px rgba(64, 158, 255, 0.1), + inset 4px 0 0 #409eff, + var(--card-highlight) !important; +} + +.workspace-tab, +.workflow-step, +.workflow-picker-card, + .nav-stage, + .tags-view-item, + .primary-button, + .ghost-button, + .status-badge, + .status-badge-auto { + transition: + transform 0.18s ease, + box-shadow 0.18s ease, + border-color 0.18s ease, + background 0.18s ease, + color 0.18s ease; +} + +.workspace-tab, +.workflow-step, +.workflow-picker-card { + box-shadow: var(--card-shadow-low), var(--card-highlight); +} + +.workspace-tab:hover, +.workflow-step:hover, +.workflow-picker-card:hover { + transform: translateY(-3px); + box-shadow: var(--card-shadow-mid), var(--card-highlight); +} + +.primary-button { + box-shadow: + 0 8px 16px rgba(64, 158, 255, 0.22), + inset 0 1px 0 rgba(255, 255, 255, 0.28); +} + +.primary-button:hover { + transform: translateY(-2px); + box-shadow: + 0 12px 22px rgba(64, 158, 255, 0.28), + inset 0 1px 0 rgba(255, 255, 255, 0.32); +} + +.ghost-button { + box-shadow: 0 4px 10px rgba(31, 45, 61, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.88); +} + +.ghost-button:hover { + transform: translateY(-2px); + box-shadow: 0 8px 18px rgba(64, 158, 255, 0.14), inset 0 1px 0 rgba(255, 255, 255, 0.9); +} + +.admin-shell .topbar, +.admin-shell .tags-view-container { + box-shadow: + 0 4px 14px rgba(31, 45, 61, 0.08), + inset 0 -1px 0 rgba(232, 237, 246, 0.9); +} + +.admin-shell .nav-stage { + margin: 3px 8px; + border-radius: 10px; +} + +.admin-shell .nav-stage:hover, +.admin-shell .router-link-active.nav-stage, +.admin-shell .nav-stage-active { + transform: translateX(3px); + box-shadow: + 0 8px 18px rgba(0, 21, 41, 0.22), + inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.admin-shell .tags-view-item { + border-radius: 8px; + box-shadow: 0 2px 6px rgba(31, 45, 61, 0.06); +} + +.admin-shell .tags-view-item:hover, +.admin-shell .tags-view-item.active { + transform: translateY(-1px); + box-shadow: 0 8px 16px rgba(64, 158, 255, 0.18); +} + +/* vfadmin-like sidebar menu */ +.shell.admin-shell { + grid-template-columns: 220px minmax(0, 1fr); +} + +.shell.admin-shell.sidebar-collapsed { + grid-template-columns: 64px minmax(0, 1fr); +} + +.admin-shell .sidebar { + background: #304156; + color: #bfcbd9; + box-shadow: 2px 0 6px rgba(0, 21, 41, 0.18); +} + +.admin-shell .brand { + height: 56px; + min-height: 56px; + padding: 0 20px; + background: #304156; +} + +.admin-shell .brand-logo { + width: 34px; + height: 34px; +} + +.admin-shell .brand h1 { + color: #eef5fb; + font-size: 17px; + font-weight: 600; +} + +.admin-shell .nav-stage-list { + padding: 10px 0; + overflow-y: auto; +} + +.admin-shell .nav-stage-group { + margin: 0; +} + +.admin-shell .nav-stage { + display: grid; + grid-template-columns: 26px minmax(0, 1fr); + gap: 16px; + min-height: 58px; + margin: 0; + padding: 0 24px 0 24px; + border: 0; + border-radius: 0; + background: transparent; + color: #bfcbd9; + box-shadow: none; + transition: color 0.16s ease, background 0.16s ease; +} + +.admin-shell .nav-stage-has-children { + grid-template-columns: 26px minmax(0, 1fr) 18px; +} + +.admin-shell .nav-stage:hover, +.admin-shell .router-link-active.nav-stage, +.admin-shell .nav-stage-active { + transform: none; + border-color: transparent; + background: #263445; + color: #d7e4f0; + box-shadow: none; +} + +.admin-shell .router-link-active.nav-stage::before, +.admin-shell .nav-stage-active::before { + content: none; +} + +.admin-shell .nav-stage-step { + width: 26px; + height: 26px; + border: 0; + border-radius: 0; + background: transparent; + color: inherit; +} + +.admin-shell .nav-stage-step svg { + width: 24px; + height: 24px; + stroke: currentColor; + stroke-width: 2.2; +} + +.admin-shell .nav-stage-body strong { + color: inherit; + font-size: 18px; + font-weight: 500; + letter-spacing: 0; +} + +.admin-shell .nav-stage-chevron { + width: 18px; + height: 18px; +} + +.admin-shell .nav-stage-chevron svg { + width: 18px; + height: 18px; +} + +.admin-shell .nav-stage-group-expanded .nav-stage-chevron { + transform: rotate(180deg); +} + +.admin-shell .nav-submenu { + display: block; + margin: 0; + padding: 8px 0 12px 0; + border-radius: 0; + background: #1f2d3d; +} + +.admin-shell .nav-subitem { + display: grid; + grid-template-columns: 26px minmax(0, 1fr); + gap: 12px; + align-items: center; + min-height: 58px; + padding: 0 16px 0 42px; + border: 0; + border-radius: 0; + background: transparent; + color: #bfcbd9; + font-size: 18px; + font-weight: 500; + box-shadow: none; + transition: color 0.16s ease, background 0.16s ease; +} + +.admin-shell .nav-stage-body strong, +.admin-shell .nav-subitem span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-shell .nav-subitem:hover, +.admin-shell .nav-subitem-active, +.admin-shell .router-link-active.nav-subitem { + transform: none; + border-color: transparent; + background: rgba(64, 158, 255, 0.1); + color: #d7e4f0; + box-shadow: none; +} + +.admin-shell .nav-subitem-active, +.admin-shell .router-link-active.nav-subitem { + position: relative; + background: rgba(64, 158, 255, 0.18); + color: #66b1ff; +} + +.admin-shell .nav-subitem-active::before, +.admin-shell .router-link-active.nav-subitem::before { + content: ""; + position: absolute; + inset: 12px auto 12px 24px; + width: 4px; + border-radius: 999px; + background: #409eff; + box-shadow: 0 0 12px rgba(64, 158, 255, 0.72); +} + +.admin-shell .nav-subitem-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + color: inherit; +} + +.admin-shell .nav-subitem-icon svg { + width: 24px; + height: 24px; + fill: none; + stroke: currentColor; + stroke-width: 2.2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.admin-shell .nav-subitem-dot { + display: none; +} + +.admin-shell.sidebar-collapsed .nav-stage { + grid-template-columns: 1fr; + justify-items: center; + padding: 0; +} + +.admin-shell.sidebar-collapsed .nav-stage-step { + width: 26px; + height: 26px; +} + +.form-field input, +.form-field select, +.form-field textarea, +.table-search-field input, +.table-sort-field select, +.pagination-size-field select, +.assistant-chat-input textarea { + box-shadow: + inset 0 1px 2px rgba(31, 45, 61, 0.05), + 0 1px 0 rgba(255, 255, 255, 0.8); +} + +.form-field input:focus, +.form-field select:focus, +.form-field textarea:focus, +.table-search-field input:focus, +.table-sort-field select:focus, +.pagination-size-field select:focus, +.assistant-chat-input textarea:focus { + transform: translateY(-1px); + box-shadow: + 0 0 0 3px rgba(64, 158, 255, 0.12), + 0 8px 16px rgba(64, 158, 255, 0.08), + inset 0 1px 2px rgba(31, 45, 61, 0.04); +} + +.workflow-drawer, +.assistant-chat-drawer { + box-shadow: + -18px 0 42px rgba(31, 45, 61, 0.18), + 0 12px 34px rgba(31, 45, 61, 0.12), + var(--card-highlight); +} + +.notification-toast-card { + border-radius: 12px; + box-shadow: + 0 16px 34px rgba(31, 45, 61, 0.16), + 0 4px 10px rgba(31, 45, 61, 0.1); +} + +.status-badge, +.status-badge-auto, +.panel-tag, +.table-chip, +.table-badge, +.required-chip, +.optional-chip, +.topbar-tag { + box-shadow: + 0 3px 8px rgba(31, 45, 61, 0.07), + inset 0 1px 0 rgba(255, 255, 255, 0.86); +} + +.status-badge:hover, +.status-badge-auto:hover, +.panel-tag:hover, +.table-chip:hover, +.table-badge:hover { + transform: translateY(-1px); + box-shadow: + 0 7px 14px rgba(31, 45, 61, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.9); +} + +.login-panel { + box-shadow: + 0 24px 70px rgba(0, 0, 0, 0.18), + 0 8px 18px rgba(0, 0, 0, 0.12), + var(--card-highlight); +} + +.assistant-bot-face { + box-shadow: + 0 6px 12px rgba(64, 158, 255, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.88); +} + +.inventory-unified-panel { + display: grid; + gap: 14px; +} + +.inventory-warehouse-strip { + display: flex; + align-items: center; + gap: 8px; + width: fit-content; + max-width: 100%; + padding: 6px; + border: 1px solid rgba(222, 230, 241, 0.98); + border-radius: 14px; + background: linear-gradient(180deg, #f8fafd 0%, #eef3f8 100%); + box-shadow: + inset 0 1px 2px rgba(31, 45, 61, 0.06), + 0 4px 12px rgba(31, 45, 61, 0.05); +} + +.inventory-tabbar { + display: inline-flex; + align-items: center; + gap: 8px; + width: fit-content; + max-width: 100%; + padding: 6px; + border: 1px solid rgba(222, 230, 241, 0.98); + border-radius: 14px; + background: linear-gradient(180deg, #f8fafd 0%, #eef3f8 100%); + box-shadow: + inset 0 1px 2px rgba(31, 45, 61, 0.06), + 0 4px 12px rgba(31, 45, 61, 0.05); +} + +.inventory-tab { + display: inline-flex; + align-items: center; + gap: 10px; + min-height: 40px; + padding: 0 16px; + border: 1px solid transparent; + border-radius: 11px; + background: transparent; + color: #606266; + font: inherit; + cursor: pointer; + transition: + transform 0.18s ease, + box-shadow 0.18s ease, + border-color 0.18s ease, + background 0.18s ease, + color 0.18s ease; +} + +.inventory-tab-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border: 1px solid rgba(64, 158, 255, 0.18); + border-radius: 10px; + background: linear-gradient(145deg, rgba(255, 255, 255, 0.95), rgba(236, 245, 255, 0.82)); + color: #409eff; + box-shadow: + 0 5px 12px rgba(64, 158, 255, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.96); + transition: + transform 0.18s ease, + border-color 0.18s ease, + background 0.18s ease, + color 0.18s ease; +} + +.inventory-tab-icon svg { + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.inventory-tab-icon-semi { + color: #e6a23c; + border-color: rgba(230, 162, 60, 0.22); + background: linear-gradient(145deg, rgba(255, 255, 255, 0.95), rgba(253, 246, 236, 0.86)); +} + +.inventory-tab-icon-finished { + color: #67c23a; + border-color: rgba(103, 194, 58, 0.24); + background: linear-gradient(145deg, rgba(255, 255, 255, 0.95), rgba(240, 249, 235, 0.88)); +} + +.inventory-tab-icon-auxiliary { + color: #f56c6c; + border-color: rgba(245, 108, 108, 0.22); + background: linear-gradient(145deg, rgba(255, 255, 255, 0.95), rgba(254, 240, 240, 0.88)); +} + +.inventory-tab strong { + color: inherit; + font-weight: 700; +} + +.inventory-tab span { + color: #909399; + font-size: 12px; +} + +.inventory-tab:hover { + transform: translateY(-1px); + color: #409eff; + background: rgba(255, 255, 255, 0.82); +} + +.inventory-tab-active { + transform: translateY(-2px); + border-color: rgba(64, 158, 255, 0.34); + background: #ffffff; + color: #409eff; + box-shadow: + 0 10px 20px rgba(64, 158, 255, 0.16), + inset 0 1px 0 rgba(255, 255, 255, 0.94); +} + +.inventory-tab-active .inventory-tab-icon { + transform: scale(1.06); + border-color: currentColor; +} + +.inventory-tab-active span { + color: #409eff; +} + +.inventory-tab-active .inventory-tab-icon { + color: inherit; +} + +.inventory-tab-panel { + min-width: 0; +} + +.inventory-section-divider { + height: 1px; + background: linear-gradient(90deg, rgba(64, 158, 255, 0), rgba(64, 158, 255, 0.22), rgba(64, 158, 255, 0)); +} + +.inventory-io-bar { + position: relative; + display: grid; + gap: 10px; + padding: 12px 330px 12px 14px; + border: 1px solid rgba(222, 230, 241, 0.96); + border-radius: 16px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(246, 249, 253, 0.88)), + rgba(255, 255, 255, 0.72); + box-shadow: + 0 10px 24px rgba(31, 45, 61, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.94); +} + +.inventory-io-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.inventory-io-label { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 76px; + height: 32px; + border-radius: 10px; + color: #606266; + background: rgba(245, 247, 250, 0.92); + border: 1px solid rgba(220, 223, 230, 0.9); + font-size: 13px; + font-weight: 700; +} + +.inventory-io-action { + display: inline-flex; + align-items: center; + gap: 7px; + min-height: 34px; + padding: 0 12px 0 8px; + border-radius: 11px; + border: 1px solid transparent; + background: #ffffff; + color: #303133; + font: inherit; + cursor: pointer; + box-shadow: + 0 6px 14px rgba(31, 45, 61, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.98); + transition: + transform 0.18s ease, + box-shadow 0.18s ease, + border-color 0.18s ease, + color 0.18s ease; +} + +.inventory-ledger-button { + position: absolute; + right: 190px; + top: 50%; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 42px; + padding: 0 18px; + border: 1px solid rgba(64, 158, 255, 0.36); + border-radius: 14px; + color: #337ecc; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(236, 245, 255, 0.94)); + box-shadow: + 0 10px 24px rgba(64, 158, 255, 0.14), + inset 0 1px 0 rgba(255, 255, 255, 0.96); + cursor: pointer; + transition: + transform 0.18s ease, + box-shadow 0.18s ease, + border-color 0.18s ease; +} + +.production-work-order-inbound-button { + right: 18px; +} + +.inventory-ledger-button:hover { + transform: translateY(-52%); + border-color: rgba(64, 158, 255, 0.68); + box-shadow: + 0 14px 30px rgba(64, 158, 255, 0.22), + inset 0 1px 0 rgba(255, 255, 255, 0.98); +} + +.inventory-ledger-icon { + width: 18px; + height: 18px; +} + +.inventory-ledger-icon svg { + width: 100%; + height: 100%; + fill: none; + stroke: currentColor; + stroke-width: 1.9; + stroke-linecap: round; + stroke-linejoin: round; +} + +.inventory-io-action:hover { + transform: translateY(-2px); + box-shadow: + 0 12px 24px rgba(31, 45, 61, 0.13), + inset 0 1px 0 rgba(255, 255, 255, 0.98); +} + +.inventory-io-action strong { + font-size: 13px; + font-weight: 700; +} + +.inventory-io-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 9px; + font-size: 18px; + line-height: 1; + font-weight: 800; +} + +.inventory-io-action-in { + border-color: rgba(103, 194, 58, 0.26); +} + +.inventory-io-action-in .inventory-io-mark { + color: #67c23a; + background: rgba(240, 249, 235, 0.96); + border: 1px solid rgba(103, 194, 58, 0.24); +} + +.inventory-io-action-in:hover { + color: #529b2e; + border-color: rgba(103, 194, 58, 0.46); +} + +.inventory-io-action-out { + border-color: rgba(245, 108, 108, 0.24); +} + +.inventory-io-action-out .inventory-io-mark { + color: #f56c6c; + background: rgba(254, 240, 240, 0.96); + border: 1px solid rgba(245, 108, 108, 0.24); +} + +.inventory-io-action-out:hover { + color: #c45656; + border-color: rgba(245, 108, 108, 0.46); +} + +.warehouse-ledger-drawer { + gap: 16px; +} + +.warehouse-ledger-summary { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; +} + +.warehouse-ledger-summary-card { + padding: 14px; + border: 1px solid rgba(222, 230, 241, 0.96); + border-radius: 16px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(246, 249, 253, 0.92)), + rgba(255, 255, 255, 0.78); + box-shadow: + 0 12px 28px rgba(31, 45, 61, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.95); +} + +.warehouse-ledger-summary-card span { + display: block; + color: #606266; + font-size: 12px; +} + +.warehouse-ledger-summary-card strong { + display: block; + margin-top: 6px; + color: #303133; + font-size: 18px; +} + +.warehouse-ledger-filters { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: end; + padding: 12px; + border: 1px solid rgba(37, 99, 235, 0.12); + border-radius: 18px; + background: + linear-gradient(135deg, rgba(248, 251, 255, 0.98), rgba(241, 247, 255, 0.92)), + rgba(255, 255, 255, 0.88); + box-shadow: + 0 12px 30px rgba(15, 23, 42, 0.06), + inset 0 1px 0 rgba(255, 255, 255, 0.96); +} + +.warehouse-ledger-filter-fields { + display: grid; + grid-template-columns: minmax(240px, 1.45fr) minmax(118px, 0.55fr) minmax(180px, 1fr) repeat(2, minmax(132px, 0.7fr)); + gap: 12px; + min-width: 0; +} + +.warehouse-ledger-filter-actions { + display: flex; + flex-wrap: nowrap; + gap: 9px; + align-items: end; + justify-content: flex-end; +} + +.warehouse-ledger-filter-actions button { + min-height: 38px; + padding: 0 16px; + border-radius: 14px; + white-space: nowrap; +} + +.warehouse-ledger-export-button { + position: relative; + overflow: hidden; + color: #0f5b3d !important; + border: 1px solid rgba(22, 163, 74, 0.28) !important; + background: + linear-gradient(135deg, rgba(240, 253, 244, 0.98), rgba(220, 252, 231, 0.88)) !important; + box-shadow: + 0 10px 22px rgba(22, 163, 74, 0.13), + inset 0 1px 0 rgba(255, 255, 255, 0.92) !important; + font-weight: 850; +} + +.warehouse-ledger-export-button::before { + content: "⇩"; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + margin-right: 7px; + border-radius: 7px; + color: #ffffff; + background: linear-gradient(135deg, #16a34a, #0f766e); + box-shadow: 0 4px 10px rgba(15, 118, 110, 0.2); + font-size: 12px; + line-height: 1; +} + +.warehouse-ledger-export-button:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: + 0 14px 28px rgba(22, 163, 74, 0.18), + inset 0 1px 0 rgba(255, 255, 255, 0.96) !important; +} + +.warehouse-ledger-table-wrap { + max-height: min(58vh, 620px); + margin-top: 12px; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 18px; + background: rgba(255, 255, 255, 0.92); + box-shadow: + 0 16px 34px rgba(15, 23, 42, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.96); +} + +.warehouse-ledger-table { + min-width: max(100%, 1460px) !important; + border-collapse: separate; + border-spacing: 0; +} + +.warehouse-ledger-table th, +.warehouse-ledger-table td { + height: 58px; + padding: 10px 12px !important; + border-bottom: 1px solid rgba(203, 213, 225, 0.7); + color: #26364d; +} + +.warehouse-ledger-table th { + position: sticky; + top: 0; + z-index: 2; + color: #34445d !important; + background: + linear-gradient(180deg, rgba(248, 251, 255, 0.98), rgba(237, 244, 255, 0.96)); + font-size: 12px; + font-weight: 900 !important; +} + +.warehouse-ledger-table td { + background: rgba(255, 255, 255, 0.96); +} + +.warehouse-ledger-table tbody tr:hover td { + background: rgba(239, 246, 255, 0.92); +} + +.warehouse-ledger-table .ledger-col-time { + width: 132px; +} + +.warehouse-ledger-table .ledger-col-direction { + width: 70px; +} + +.warehouse-ledger-table .ledger-col-type { + width: 98px; +} + +.warehouse-ledger-table .ledger-col-item { + width: 168px; +} + +.warehouse-ledger-table .ledger-col-lot { + width: 160px; +} + +.warehouse-ledger-table .ledger-col-qty, +.warehouse-ledger-table .ledger-col-weight { + width: 104px; +} + +.warehouse-ledger-table .ledger-col-price, +.warehouse-ledger-table .ledger-col-amount { + width: 82px; +} + +.warehouse-ledger-table .ledger-col-source { + width: 126px; +} + +.warehouse-ledger-table .ledger-col-operator { + width: 96px; +} + +.warehouse-ledger-table .ledger-col-logistics { + width: 112px; +} + +.warehouse-ledger-table .ledger-col-remark { + width: 168px; +} + +.warehouse-ledger-table .ledger-col-archive { + width: 154px; +} + +.warehouse-ledger-table th:last-child, +.warehouse-ledger-table td:last-child { + position: sticky; + right: 0; + z-index: 3; + box-shadow: -12px 0 22px rgba(15, 23, 42, 0.06); +} + +.warehouse-ledger-table th:last-child { + z-index: 4; +} + +.warehouse-ledger-remark-cell { + color: #475569; +} + +.warehouse-ledger-archive-cell { + overflow: visible !important; +} + +.warehouse-ledger-table .document-archive-actions { + display: grid; + grid-template-columns: 1fr; + width: 132px; + min-width: 0; + gap: 5px; + padding: 6px; + border: 1px solid rgba(37, 99, 235, 0.16); + border-radius: 13px; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(241, 247, 255, 0.94)); + box-shadow: + 0 8px 18px rgba(15, 23, 42, 0.06), + inset 0 1px 0 rgba(255, 255, 255, 0.96); +} + +.warehouse-ledger-table .document-archive-status { + justify-content: center; + min-height: 22px; + padding: 0 7px; + font-size: 11px; + letter-spacing: 0.01em; +} + +.warehouse-ledger-table .document-archive-status-dot { + width: 6px; + height: 6px; +} + +.warehouse-ledger-table .document-archive-buttons { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 4px; +} + +.warehouse-ledger-table .document-archive-button { + min-height: 25px; + padding: 0; + border-width: 1px; + border-radius: 8px; + box-shadow: none; + font-size: 0; + letter-spacing: 0; +} + +.warehouse-ledger-table .document-archive-button:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 7px 14px rgba(15, 23, 42, 0.1); +} + +.warehouse-ledger-table .document-archive-icon { + min-width: 22px; + height: 20px; + border: 0; + border-radius: 7px; + background: rgba(255, 255, 255, 0.72); + font-size: 10px; +} + +@media (max-width: 1180px) { + .warehouse-ledger-filters { + grid-template-columns: 1fr; + } + + .warehouse-ledger-filter-actions { + justify-content: flex-start; + } +} + +@media (max-width: 820px) { + .warehouse-ledger-filter-fields { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 560px) { + .warehouse-ledger-filter-fields, + .warehouse-ledger-filter-actions { + grid-template-columns: 1fr; + } + + .warehouse-ledger-filter-actions { + display: grid; + } + + .warehouse-ledger-filter-actions button { + width: 100%; + } +} + +.status-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 52px; + padding: 4px 9px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + border: 1px solid transparent; +} + +.status-pill.status-success { + color: #529b2e; + border-color: rgba(103, 194, 58, 0.32); + background: rgba(240, 249, 235, 0.96); +} + +.status-pill.status-warning { + color: #b88230; + border-color: rgba(230, 162, 60, 0.32); + background: rgba(253, 246, 236, 0.96); +} + +.status-pill.status-info { + color: #337ecc; + border-color: rgba(64, 158, 255, 0.32); + background: rgba(236, 245, 255, 0.96); +} + +.status-pill.status-muted { + color: #606266; + border-color: rgba(220, 223, 230, 0.86); + background: rgba(245, 247, 250, 0.96); +} + +.material-item-cell { + display: flex; + flex-wrap: wrap; + gap: 6px; + max-width: 520px; +} + +.material-item-chip { + display: inline-grid; + gap: 2px; + max-width: 240px; + padding: 6px 9px; + border: 1px solid #d9ecff; + border-radius: 10px; + background: linear-gradient(180deg, #ffffff 0%, #ecf5ff 100%); + color: #303133; + box-shadow: + 0 4px 10px rgba(64, 158, 255, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.9); + cursor: default; +} + +.material-item-chip strong, +.material-item-chip small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.material-item-chip strong { + color: #409eff; + font-size: 12px; +} + +.material-item-chip small { + color: #606266; + font-size: 11px; +} + +.material-item-chip .material-performance-line { + color: #2f7d4f; +} + +.empty-inline { + color: #909399; + font-size: 12px; +} + +@media (max-width: 720px) { + .panel:hover, + .workspace-panel:hover, + .finance-board-panel:hover, + .workflow-section-card:hover, + .workflow-summary-card:hover, + .order-item-card:hover, + .detail-item:hover, + .detail-block:hover { + transform: none; + } +} + +/* Unified pagination bar */ +.pagination-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin-top: 16px; + padding: 18px 24px; + border: 1px solid rgba(226, 232, 240, 0.95); + border-radius: 14px; + background: #ffffff; + color: #606266; + box-shadow: + 0 10px 24px rgba(31, 45, 61, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.92); +} + +.pagination-summary { + color: #606266; + font-size: 14px; + white-space: nowrap; +} + +.pagination-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + color: #303133; + font-size: 14px; +} + +.pagination-total-pages { + margin-right: 2px; + color: #606266; + white-space: nowrap; +} + +.pagination-page, +.pagination-arrow { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 34px; + height: 34px; + padding: 0 10px; + border: 0; + border-radius: 10px; + background: transparent; + color: #303133; + font: inherit; + cursor: pointer; + transition: + transform 0.16s ease, + background 0.16s ease, + color 0.16s ease, + box-shadow 0.16s ease; +} + +.pagination-page:hover, +.pagination-arrow:hover:not(:disabled) { + transform: translateY(-1px); + background: #f2f7ff; + color: #409eff; + box-shadow: 0 6px 12px rgba(64, 158, 255, 0.12); +} + +.pagination-page-active { + background: #ecf5ff; + color: #409eff; + font-weight: 700; + box-shadow: + 0 8px 16px rgba(64, 158, 255, 0.16), + inset 0 1px 0 rgba(255, 255, 255, 0.9); +} + +.pagination-arrow { + min-width: 26px; + padding: 0; + color: #909399; + font-size: 34px; + line-height: 1; +} + +.pagination-arrow:disabled { + cursor: not-allowed; + color: #c0c4cc; + opacity: 0.62; +} + +.pagination-ellipsis { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + height: 34px; + color: #303133; + letter-spacing: 0.08em; +} + +.pagination-size-field { + position: relative; + display: inline-flex; + align-items: center; + gap: 0; + height: 34px; + margin-left: 8px; + padding: 0 32px 0 14px; + border-radius: 10px; + background: #f4f6f8; + color: #303133; + font-size: 14px; + white-space: nowrap; + box-shadow: inset 0 1px 1px rgba(31, 45, 61, 0.04); +} + +.pagination-size-field::after { + content: ""; + position: absolute; + right: 12px; + top: 50%; + width: 8px; + height: 8px; + border-right: 2px solid #606266; + border-bottom: 2px solid #606266; + transform: translateY(-65%) rotate(45deg); + pointer-events: none; +} + +.pagination-size-field span { + color: #303133; +} + +.pagination-size-field select { + width: auto; + min-width: 34px; + height: 34px; + padding: 0; + border: 0; + background: transparent; + color: #303133; + font: inherit; + outline: none; + appearance: none; + box-shadow: none; +} + +.pagination-size-field select:focus { + box-shadow: none; +} + +.admin-shell .form-field select, +.admin-shell .table-sort-field select, +.admin-shell .compact-select-field select, +.admin-shell select { + min-height: 36px; + border: 1px solid #dcdfe6 !important; + border-radius: 4px !important; + background-color: #ffffff !important; + color: #606266 !important; + box-shadow: + inset 0 1px 2px rgba(31, 45, 61, 0.05), + 0 1px 0 rgba(255, 255, 255, 0.8) !important; + outline: none; +} + +.admin-shell .form-field select:focus, +.admin-shell .table-sort-field select:focus, +.admin-shell .compact-select-field select:focus, +.admin-shell select:focus { + border-color: #409eff !important; + box-shadow: + 0 0 0 3px rgba(64, 158, 255, 0.12), + 0 8px 16px rgba(64, 158, 255, 0.08), + inset 0 1px 2px rgba(31, 45, 61, 0.04) !important; +} + +.admin-shell select option { + background: #ffffff; + color: #303133; +} + +@media (max-width: 860px) { + .pagination-bar { + align-items: stretch; + flex-direction: column; + padding: 14px; + } + + .pagination-actions { + flex-wrap: wrap; + justify-content: flex-start; + } +} + +@media (max-width: 860px) { + .import-layout, + .compact-summary-grid { + grid-template-columns: 1fr; + } + + .user-avatar-meta { + display: none; + } +} + +.workflow-drawer .table-wrap { + max-width: 100%; + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior-x: contain; + padding-bottom: 4px; +} + +.workflow-drawer .data-table { + width: max-content; + min-width: 100%; + table-layout: auto; +} + +.workflow-drawer .data-table th { + writing-mode: horizontal-tb; + white-space: normal; + word-break: keep-all; + overflow-wrap: normal; + line-height: 1.35; + vertical-align: middle; +} + +.workflow-drawer .data-table td { + min-width: 0; + vertical-align: middle; + word-break: normal; + overflow-wrap: anywhere; +} + +.drawer-cell-ellipsis { + display: block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.drawer-cell-code { + display: inline-flex; + max-width: 100%; + padding: 4px 8px; + border-radius: 999px; + background: #eef6ff; + color: #1f5fa8; + font-weight: 650; + line-height: 1.25; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.drawer-cell-number { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.drawer-data-table-wrap { + width: 100%; + max-width: 100%; + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior-x: contain; + padding-bottom: 2px; +} + +.drawer-data-table { + width: max-content; + min-width: 100%; + table-layout: auto; +} + +.drawer-data-table th, +.drawer-data-table td { + min-width: 0; +} + +.drawer-table-head-label { + display: inline-block; + max-width: 100%; + line-height: 1.35; + white-space: normal; + word-break: keep-all; +} + +.drawer-cell-actions { + text-align: center; +} + +.mini-icon-button { + border: 1px solid #d9e6f5; + border-radius: 999px; + background: #f5f9ff; + color: #2f74c8; + font-size: 12px; + padding: 4px 9px; + cursor: pointer; +} + +.mini-icon-button:hover { + border-color: #9fc9f5; + background: #ecf5ff; +} + +.drawer-row-detail td { + background: #f8fbff; +} + +.drawer-row-detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; +} + +.drawer-row-detail-grid article { + display: grid; + gap: 4px; + padding: 10px 12px; + border: 1px solid #e4edf7; + border-radius: 12px; + background: #ffffff; +} + +.drawer-row-detail-grid span { + color: #7a8699; + font-size: 12px; +} + +.drawer-row-detail-grid strong { + color: #1f2d3d; + font-size: 13px; + overflow-wrap: anywhere; +} + +.inventory-ledger-detail-mask .dual-drawer-shell { + width: min(1760px, calc(100vw - 28px)); + max-width: calc(100vw - 28px); +} + +.inventory-ledger-detail-mask .workflow-drawer { + min-width: 0; +} + +.inventory-ledger-detail-mask .compact-side-drawer { + width: min(820px, calc(50vw - 22px)); +} + +.work-order-detail-mask .dual-drawer-shell { + width: min(1680px, calc(100vw - 28px)); + max-width: calc(100vw - 28px); +} + +.work-order-detail-mask .workflow-drawer { + min-width: 0; +} + +.work-order-detail-mask .compact-side-drawer { + width: min(800px, calc(50vw - 22px)); +} + +/* ERP visual upgrade foundation overrides */ +body { + font-family: var(--ui-font-body); + color: var(--ui-ink); + background: + radial-gradient(circle at 16% 8%, rgba(37, 99, 235, 0.12), transparent 28%), + linear-gradient(135deg, #eef3f7 0%, #f8fafc 48%, #e8eef5 100%); +} + +button, +input, +select, +textarea { + font: inherit; +} + +:focus-visible { + outline: none; + box-shadow: var(--ui-focus); +} + +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +a:focus-visible { + outline: none; + box-shadow: var(--ui-focus); +} + +/* Task 6 final overrides: formal data table system */ +.table-wrap, +.drawer-data-table-wrap { + position: relative; + width: 100%; + max-width: 100%; + margin-top: 16px; + overflow-x: auto; + overflow-y: auto; + overscroll-behavior-x: contain; + border: 1px solid rgba(209, 218, 232, 0.96); + border-radius: 16px; + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.96), rgba(255, 255, 255, 0)) 0 0 / 24px 100% no-repeat, + linear-gradient(270deg, rgba(232, 238, 247, 0.92), rgba(232, 238, 247, 0)) 100% 0 / 34px 100% no-repeat, + linear-gradient(180deg, #ffffff 0%, #fbfdff 100%); + box-shadow: + inset -18px 0 22px -28px rgba(37, 54, 77, 0.72), + 0 16px 38px rgba(31, 45, 61, 0.08), + 0 1px 0 rgba(255, 255, 255, 0.92); +} + +.drawer-data-table-wrap { + margin-top: 0; +} + +.data-table, +.drawer-data-table { + width: max-content; + min-width: 100%; + border-collapse: separate; + border-spacing: 0; + table-layout: auto; +} + +.data-table th, +.data-table td { + vertical-align: middle; + border-bottom: 1px solid rgba(221, 228, 238, 0.95); +} + +.data-table th { + position: sticky; + top: 0; + z-index: 2; + background: + linear-gradient(180deg, rgba(248, 251, 255, 0.98) 0%, rgba(238, 244, 251, 0.98) 100%); + color: #334155; + font-size: 12px; + font-weight: 750; + letter-spacing: 0.02em; + line-height: 1.25; + text-align: left; + white-space: nowrap; + word-break: keep-all; + overflow-wrap: normal; + writing-mode: horizontal-tb; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.96), + 0 1px 0 rgba(209, 218, 232, 0.96); +} + +.data-table td { + color: #42526b; + line-height: 1.45; +} + +.data-table tbody tr { + transition: background 0.16s ease, box-shadow 0.16s ease; +} + +.data-table tbody tr:hover td { + background: #f7fbff; +} + +.data-table tbody tr:hover td:first-child { + box-shadow: inset 3px 0 0 rgba(64, 158, 255, 0.48); +} + +.sortable-th-button { + min-width: 0; + justify-content: space-between; + gap: 8px; + white-space: nowrap; +} + +.sortable-th-label, +.drawer-table-head-label { + display: block; + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + word-break: keep-all; + overflow-wrap: normal; + writing-mode: horizontal-tb; +} + +.sort-arrow { + position: relative; + flex: 0 0 18px; + width: 18px; + min-width: 18px; + height: 18px; + color: #2563eb; +} + +.sort-arrow:not(.sort-arrow-active)::before, +.sort-arrow:not(.sort-arrow-active)::after { + content: ""; + position: absolute; + left: 50%; + width: 6px; + height: 6px; + border: solid currentColor; + opacity: 0.42; + transform: translateX(-50%) rotate(45deg); +} + +.sort-arrow:not(.sort-arrow-active)::before { + top: 4px; + border-width: 1px 0 0 1px; +} + +.sort-arrow:not(.sort-arrow-active)::after { + bottom: 4px; + border-width: 0 1px 1px 0; +} + +.sort-arrow-active { + display: inline-grid; + place-items: center; + color: #0f172a; + font-size: 11px; + font-weight: 800; +} + +.drawer-data-table th, +.drawer-data-table td { + max-width: 220px; + min-width: 0; +} + +.drawer-data-table th { + max-width: 180px; +} + +.drawer-data-table .drawer-cell-long, +.drawer-data-table .drawer-cell-code, +.drawer-data-table .drawer-cell-ellipsis { + max-width: 220px; +} + +.drawer-cell-ellipsis, +.drawer-cell-code { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + word-break: normal; + overflow-wrap: normal; +} + +.drawer-cell-ellipsis { + display: block; +} + +.drawer-cell-code { + display: inline-block; + vertical-align: middle; +} + +/* Task 7: smart drawer and form section system */ +.smart-drawer-mask { + position: fixed; + inset: 0; + z-index: 2700; + display: flex; + justify-content: flex-end; + padding: 18px; + background: + radial-gradient(circle at 78% 20%, rgba(14, 165, 233, 0.18), transparent 34%), + linear-gradient(90deg, rgba(15, 23, 42, 0.26), rgba(15, 23, 42, 0.52)); + backdrop-filter: blur(10px); +} + +.smart-drawer-shell { + position: relative; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + width: min(760px, 100%); + max-height: calc(100vh - 36px); + overflow: hidden; + border: 1px solid rgba(195, 209, 226, 0.86); + border-radius: 24px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(247, 250, 253, 0.98) 100%); + box-shadow: + 0 34px 90px rgba(15, 23, 42, 0.28), + inset 0 1px 0 rgba(255, 255, 255, 0.86); +} + +.smart-drawer-shell::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 5px; + background: linear-gradient(180deg, var(--ui-steel), var(--ui-cyan), rgba(22, 163, 74, 0.74)); +} + +.smart-drawer-xl { + width: min(1040px, 100%); +} + +.smart-drawer-fullscreen { + width: min(1440px, 100%); +} + +.smart-drawer-header, +.smart-drawer-footer { + position: relative; + z-index: 1; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + padding: 22px 24px; +} + +.smart-drawer-header { + border-bottom: 1px solid rgba(214, 224, 236, 0.9); + background: + linear-gradient(135deg, rgba(248, 251, 255, 0.98), rgba(236, 244, 252, 0.92)); +} + +.smart-drawer-footer { + align-items: center; + justify-content: flex-end; + border-top: 1px solid rgba(214, 224, 236, 0.9); + background: rgba(248, 251, 255, 0.96); +} + +.smart-drawer-heading { + display: grid; + gap: 7px; + min-width: 0; +} + +.smart-drawer-actions { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: flex-end; + gap: 10px; +} + +.smart-drawer-eyebrow, +.form-section-eyebrow, +.metric-strip-label { + margin: 0; + color: var(--ui-steel); + font-family: var(--ui-font-display); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.smart-drawer-title { + margin: 0; + color: #0f172a; + font-family: var(--ui-font-display); + font-size: clamp(22px, 2.2vw, 30px); + line-height: 1.08; +} + +.smart-drawer-description { + max-width: 68ch; + margin: 0; + color: var(--ui-ink-soft); + font-size: 13px; + line-height: 1.6; +} + +.smart-drawer-close { + display: inline-grid; + flex: 0 0 auto; + place-items: center; + width: 38px; + height: 38px; + border: 1px solid rgba(194, 207, 224, 0.92); + border-radius: 14px; + color: var(--ui-ink-soft); + background: rgba(255, 255, 255, 0.86); + cursor: pointer; + box-shadow: 0 10px 22px rgba(15, 23, 42, 0.08); + transition: + transform var(--ui-transition-fast), + border-color var(--ui-transition-fast), + color var(--ui-transition-fast), + box-shadow var(--ui-transition-fast); +} + +.smart-drawer-close:hover { + color: #0f172a; + border-color: rgba(37, 99, 235, 0.32); + transform: translateY(-1px); + box-shadow: 0 14px 28px rgba(37, 99, 235, 0.12); +} + +.smart-drawer-close .app-icon { + width: 18px; + height: 18px; +} + +.smart-drawer-body { + position: relative; + z-index: 1; + display: grid; + align-content: start; + gap: 18px; + min-height: 0; + padding: 22px 24px 26px; + overflow: auto; + background: + radial-gradient(circle at 100% 0%, rgba(37, 99, 235, 0.08), transparent 26%), + linear-gradient(180deg, rgba(255, 255, 255, 0.7), rgba(248, 251, 255, 0.96)); +} + +.form-section { + display: grid; + gap: 16px; + padding: 18px; + border: 1px solid rgba(211, 222, 236, 0.92); + border-radius: 20px; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(248, 251, 255, 0.92)); + box-shadow: 0 14px 34px rgba(15, 23, 42, 0.06); +} + +.form-section-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.form-section-heading { + display: grid; + gap: 6px; + min-width: 0; +} + +.form-section-title { + margin: 0; + color: #142033; + font-family: var(--ui-font-display); + font-size: 18px; + line-height: 1.2; +} + +.form-section-description { + margin: 0; + color: var(--ui-ink-muted); + font-size: 13px; + line-height: 1.55; +} + +.form-section-actions { + display: flex; + flex: 0 0 auto; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.form-section-grid { + display: grid; + gap: 14px; + align-items: start; +} + +.form-section-columns-1 .form-section-grid { + grid-template-columns: minmax(0, 1fr); +} + +.form-section-columns-2 .form-section-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.form-section-columns-3 .form-section-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.metric-strip { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 12px; +} + +.metric-strip-item { + position: relative; + display: grid; + gap: 7px; + min-width: 0; + padding: 15px 16px; + overflow: hidden; + border: 1px solid rgba(205, 217, 232, 0.9); + border-radius: 18px; + background: + linear-gradient(145deg, rgba(255, 255, 255, 0.96), rgba(240, 247, 255, 0.92)); + box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06); +} + +.metric-strip-item::after { + content: ""; + position: absolute; + inset: auto 14px 0; + height: 3px; + border-radius: 999px 999px 0 0; + background: linear-gradient(90deg, var(--ui-steel), rgba(14, 165, 233, 0.34)); +} + +.metric-strip-value { + min-width: 0; + color: #0f172a; + font-family: var(--ui-font-display); + font-size: clamp(20px, 2vw, 28px); + line-height: 1; + overflow-wrap: anywhere; +} + +.metric-strip-hint { + color: var(--ui-ink-muted); + font-size: 12px; + line-height: 1.45; +} + +@media (max-width: 720px) { + .smart-drawer-mask { + padding: 0; + } + + .smart-drawer-shell, + .smart-drawer-xl, + .smart-drawer-fullscreen { + width: 100vw; + height: 100vh; + max-height: 100vh; + border-radius: 0; + } + + .smart-drawer-header, + .smart-drawer-footer, + .smart-drawer-body { + padding-right: 18px; + padding-left: 18px; + } + + .smart-drawer-header, + .form-section-header { + flex-direction: column; + } + + .smart-drawer-actions { + width: 100%; + justify-content: flex-start; + padding-right: 48px; + } + + .smart-drawer-close { + position: absolute; + top: 16px; + right: 16px; + } + + .smart-drawer-heading { + padding-right: 48px; + } + + .form-section-columns-2 .form-section-grid, + .form-section-columns-3 .form-section-grid { + grid-template-columns: minmax(0, 1fr); + } + + .form-section-actions { + width: 100%; + justify-content: flex-start; + } +} + +/* Task 8: elevated searchable select overlay for long business options. */ +.global-select-layer.global-select-layer { + z-index: 3100; + pointer-events: none; +} + +.global-select-layer .global-select-input-box { + pointer-events: auto; +} + +.global-select-layer .global-select-search-input { + height: 100%; + border: 1px solid rgba(113, 133, 158, 0.28); + border-radius: 14px; + color: #102033; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 250, 253, 0.96)); + font-family: var(--ui-font-body); + font-size: 14px; + letter-spacing: 0.01em; + padding: 0 38px 0 14px; + box-shadow: + 0 0 0 3px rgba(37, 99, 235, 0.1), + 0 16px 34px rgba(15, 23, 42, 0.14); + transition: + border-color var(--ui-transition-fast), + box-shadow var(--ui-transition-fast), + background var(--ui-transition-fast); +} + +.global-select-layer .global-select-search-input:focus { + border-color: rgba(37, 99, 235, 0.48); + background: #ffffff; + box-shadow: + 0 0 0 3px rgba(37, 99, 235, 0.16), + 0 20px 42px rgba(15, 23, 42, 0.18); +} + +.global-select-layer .global-select-search-input::placeholder { + color: #93a4b7; +} + +.global-select-layer .global-select-arrow { + right: 15px; + width: 9px; + height: 9px; + border-color: #71859e; +} + +.global-select-layer .global-select-panel { + overflow: hidden; + border: 1px solid rgba(190, 205, 222, 0.92); + border-radius: 18px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(245, 248, 252, 0.96)); + box-shadow: + 0 26px 70px rgba(15, 23, 42, 0.2), + 0 0 0 1px rgba(255, 255, 255, 0.75) inset; + backdrop-filter: blur(18px); + pointer-events: auto; +} + +.global-select-layer .global-select-option-list { + display: grid; + gap: 6px; + padding: 7px; + scrollbar-gutter: stable; +} + +.global-select-layer .global-select-option { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 4px 12px; + min-height: 54px; + padding: 10px 12px; + border: 1px solid transparent; + border-radius: 14px; + color: #18283a; + background: transparent; + text-align: left; + transition: + background var(--ui-transition-fast), + border-color var(--ui-transition-fast), + box-shadow var(--ui-transition-fast); +} + +.global-select-layer .global-select-option:hover, +.global-select-layer .global-select-option-active { + color: #102033; + border-color: rgba(37, 99, 235, 0.2); + background: + linear-gradient(135deg, rgba(239, 246, 255, 0.98), rgba(232, 245, 255, 0.86)); + box-shadow: 0 10px 24px rgba(37, 99, 235, 0.1); +} + +.global-select-layer .global-select-option-selected { + border-color: rgba(37, 99, 235, 0.32); + color: #0f3f8f; + background: + linear-gradient(135deg, rgba(219, 234, 254, 0.95), rgba(236, 253, 245, 0.72)); + box-shadow: + 0 12px 28px rgba(37, 99, 235, 0.12), + 3px 0 0 rgba(37, 99, 235, 0.72) inset; +} + +.global-select-layer .global-select-option-main { + grid-column: 1; + grid-row: 1; + min-width: 0; + color: inherit; + font-family: var(--ui-font-display); + font-size: 14px; + font-weight: 700; + line-height: 1.24; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.global-select-layer .global-select-option-sub { + grid-column: 1 / -1; + grid-row: 2; + min-width: 0; + color: #50657a; + font-size: 12px; + line-height: 1.35; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.global-select-layer .global-select-option-meta { + grid-column: 2; + grid-row: 1; + justify-self: end; + min-width: 0; + width: fit-content; + max-width: 100%; + padding: 3px 7px; + border-radius: 999px; + color: #64748b; + background: rgba(226, 232, 240, 0.72); + font-size: 11px; + line-height: 1.25; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.global-select-layer .global-select-option-disabled { + cursor: not-allowed; + opacity: 0.5; + filter: grayscale(0.2); + transform: none; +} + +.global-select-layer .global-select-option-disabled:hover { + border-color: transparent; + background: transparent; + box-shadow: none; +} + +.global-select-layer .global-select-empty { + margin: 4px; + padding: 18px 12px; + border-radius: 14px; + color: #71859e; + background: rgba(241, 245, 249, 0.72); + text-align: center; +} + +.warehouse-switchboard { + display: grid; + grid-template-columns: repeat(6, minmax(128px, 1fr)); + gap: 12px; + padding: 14px; + border: 1px solid rgba(188, 204, 224, 0.72); + border-radius: 24px; + background: + radial-gradient(circle at 8% 0%, rgba(34, 197, 94, 0.16), transparent 28%), + radial-gradient(circle at 92% 10%, rgba(14, 165, 233, 0.17), transparent 32%), + linear-gradient(135deg, rgba(248, 250, 252, 0.98), rgba(239, 246, 255, 0.9)); + box-shadow: + 0 22px 48px rgba(15, 23, 42, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.9); +} + +.warehouse-tab-card { + position: relative; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 10px; + min-height: 78px; + padding: 13px; + overflow: hidden; + border: 1px solid rgba(203, 213, 225, 0.76); + border-radius: 18px; + color: #334155; + background: + linear-gradient(160deg, rgba(255, 255, 255, 0.94), rgba(248, 250, 252, 0.78)), + rgba(255, 255, 255, 0.78); + box-shadow: + 0 10px 22px rgba(15, 23, 42, 0.06), + inset 0 1px 0 rgba(255, 255, 255, 0.94); + cursor: pointer; + text-align: left; + transition: + transform 0.18s ease, + border-color 0.18s ease, + box-shadow 0.18s ease, + background 0.18s ease, + color 0.18s ease; +} + +.warehouse-tab-card::after { + position: absolute; + right: -18px; + bottom: -28px; + width: 76px; + height: 76px; + border-radius: 999px; + background: currentColor; + content: ""; + opacity: 0.07; +} + +.warehouse-tab-card:hover { + transform: translateY(-2px); + border-color: rgba(14, 116, 144, 0.28); + box-shadow: + 0 16px 30px rgba(15, 23, 42, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.96); +} + +.warehouse-tab-card-active { + color: #0f766e; + transform: translateY(-3px); + border: 2px solid color-mix(in srgb, currentColor 72%, white 28%); + background: + radial-gradient(circle at 94% 18%, color-mix(in srgb, currentColor 16%, transparent), transparent 34%), + linear-gradient(160deg, rgba(255, 255, 255, 0.98), color-mix(in srgb, currentColor 10%, white 90%)), + #ffffff; + box-shadow: + 0 0 0 5px color-mix(in srgb, currentColor 14%, transparent), + 0 24px 44px color-mix(in srgb, currentColor 22%, transparent), + inset 6px 0 0 currentColor, + inset 0 1px 0 rgba(255, 255, 255, 0.96); +} + +.warehouse-tab-card-active::before { + position: absolute; + top: 8px; + right: 9px; + z-index: 2; + padding: 3px 8px; + border: 1px solid color-mix(in srgb, currentColor 30%, white 70%); + border-radius: 999px; + color: currentColor; + background: rgba(255, 255, 255, 0.88); + box-shadow: 0 8px 18px color-mix(in srgb, currentColor 14%, transparent); + content: "当前"; + font-size: 11px; + font-weight: 900; + letter-spacing: 0.08em; +} + +.warehouse-tab-card-active::after { + width: 96px; + height: 96px; + opacity: 0.14; +} + +.warehouse-tab-card-active:hover { + transform: translateY(-3px); + border-color: color-mix(in srgb, currentColor 78%, white 22%); + box-shadow: + 0 0 0 5px color-mix(in srgb, currentColor 16%, transparent), + 0 28px 52px color-mix(in srgb, currentColor 26%, transparent), + inset 6px 0 0 currentColor, + inset 0 1px 0 rgba(255, 255, 255, 0.96); +} + +.warehouse-tab-card-icon { + position: relative; + z-index: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + border: 1px solid currentColor; + border-radius: 14px; + background: rgba(255, 255, 255, 0.72); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9); + opacity: 0.92; +} + +.warehouse-tab-card-icon .app-icon { + width: 20px; + height: 20px; +} + +.warehouse-tab-card-copy { + position: relative; + z-index: 1; + display: grid; + gap: 4px; + min-width: 0; +} + +.warehouse-tab-card-copy strong { + color: inherit; + font-size: 14px; + font-weight: 800; + line-height: 1.2; +} + +.warehouse-tab-card-copy span { + color: #64748b; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.02em; +} + +.warehouse-tab-card-active .warehouse-tab-card-copy span { + color: #0f766e; +} + +.warehouse-tab-card.inventory-tab-icon-raw { + color: #2563eb; +} + +.warehouse-tab-card.inventory-tab-icon-semi { + color: #b45309; +} + +.warehouse-tab-card.inventory-tab-icon-finished { + color: #15803d; +} + +.warehouse-tab-card.inventory-tab-icon-auxiliary { + color: #be123c; +} + +.warehouse-tab-card.inventory-tab-icon-scrap { + color: #475569; +} + +.warehouse-tab-card.inventory-tab-icon-return { + color: #7c3aed; +} + +.warehouse-operation-zone { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(220px, 0.52fr); + gap: 14px; + align-items: stretch; +} + +.inventory-page-feedback { + display: grid; + gap: 10px; + margin: 16px 0 18px; +} + +.warehouse-operation-group { + position: relative; + display: grid; + align-content: start; + gap: 13px; + min-width: 0; + padding: 16px; + overflow: hidden; + border: 1px solid rgba(203, 213, 225, 0.72); + border-radius: 22px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.88)), + #ffffff; + box-shadow: + 0 18px 42px rgba(15, 23, 42, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.94); +} + +.warehouse-operation-group::before { + position: absolute; + inset: 0 auto 0 0; + width: 5px; + content: ""; +} + +.warehouse-operation-group header { + position: relative; + z-index: 1; + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.warehouse-operation-group header strong { + color: #172033; + font-size: 16px; + font-weight: 900; +} + +.warehouse-operation-kicker { + color: #64748b; + font-size: 10px; + font-weight: 900; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.warehouse-operation-actions, +.warehouse-tool-actions { + position: relative; + z-index: 1; + display: flex; + flex-wrap: wrap; + gap: 9px; +} + +.warehouse-operation-button, +.warehouse-tool-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + min-height: 38px; + padding: 0 13px; + border: 1px solid rgba(203, 213, 225, 0.82); + border-radius: 13px; + color: #1e293b; + background: rgba(255, 255, 255, 0.9); + box-shadow: + 0 8px 18px rgba(15, 23, 42, 0.07), + inset 0 1px 0 rgba(255, 255, 255, 0.95); + cursor: pointer; + font: inherit; + transition: + transform 0.18s ease, + border-color 0.18s ease, + box-shadow 0.18s ease, + color 0.18s ease, + background 0.18s ease; +} + +.warehouse-operation-button .app-icon, +.warehouse-tool-button .app-icon { + width: 17px; + height: 17px; + flex: 0 0 auto; +} + +.warehouse-operation-button strong, +.warehouse-tool-button strong { + color: inherit; + font-size: 13px; + font-weight: 800; + line-height: 1.2; + white-space: nowrap; +} + +.warehouse-operation-button:hover, +.warehouse-tool-button:hover { + transform: translateY(-2px); + box-shadow: + 0 14px 28px rgba(15, 23, 42, 0.11), + inset 0 1px 0 rgba(255, 255, 255, 0.98); +} + +.warehouse-operation-group-in { + background: + radial-gradient(circle at 100% 0%, rgba(34, 197, 94, 0.16), transparent 36%), + linear-gradient(180deg, rgba(255, 255, 255, 0.97), rgba(240, 253, 244, 0.72)); +} + +.warehouse-operation-group-in::before { + background: linear-gradient(180deg, #22c55e, #86efac); +} + +.warehouse-operation-button-in { + border-color: rgba(34, 197, 94, 0.24); + color: #15803d; +} + +.warehouse-operation-button-in:hover { + border-color: rgba(34, 197, 94, 0.48); + background: rgba(240, 253, 244, 0.96); +} + +.warehouse-operation-group-out { + background: + radial-gradient(circle at 100% 0%, rgba(239, 68, 68, 0.14), transparent 36%), + linear-gradient(180deg, rgba(255, 255, 255, 0.97), rgba(255, 241, 242, 0.7)); +} + +.warehouse-operation-group-out::before { + background: linear-gradient(180deg, #ef4444, #fda4af); +} + +.warehouse-operation-button-out { + border-color: rgba(239, 68, 68, 0.23); + color: #be123c; +} + +.warehouse-operation-button-out:hover { + border-color: rgba(239, 68, 68, 0.46); + background: rgba(255, 241, 242, 0.96); +} + +.warehouse-operation-group-tools { + background: + radial-gradient(circle at 100% 0%, rgba(14, 165, 233, 0.15), transparent 38%), + linear-gradient(180deg, rgba(255, 255, 255, 0.97), rgba(239, 246, 255, 0.72)); +} + +.warehouse-operation-group-tools::before { + background: linear-gradient(180deg, #0ea5e9, #67e8f9); +} + +.warehouse-tool-actions { + display: grid; + grid-template-columns: minmax(0, 1fr); +} + +.warehouse-tool-button { + width: 100%; + color: #0369a1; +} + +.warehouse-tool-button:hover { + border-color: rgba(14, 165, 233, 0.44); + background: rgba(240, 249, 255, 0.96); +} + +.warehouse-tool-button-primary { + color: #0f766e; + border-color: rgba(20, 184, 166, 0.3); + background: + linear-gradient(135deg, rgba(240, 253, 250, 0.96), rgba(236, 253, 245, 0.84)), + #ffffff; +} + +.warehouse-tool-button-primary:hover { + border-color: rgba(20, 184, 166, 0.54); +} + +@media (max-width: 1180px) { + .warehouse-switchboard { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .warehouse-operation-zone { + grid-template-columns: minmax(0, 1fr); + } + + .warehouse-tool-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 720px) { + .warehouse-switchboard { + grid-template-columns: minmax(0, 1fr); + padding: 10px; + border-radius: 18px; + } + + .warehouse-tab-card { + min-height: 64px; + } + + .warehouse-operation-group { + padding: 14px; + border-radius: 18px; + } + + .warehouse-operation-actions, + .warehouse-tool-actions { + display: grid; + grid-template-columns: minmax(0, 1fr); + } + + .warehouse-operation-button, + .warehouse-tool-button { + width: 100%; + } +} + +/* Task 10A: purchase chain page hero system */ +.page-hero-card { + position: relative; + display: flex; + align-items: stretch; + justify-content: space-between; + gap: 18px; + min-height: 132px; + padding: 22px 24px; + overflow: hidden; + border: 1px solid rgba(190, 205, 224, 0.92); + border-radius: 24px; + background: + radial-gradient(circle at 8% 0%, rgba(14, 165, 233, 0.16), transparent 32%), + radial-gradient(circle at 92% 16%, rgba(22, 163, 74, 0.12), transparent 30%), + linear-gradient(135deg, rgba(255, 255, 255, 0.98) 0%, rgba(242, 247, 252, 0.94) 58%, rgba(232, 239, 247, 0.9) 100%); + box-shadow: + 0 18px 42px rgba(15, 23, 42, 0.09), + inset 0 1px 0 rgba(255, 255, 255, 0.95); +} + +.page-hero-card::before { + content: ""; + position: absolute; + inset: 18px auto 18px 0; + width: 5px; + border-radius: 0 999px 999px 0; + background: linear-gradient(180deg, var(--ui-steel), var(--ui-cyan), var(--ui-green)); +} + +.page-hero-card::after { + content: ""; + position: absolute; + right: 22px; + bottom: 18px; + width: 168px; + height: 44px; + border: 1px solid rgba(37, 99, 235, 0.12); + border-radius: 999px; + background: + linear-gradient(90deg, rgba(37, 99, 235, 0.08) 1px, transparent 1px) 0 0 / 18px 100%, + rgba(255, 255, 255, 0.2); + opacity: 0.62; + pointer-events: none; +} + +.page-hero-copy { + position: relative; + z-index: 1; + display: grid; + align-content: center; + gap: 8px; + min-width: 0; +} + +.page-hero-card .eyebrow { + margin: 0; + color: var(--ui-steel); + font-family: var(--ui-font-display); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.page-hero-card h3 { + margin: 0; + color: #0f172a; + font-family: var(--ui-font-display); + font-size: clamp(24px, 2.8vw, 34px); + font-weight: 850; + line-height: 1.08; +} + +.page-hero-subtitle { + max-width: 68ch; + margin: 0; + color: var(--ui-ink-soft); + font-size: 14px; + font-weight: 500; + line-height: 1.65; +} + +.page-hero-actions { + position: relative; + z-index: 1; + display: flex; + flex: 0 0 auto; + flex-wrap: wrap; + align-content: center; + align-items: center; + justify-content: flex-end; + gap: 10px; + max-width: 420px; +} + +.page-hero-actions .panel-tag { + background: rgba(255, 255, 255, 0.86); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9); +} + +.purchase-chain-hero { + margin-bottom: 16px; +} + +.purchase-receipt-hero { + background: + radial-gradient(circle at 10% 0%, rgba(22, 163, 74, 0.16), transparent 32%), + radial-gradient(circle at 92% 12%, rgba(14, 165, 233, 0.12), transparent 30%), + linear-gradient(135deg, rgba(255, 255, 255, 0.98) 0%, rgba(242, 250, 246, 0.94) 58%, rgba(235, 245, 240, 0.9) 100%); +} + +.quality-inspection-hero { + background: + radial-gradient(circle at 10% 0%, rgba(217, 119, 6, 0.16), transparent 32%), + radial-gradient(circle at 92% 12%, rgba(37, 99, 235, 0.12), transparent 30%), + linear-gradient(135deg, rgba(255, 255, 255, 0.98) 0%, rgba(255, 249, 242, 0.94) 56%, rgba(244, 246, 250, 0.9) 100%); +} + +.status-badge-stack { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + min-width: 0; +} + +.receipt-line-meta-grid { + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); +} + +.drawer-row-detail-grid strong.status-badge-stack { + justify-content: flex-start; +} + +/* Keep wide business tables inside their scroll containers instead of stretching the whole page. */ +.page-grid, +.page-grid > *, +.panel, +.table-wrap { + min-width: 0; + max-width: 100%; +} + +@media (max-width: 860px) { + .page-hero-card { + flex-direction: column; + min-height: 0; + padding: 18px; + } + + .page-hero-actions { + width: 100%; + max-width: none; + justify-content: flex-start; + } +} + +@media (max-width: 560px) { + .page-hero-actions > button { + width: 100%; + } +} + +/* Task 10B: sales and delivery pages reuse the page hero system without changing table rules. */ +.sales-chain-hero { + margin-bottom: 16px; +} + +.sales-chain-hero.page-hero-card { + border-color: rgba(176, 196, 218, 0.94); +} + +.sales-planning-hero { + background: + radial-gradient(circle at 10% 0%, rgba(20, 184, 166, 0.16), transparent 32%), + radial-gradient(circle at 92% 12%, rgba(37, 99, 235, 0.12), transparent 30%), + linear-gradient(135deg, rgba(255, 255, 255, 0.98) 0%, rgba(240, 253, 250, 0.94) 56%, rgba(234, 242, 252, 0.9) 100%); +} + +.sales-order-hero { + background: + radial-gradient(circle at 10% 0%, rgba(37, 99, 235, 0.15), transparent 32%), + radial-gradient(circle at 92% 12%, rgba(14, 165, 233, 0.13), transparent 30%), + linear-gradient(135deg, rgba(255, 255, 255, 0.98) 0%, rgba(242, 247, 252, 0.94) 58%, rgba(232, 239, 247, 0.9) 100%); +} + +.delivery-ledger-hero { + background: + radial-gradient(circle at 10% 0%, rgba(14, 165, 233, 0.15), transparent 32%), + radial-gradient(circle at 92% 12%, rgba(245, 158, 11, 0.12), transparent 30%), + linear-gradient(135deg, rgba(255, 255, 255, 0.98) 0%, rgba(240, 249, 255, 0.94) 56%, rgba(248, 244, 234, 0.9) 100%); +} + +.sales-chain-hero .page-hero-actions { + row-gap: 10px; +} + +.sales-planning-hero .page-hero-actions { + max-width: 540px; +} + +.sales-chain-hero .supplier-action-bar { + flex-wrap: wrap; + justify-content: flex-end; + border-color: rgba(207, 219, 234, 0.72); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(248, 251, 255, 0.76)), + rgba(255, 255, 255, 0.66); +} + +.sales-chain-hero .supplier-exchange-group { + flex-wrap: wrap; +} + +.sales-chain-hero .contract-header-button, +.sales-chain-hero .primary-button { + min-height: 38px; +} + +@media (max-width: 860px) { + .sales-chain-hero .supplier-action-bar { + justify-content: flex-start; + } +} + +@media (max-width: 560px) { + .sales-chain-hero .supplier-action-bar, + .sales-chain-hero .supplier-exchange-group { + width: 100%; + } + + .sales-chain-hero .supplier-exchange-group { + padding-left: 0; + border-left: 0; + } + + .sales-chain-hero .supplier-action-button, + .sales-chain-hero .supplier-exchange-group .supplier-action-button { + flex: 1 1 100%; + } +} + +/* Task 10C: production pages align with the page hero system without touching table rules. */ +.production-chain-hero, +.operation-chain-hero { + margin-bottom: 16px; + border-color: rgba(176, 196, 218, 0.94); +} + +.production-ledger-hero { + background: + radial-gradient(circle at 10% 0%, rgba(22, 163, 74, 0.16), transparent 32%), + radial-gradient(circle at 92% 12%, rgba(37, 99, 235, 0.12), transparent 30%), + linear-gradient(135deg, rgba(255, 255, 255, 0.98) 0%, rgba(241, 252, 246, 0.94) 56%, rgba(234, 242, 252, 0.9) 100%); +} + +.operation-report-hero { + background: + radial-gradient(circle at 10% 0%, rgba(14, 165, 233, 0.16), transparent 32%), + radial-gradient(circle at 92% 12%, rgba(217, 119, 6, 0.12), transparent 30%), + linear-gradient(135deg, rgba(255, 255, 255, 0.98) 0%, rgba(240, 249, 255, 0.94) 56%, rgba(251, 247, 238, 0.9) 100%); +} + +.operation-filter-ribbon { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin: -2px 0 14px; + padding: 10px; + border: 1px solid rgba(207, 219, 234, 0.86); + border-radius: 18px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(248, 251, 255, 0.82)), + rgba(255, 255, 255, 0.72); + box-shadow: + 0 10px 26px rgba(15, 23, 42, 0.07), + inset 0 1px 0 rgba(255, 255, 255, 0.96); +} + +.operation-filter-default { + display: inline-flex; + align-items: center; + min-height: 34px; + padding: 0 12px; + border-radius: 999px; + background: rgba(239, 246, 255, 0.92); + color: #1d4ed8; + font-size: 12px; + font-weight: 800; + white-space: nowrap; +} + +.operation-filter-field { + display: inline-grid; + grid-template-columns: auto minmax(132px, 1fr); + align-items: center; + gap: 8px; + min-height: 34px; + padding: 4px 8px 4px 12px; + border: 1px solid rgba(207, 219, 234, 0.78); + border-radius: 12px; + background: rgba(255, 255, 255, 0.86); + color: var(--ui-ink-soft); + font-size: 12px; + font-weight: 700; +} + +.operation-filter-field input { + width: 100%; + min-width: 0; + min-height: 28px; + border: 0; + border-radius: 8px; + background: rgba(241, 245, 249, 0.88); + color: var(--ui-ink); + font-size: 12px; + font-weight: 700; +} + +.operation-filter-ribbon .ghost-button { + min-height: 34px; +} + +.production-compact-tip, +.production-ledger-note { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border: 1px solid rgba(186, 203, 222, 0.78); + border-radius: 14px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 251, 255, 0.86)), + rgba(255, 255, 255, 0.72); + color: var(--ui-ink-soft); + font-size: 12px; + font-weight: 600; + line-height: 1.55; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.92); +} + +.production-compact-tip strong, +.production-ledger-note strong { + color: #0f172a; + font-weight: 850; + white-space: nowrap; +} + +.production-compact-tip span, +.production-ledger-note span { + min-width: 0; +} + +.production-mode-tip { + margin-bottom: 14px; + border-color: rgba(245, 158, 11, 0.32); + background: + linear-gradient(180deg, rgba(255, 251, 235, 0.96), rgba(255, 247, 237, 0.84)), + rgba(255, 255, 255, 0.72); + color: #92400e; +} + +.production-mode-tip strong { + color: #78350f; +} + +.production-mode-tip .field-hint { + flex: 0 0 auto; + margin-left: 0; +} + +.production-ledger-note { + margin: 14px 0; +} + +.production-drawer-tip { + margin-top: 14px; +} + +@media (max-width: 860px) { + .operation-filter-ribbon { + align-items: stretch; + } + + .operation-filter-field { + flex: 1 1 220px; + } +} + +@media (max-width: 560px) { + .operation-filter-ribbon, + .production-compact-tip, + .production-ledger-note { + align-items: stretch; + } + + .operation-filter-default, + .operation-filter-field, + .operation-filter-ribbon .ghost-button { + width: 100%; + } + + .production-compact-tip, + .production-ledger-note { + flex-direction: column; + } + + .production-compact-tip strong, + .production-ledger-note strong { + white-space: normal; + } +} + +/* Task 11: finance and system permission precision console polish */ +.finance-precision-panel, +.system-permission-page, +.system-permission-map-card, +.system-permission-maintenance-card, +.org-permission-tree-card { + min-width: 0; +} + +.finance-precision-panel { + overflow: hidden; + border-color: rgba(190, 205, 224, 0.9); + border-radius: 26px; + background: + radial-gradient(circle at 4% 0%, rgba(14, 165, 233, 0.08), transparent 28%), + linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(246, 249, 252, 0.92)); + box-shadow: var(--ui-shadow-card); +} + +.finance-report-hero.page-hero-card { + margin: 0 0 14px; + border: 0; + border-radius: 0 0 24px 24px; + background: + radial-gradient(circle at 8% 0%, rgba(14, 165, 233, 0.14), transparent 32%), + radial-gradient(circle at 92% 20%, rgba(217, 119, 6, 0.12), transparent 30%), + linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(240, 246, 252, 0.94) 58%, rgba(234, 240, 247, 0.9)); +} + +.finance-report-hero .title-with-help, +.system-permission-hero-card .page-hero-copy { + min-width: 0; +} + +.finance-report-hero .title-with-help h3 { + margin: 0; +} + +.filter-ribbon, +.finance-filter-ribbon { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + padding: 10px; + border: 1px solid rgba(207, 219, 234, 0.88); + border-radius: 18px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 251, 255, 0.84)), + rgba(255, 255, 255, 0.72); + box-shadow: + 0 10px 26px rgba(15, 23, 42, 0.07), + inset 0 1px 0 rgba(255, 255, 255, 0.94); +} + +.finance-filter-ribbon.page-hero-actions { + max-width: min(620px, 100%); + align-content: center; + justify-content: flex-end; +} + +.finance-filter-ribbon .finance-compact-field { + display: inline-grid; + grid-template-columns: auto minmax(132px, 1fr); + align-items: center; + gap: 8px; + min-width: 0; + min-height: 36px; + margin: 0; + padding: 4px 8px 4px 12px; + border: 1px solid rgba(207, 219, 234, 0.8); + border-radius: 12px; + background: rgba(255, 255, 255, 0.88); +} + +.finance-filter-ribbon .finance-compact-field span { + margin: 0; + color: var(--ui-ink-soft); + font-size: 12px; + font-weight: 800; + white-space: nowrap; +} + +.finance-filter-ribbon .finance-compact-field input { + width: 100%; + min-width: 0; + min-height: 28px; + border: 0; + border-radius: 8px; + background: rgba(241, 245, 249, 0.9); + color: var(--ui-ink); + font-size: 12px; + font-weight: 700; +} + +.finance-filter-ribbon .ghost-button, +.finance-filter-ribbon .primary-button { + min-height: 36px; + border-radius: 12px; + padding-inline: 14px; + font-size: 12px; +} + +.finance-precision-panel > .table-control-bar { + margin: 0 18px 12px; +} + +.finance-precision-panel .finance-inline-stats { + margin-top: 0; +} + +.finance-precision-panel .finance-inline-stat { + border-radius: 18px; + border: 1px solid rgba(190, 205, 224, 0.9); + border-color: rgba(190, 205, 224, 0.9); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 251, 255, 0.9)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.94); +} + +.finance-precision-panel .finance-inline-stat span { + color: var(--ui-ink-muted); +} + +.finance-precision-panel .finance-inline-stat strong { + color: #0f172a; + font-family: var(--ui-font-display); + font-variant-numeric: tabular-nums; +} + +.finance-precision-panel .finance-inline-stat-steel { + border-color: rgba(14, 165, 233, 0.3); +} + +.finance-precision-panel .finance-inline-stat-copper { + border-color: rgba(217, 119, 6, 0.28); +} + +.finance-precision-panel .finance-inline-stat-success { + border-color: rgba(22, 163, 74, 0.28); +} + +.finance-precision-panel .finance-inline-stat-danger { + border-color: rgba(220, 38, 38, 0.3); +} + +.finance-precision-panel .finance-board-tags { + padding-bottom: 14px; +} + +.finance-precision-panel .finance-sheet-wrap { + max-width: 100%; + overflow-x: auto; + overflow-y: visible; + scrollbar-gutter: stable; +} + +.finance-precision-panel .finance-sheet-table { + width: max-content; + min-width: 100%; + max-width: none; + table-layout: fixed; + border: 1px solid rgba(190, 205, 224, 0.92); + border-color: rgba(190, 205, 224, 0.92); + border-radius: 18px; + overflow: hidden; + background: #ffffff; + box-shadow: + 0 14px 32px rgba(15, 23, 42, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.94); +} + +.finance-precision-panel .finance-sheet-table th, +.finance-precision-panel .finance-sheet-table td { + border-right: 1px solid rgba(207, 219, 234, 0.92); + border-bottom: 1px solid rgba(207, 219, 234, 0.92); + border-color: rgba(207, 219, 234, 0.92); + color: #334155; + font-size: 12px; + line-height: 1.45; +} + +.finance-precision-panel .finance-sheet-table th:last-child, +.finance-precision-panel .finance-sheet-table td:last-child { + border-right: 0; +} + +.finance-precision-panel .finance-sheet-table tbody td, +.finance-precision-panel .finance-sheet-table tbody tr:nth-child(even) td { + background: #ffffff; +} + +.finance-precision-panel .finance-sheet-table tbody tr:hover td { + background: #f8fbff; +} + +.finance-precision-panel .finance-sheet-title-row th { + top: 0; + z-index: 28; + background: + linear-gradient(90deg, rgba(217, 119, 6, 0.1), rgba(14, 165, 233, 0.12), rgba(217, 119, 6, 0.1)), + linear-gradient(180deg, #f8fafc, #eef4fa); + color: #0f172a; + font-family: var(--ui-font-display); + font-size: 22px; + letter-spacing: 0.06em; +} + +.finance-precision-panel .finance-sheet-group-row th { + top: 58px; + z-index: 27; + background: linear-gradient(180deg, #eef6ff, #e8f0f8); + color: #1e3a5f; + font-size: 11px; + letter-spacing: 0.08em; +} + +.finance-precision-panel .finance-sheet-column-row th { + top: 104px; + z-index: 26; + background: linear-gradient(180deg, #f8fafc, #eef3f8); + color: #334155; + font-weight: 850; +} + +.finance-precision-panel .finance-sheet-table tbody .finance-sticky-cell { + z-index: 16; + background: #fbfdff; + box-shadow: 1px 0 0 rgba(207, 219, 234, 0.95); +} + +.finance-precision-panel .finance-sheet-column-row .finance-sticky-cell, +.finance-precision-panel .finance-sheet-group-row .finance-sticky-cell, +.finance-precision-panel .finance-sheet-title-row .finance-sticky-cell { + z-index: 32; +} + +.finance-precision-panel .finance-sticky-1 { + left: 0; +} + +.finance-precision-panel .finance-sticky-2 { + left: 84px; +} + +.finance-precision-panel .finance-sticky-3 { + left: 200px; +} + +.finance-precision-panel .finance-sticky-4 { + left: 360px; +} + +.finance-precision-panel .finance-sticky-5 { + left: 520px; +} + +.finance-precision-panel .finance-code-cell strong { + color: #0f172a; +} + +.finance-precision-panel .finance-code-cell small, +.finance-precision-panel .finance-note-cell span { + color: var(--ui-ink-muted); +} + +.finance-precision-panel .finance-mode-chip { + border: 1px solid rgba(14, 165, 233, 0.22); + border-color: rgba(14, 165, 233, 0.22); + background: #eff8ff; + color: #0369a1; + font-weight: 800; +} + +.finance-precision-panel .finance-mode-self { + border-color: rgba(14, 165, 233, 0.22); +} + +.finance-precision-panel .finance-mode-customer { + border-color: rgba(22, 163, 74, 0.24); + background: #f0fdf4; + color: #15803d; +} + +.finance-precision-panel .finance-mode-outsource { + border-color: rgba(217, 119, 6, 0.25); + background: #fff7ed; + color: #b45309; +} + +.finance-precision-panel .finance-sheet-row-warning td { + box-shadow: inset 0 1px 0 rgba(217, 119, 6, 0.1); +} + +.finance-precision-panel .finance-sheet-row-loss td, +.finance-precision-panel .finance-sheet-row-loss .finance-sticky-cell { + background: #fff5f5 !important; +} + +.finance-precision-panel .finance-empty-cell { + color: var(--ui-ink-muted); + background: #f8fafc !important; +} + +.finance-precision-panel .finance-rollup-table, +.finance-precision-panel .finance-expense-table { + border: 1px solid rgba(190, 205, 224, 0.92); + border-color: rgba(190, 205, 224, 0.92); + border-radius: 18px; + overflow: hidden; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 251, 255, 0.9)); +} + +.finance-precision-panel .finance-rollup-table th, +.finance-precision-panel .finance-rollup-table td, +.finance-precision-panel .finance-expense-table th, +.finance-precision-panel .finance-expense-table td { + border-right: 1px solid rgba(207, 219, 234, 0.86); + border-bottom: 1px solid rgba(207, 219, 234, 0.86); +} + +.finance-precision-panel .finance-rollup-table th:last-child, +.finance-precision-panel .finance-rollup-table td:last-child, +.finance-precision-panel .finance-expense-table th:last-child, +.finance-precision-panel .finance-expense-table td:last-child { + border-right: 0; +} + +.finance-precision-panel .finance-rollup-table tr:last-child th, +.finance-precision-panel .finance-rollup-table tr:last-child td, +.finance-precision-panel .finance-expense-table tr:last-child th, +.finance-precision-panel .finance-expense-table tr:last-child td { + border-bottom: 0; +} + +.finance-expense-drawer { + border-left: 1px solid rgba(190, 205, 224, 0.95); + background: + radial-gradient(circle at 0 0, rgba(14, 165, 233, 0.1), transparent 34%), + linear-gradient(180deg, #ffffff, #f6f9fc); +} + +.finance-expense-grid { + align-items: start; +} + +.finance-expense-drawer .workflow-section-card, +.finance-expense-drawer .workflow-summary-card { + border-color: rgba(190, 205, 224, 0.92); + border-radius: 22px; + background: rgba(255, 255, 255, 0.92); + box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08); +} + +.finance-expense-drawer .table-wrap { + overflow-x: auto; +} + +.system-permission-page { + width: 100%; + max-width: 100%; + overflow-x: clip; +} + +.system-permission-page .system-permission-hero-card.page-hero-card { + min-height: 128px; + padding: 22px 24px; + border-color: rgba(190, 205, 224, 0.92); + background: + radial-gradient(circle at 8% 0%, rgba(14, 165, 233, 0.15), transparent 32%), + radial-gradient(circle at 92% 16%, rgba(37, 99, 235, 0.1), transparent 30%), + linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(242, 247, 252, 0.94) 58%, rgba(232, 239, 247, 0.9)); +} + +.system-permission-page .system-permission-hero-card h3 { + margin: 0; + color: #0f172a; + font-family: var(--ui-font-display); + font-size: clamp(24px, 2.8vw, 34px); + line-height: 1.08; +} + +.admin-tab-switcher.system-permission-main-tabs { + display: inline-flex; + width: fit-content; + max-width: 100%; + gap: 6px; + padding: 6px; + border: 1px solid rgba(190, 205, 224, 0.92); + border-radius: 18px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(241, 245, 249, 0.84)), + rgba(255, 255, 255, 0.72); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.92); +} + +.admin-tab-switcher.system-permission-main-tabs button { + min-width: 150px; + border: 1px solid transparent; + border-radius: 13px; + padding: 10px 16px; + color: var(--ui-ink-soft); + background: transparent; + font-family: var(--ui-font-display); + font-weight: 850; +} + +.admin-tab-switcher.system-permission-main-tabs button.active { + border-color: rgba(14, 165, 233, 0.24); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(239, 248, 255, 0.92)); + color: #0f5f96; + box-shadow: + 0 10px 22px rgba(15, 23, 42, 0.08), + inset 0 3px 0 rgba(14, 165, 233, 0.72); +} + +.system-permission-map-card, +.system-permission-maintenance-card { + border: 1px solid rgba(190, 205, 224, 0.92); + border-radius: 26px; + background: + radial-gradient(circle at 0 0, rgba(14, 165, 233, 0.08), transparent 26%), + linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(247, 250, 253, 0.92)); + box-shadow: var(--ui-shadow-card); +} + +.system-permission-page .section-head { + align-items: flex-start; +} + +.mindmap-card-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 10px; +} + +.org-view-switch, +.mindmap-toolbar { + display: inline-flex; + flex-wrap: wrap; + gap: 6px; + padding: 5px; + border: 1px solid rgba(207, 219, 234, 0.88); + border-radius: 14px; + background: rgba(255, 255, 255, 0.76); +} + +.org-view-switch button, +.mindmap-toolbar button { + min-height: 30px; + border: 1px solid transparent; + border-radius: 10px; + padding: 0 10px; + background: transparent; + color: var(--ui-ink-soft); + font-size: 12px; + font-weight: 800; + cursor: pointer; +} + +.org-view-switch button.active, +.mindmap-toolbar button:hover { + border-color: rgba(14, 165, 233, 0.22); + background: #eff8ff; + color: #0369a1; +} + +.maintenance-table-panel, +.employee-panel, +.org-tree-panel { + min-width: 0; +} + +.maintenance-table-panel .table-wrap, +.employee-panel .table-wrap { + overflow-x: auto; + border: 1px solid rgba(190, 205, 224, 0.9); + border-radius: 18px; + background: #ffffff; +} + +.maintenance-table-panel .data-table, +.org-employee-table { + margin: 0; + border-collapse: separate; + border-spacing: 0; +} + +.maintenance-table-panel .data-table th, +.maintenance-table-panel .data-table td, +.org-employee-table th, +.org-employee-table td { + border-bottom: 1px solid rgba(207, 219, 234, 0.86); +} + +.maintenance-table-panel .data-table thead th, +.org-employee-table thead th { + position: sticky; + top: 0; + z-index: 2; + background: linear-gradient(180deg, #f8fafc, #eef3f8); + color: #334155; + font-family: var(--ui-font-display); + font-weight: 850; +} + +.maintenance-table-panel .data-table tbody tr:hover td, +.org-employee-table tbody tr:hover td { + background: #f8fbff; +} + +.system-permission-drawer { + border-left: 1px solid rgba(190, 205, 224, 0.95); + background: + radial-gradient(circle at 0 0, rgba(14, 165, 233, 0.1), transparent 34%), + linear-gradient(180deg, #ffffff, #f6f9fc); +} + +.system-permission-drawer .drawer-header, +.finance-expense-drawer .workflow-drawer-head { + padding-bottom: 14px; + border-bottom: 1px solid rgba(207, 219, 234, 0.86); +} + +.authorize-summary, +.permission-tree, +.selected-manager-tags, +.manager-search-box, +.manager-option-grid { + border-color: rgba(190, 205, 224, 0.9); +} + +.org-context-menu { + border: 1px solid rgba(190, 205, 224, 0.95); + border-radius: 14px; + background: rgba(255, 255, 255, 0.98); + box-shadow: var(--ui-shadow-float); +} + +.org-context-menu button:hover { + background: #eff8ff; + color: #0369a1; +} + +.org-permission-tree-card .org-tree-panel, +.org-permission-tree-card .employee-panel { + border-color: rgba(190, 205, 224, 0.92); + border-radius: 24px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 251, 255, 0.92)); +} + +.org-permission-tree-card .employee-search-field { + border: 1px solid rgba(207, 219, 234, 0.88); + border-radius: 16px; + padding: 8px 10px; + background: rgba(255, 255, 255, 0.82); +} + +.org-permission-tree-card .employee-search-field input { + background: #f8fafc; +} + +@media (max-width: 980px) { + .finance-report-hero.page-hero-card, + .system-permission-page .system-permission-hero-card.page-hero-card { + flex-direction: column; + } + + .finance-filter-ribbon.page-hero-actions, + .mindmap-card-actions { + justify-content: flex-start; + } + + .finance-filter-ribbon .finance-compact-field { + flex: 1 1 190px; + } + + .system-permission-page .section-head { + flex-direction: column; + } +} + +@media (max-width: 640px) { + .finance-report-hero.page-hero-card, + .system-permission-page .system-permission-hero-card.page-hero-card { + padding: 18px; + } + + .filter-ribbon, + .finance-filter-ribbon, + .admin-tab-switcher.system-permission-main-tabs { + width: 100%; + } + + .finance-filter-ribbon .finance-compact-field, + .finance-filter-ribbon .ghost-button, + .finance-filter-ribbon .primary-button, + .admin-tab-switcher.system-permission-main-tabs button, + .org-view-switch, + .mindmap-toolbar { + width: 100%; + } + + .admin-tab-switcher.system-permission-main-tabs { + display: grid; + grid-template-columns: 1fr; + } +} + +@media (max-width: 720px) { + .finance-precision-panel .finance-sticky-3, + .finance-precision-panel .finance-sticky-4, + .finance-precision-panel .finance-sticky-5 { + left: auto; + } +} + +/* Absolute final UI contract: no bulky explanation headers, readable table headers, long values ellipsize. */ +body .page-hero-card, +body .sales-chain-hero.page-hero-card, +body .purchase-chain-hero.page-hero-card, +body .production-chain-hero.page-hero-card, +body .operation-chain-hero.page-hero-card, +body .finance-report-hero.page-hero-card, +body .system-permission-page .system-permission-hero-card.page-hero-card, +body .panel-hero, +body .master-compact-hero, +body .compact-page-hero { + display: flex !important; + align-items: center !important; + justify-content: space-between !important; + gap: 12px !important; + min-height: 0 !important; + margin: 0 0 10px !important; + padding: 0 0 8px !important; + overflow: visible !important; + border: 0 !important; + border-radius: 0 !important; + background: transparent !important; + box-shadow: none !important; +} + +body .page-hero-card::before, +body .page-hero-card::after, +body .panel-hero::before, +body .panel-hero::after, +body .master-compact-hero::before, +body .master-compact-hero::after, +body .compact-page-hero::before, +body .compact-page-hero::after { + display: none !important; +} + +body .page-hero-copy, +body .hero-copy, +body .finance-board-title.page-hero-copy, +body .system-permission-hero-card .page-hero-copy { + display: flex !important; + flex: 1 1 auto !important; + min-width: 0 !important; + align-items: center !important; + gap: 10px !important; +} + +body .page-hero-card .eyebrow, +body .page-hero-subtitle, +body .hero-copy p, +body .master-compact-hero .hero-copy p, +body .compact-page-hero .hero-copy p { + display: none !important; +} + +body .page-hero-card h3, +body .hero-copy h3, +body .master-compact-hero .hero-copy h3, +body .compact-page-hero .hero-copy h3, +body .system-permission-page .system-permission-hero-card h3 { + max-width: 100% !important; + margin: 0 !important; + color: #0f172a !important; + font-family: var(--ui-font-display) !important; + font-size: 18px !important; + font-weight: 850 !important; + line-height: 1.2 !important; + overflow: hidden !important; + text-overflow: ellipsis !important; + white-space: nowrap !important; +} + +body .page-hero-actions, +body .sales-chain-hero .supplier-action-bar, +body .finance-filter-ribbon.page-hero-actions { + flex: 0 0 auto !important; + max-width: 100% !important; + margin-left: auto !important; +} + +body .table-wrap, +body .drawer-data-table-wrap, +body .workflow-drawer .table-wrap, +body .finance-expense-drawer .table-wrap, +body .maintenance-table-panel .table-wrap, +body .employee-panel .table-wrap, +body .stocktake-panel-history .table-wrap, +body .special-adjustment-table-wrap { + max-width: 100% !important; + overflow-x: auto !important; + overflow-y: auto !important; +} + +body .data-table, +body .compact-table, +body .drawer-data-table, +body .workflow-drawer .data-table, +body .maintenance-table-panel .data-table, +body .org-employee-table, +body .special-adjustment-table { + width: 100% !important; + min-width: max(100%, 1120px) !important; + table-layout: fixed !important; +} + +body .compact-table, +body .maintenance-table-panel .data-table, +body .org-employee-table { + min-width: max(100%, 980px) !important; +} + +body .data-table th, +body .drawer-data-table th, +body .workflow-drawer .data-table th, +body .sortable-th-button, +body .sortable-th-label, +body .drawer-table-head-label { + overflow: visible !important; + text-overflow: clip !important; + white-space: normal !important; + word-break: keep-all !important; + overflow-wrap: normal !important; + writing-mode: horizontal-tb !important; + line-height: 1.35 !important; +} + +body .data-table td, +body .drawer-data-table td, +body .workflow-drawer .data-table td { + overflow: hidden !important; + text-overflow: ellipsis !important; + white-space: nowrap !important; + word-break: normal !important; + overflow-wrap: normal !important; +} + +body .drawer-cell-ellipsis, +body .drawer-cell-code, +body .drawer-data-table .drawer-cell-long, +body .drawer-data-table .drawer-cell-code, +body .drawer-data-table .drawer-cell-ellipsis { + max-width: 100% !important; + overflow: hidden !important; + text-overflow: ellipsis !important; + white-space: nowrap !important; +} + +/* Admin polish: compact page headers and Ruoyi-style tag actions. */ +.admin-shell .tags-context-menu, +.tags-context-menu { + position: fixed; + z-index: 5000; + display: grid; + min-width: 148px; + padding: 6px; + border: 1px solid rgba(190, 205, 224, 0.92); + border-radius: 10px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 251, 255, 0.96)); + box-shadow: + 0 18px 42px rgba(15, 23, 42, 0.16), + inset 0 1px 0 rgba(255, 255, 255, 0.95); + animation: tagsContextIn 0.12s ease-out; +} + +.tags-context-menu::before { + position: absolute; + inset: 8px auto 8px 0; + width: 3px; + border-radius: 0 999px 999px 0; + background: #409eff; + content: ""; +} + +.tags-context-menu button { + position: relative; + display: flex; + align-items: center; + min-height: 30px; + padding: 0 12px; + border: 0; + border-radius: 7px; + background: transparent; + color: #334155; + font: inherit; + font-size: 12px; + font-weight: 700; + text-align: left; + cursor: pointer; + transition: + background 0.14s ease, + color 0.14s ease, + transform 0.14s ease; +} + +.tags-context-menu button:hover:not(:disabled) { + background: #ecf5ff; + color: #096dd9; + transform: translateX(2px); +} + +.tags-context-menu button:disabled { + color: #a8abb2; + cursor: not-allowed; +} + +.tags-context-menu .tags-context-danger:hover:not(:disabled) { + background: #fff1f0; + color: #d4380d; +} + +@keyframes tagsContextIn { + from { + opacity: 0; + transform: translateY(-4px) scale(0.98); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.page-hero-card { + align-items: center; + min-height: 0; + padding: 13px 18px 13px 20px; + border-radius: 16px; + box-shadow: + 0 8px 22px rgba(15, 23, 42, 0.07), + inset 0 1px 0 rgba(255, 255, 255, 0.94); +} + +.page-hero-card::before { + inset: 12px auto 12px 0; + width: 4px; +} + +.page-hero-card::after { + right: 16px; + bottom: 10px; + width: 112px; + height: 28px; + opacity: 0.24; +} + +.page-hero-copy { + gap: 4px; +} + +.page-hero-card h3 { + font-size: clamp(18px, 1.7vw, 24px); + line-height: 1.16; +} + +.page-hero-subtitle { + max-width: min(74ch, 100%); + overflow: hidden; + color: #64748b; + font-size: 12px; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.page-hero-actions { + align-items: center; + justify-content: flex-end; +} + +.sales-chain-hero, +.purchase-chain-hero, +.production-chain-hero, +.operation-chain-hero { + margin-bottom: 10px; +} + +.panel-hero, +.master-compact-hero, +.compact-page-hero { + padding: 13px 18px; + border-radius: 16px; +} + +.panel-hero { + gap: 12px; +} + +.hero-copy h3, +.master-compact-hero .hero-copy h3, +.compact-page-hero .hero-copy h3 { + max-width: none; + margin-bottom: 4px; + font-size: clamp(18px, 1.8vw, 24px); + line-height: 1.18; +} + +.hero-copy p, +.master-compact-hero .hero-copy p, +.compact-page-hero .hero-copy p { + max-width: min(86ch, 100%); + margin: 0; + overflow: hidden; + color: #64748b; + font-size: 12px; + line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 860px) { + .page-hero-card, + .panel-hero, + .master-compact-hero, + .compact-page-hero { + padding: 13px 14px; + } + + .page-hero-card { + flex-direction: row; + flex-wrap: wrap; + gap: 8px; + } + + .page-hero-copy { + flex: 1 1 180px; + } + + .page-hero-actions { + width: auto; + max-width: 100%; + } + + .sales-chain-hero .supplier-action-bar { + width: 100%; + gap: 4px; + padding: 2px; + justify-content: flex-start; + } + + .page-hero-subtitle, + .hero-copy p, + .master-compact-hero .hero-copy p, + .compact-page-hero .hero-copy p { + white-space: normal; + } +} + +@media (max-width: 560px) { + .page-hero-actions > button { + width: auto; + } + + .sales-chain-hero .supplier-action-button, + .sales-chain-hero .supplier-exchange-group .supplier-action-button { + flex: 0 0 auto; + min-height: 30px; + padding: 0 8px; + font-size: 12px; + } + + .sales-chain-hero .contract-header-button, + .sales-chain-hero .primary-button { + min-height: 30px; + } + + .supplier-action-icon { + width: 18px; + height: 18px; + border-radius: 6px; + font-size: 15px; + } + + .supplier-exchange-group { + gap: 2px; + } + + .page-hero-subtitle { + display: none; + } +} + +/* Final density pass: table width comes from column structure, not from long values. */ +.table-wrap, +.drawer-data-table-wrap, +.workflow-drawer .table-wrap, +.finance-expense-drawer .table-wrap, +.maintenance-table-panel .table-wrap, +.employee-panel .table-wrap, +.stocktake-panel-history .table-wrap, +.special-adjustment-table-wrap { + max-width: 100%; + overflow-x: auto; + overflow-y: auto; + overscroll-behavior-x: contain; + scrollbar-gutter: stable; +} + +.data-table, +.compact-table, +.drawer-data-table, +.workflow-drawer .data-table, +.maintenance-table-panel .data-table, +.org-employee-table, +.special-adjustment-table { + width: 100% !important; + min-width: max(100%, 1120px) !important; + max-width: 100% !important; + table-layout: fixed !important; +} + +.compact-table, +.maintenance-table-panel .data-table, +.org-employee-table { + min-width: max(100%, 980px) !important; +} + +.drawer-data-table, +.workflow-drawer .data-table, +.special-adjustment-table { + min-width: max(100%, 1040px) !important; +} + +.data-table th, +.data-table td, +.drawer-data-table th, +.drawer-data-table td, +.workflow-drawer .data-table th, +.workflow-drawer .data-table td { + min-width: 0 !important; + max-width: none; + overflow: hidden; + text-align: left; + vertical-align: middle; +} + +.data-table th, +.drawer-data-table th, +.workflow-drawer .data-table th, +.sortable-th-button, +.sortable-th-label, +.drawer-table-head-label { + white-space: normal; + word-break: keep-all; + overflow-wrap: normal; + writing-mode: horizontal-tb; + line-height: 1.35; +} + +.data-table td, +.drawer-data-table td, +.workflow-drawer .data-table td { + white-space: nowrap; + text-overflow: ellipsis; + word-break: normal; + overflow-wrap: normal; +} + +.drawer-cell-ellipsis, +.drawer-cell-code, +.drawer-data-table .drawer-cell-long, +.drawer-data-table .drawer-cell-code, +.drawer-data-table .drawer-cell-ellipsis { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + word-break: normal; + overflow-wrap: normal; +} + +.drawer-cell-number, +.data-table .number-cell, +.data-table td[data-cell-type="number"] { + white-space: nowrap; + word-break: normal; + overflow-wrap: normal; +} + +.data-table .action-row, +.drawer-data-table .action-row, +.org-employee-table .action-row { + display: flex; + flex-wrap: nowrap; + gap: 6px; + align-items: center; + overflow: hidden; +} + +.data-table .action-row button, +.drawer-data-table .action-row button { + min-width: 0; + padding-inline: 10px; + white-space: nowrap; +} + +/* Final hero cleanup: remove the bulky explanatory head cards across the system. */ +.page-hero-card, +.sales-chain-hero.page-hero-card, +.finance-report-hero.page-hero-card, +.system-permission-page .system-permission-hero-card.page-hero-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 0 !important; + margin: 0 0 10px !important; + padding: 0 !important; + overflow: visible; + border: 0 !important; + border-radius: 0 !important; + background: transparent !important; + box-shadow: none !important; +} + +.page-hero-card::before, +.page-hero-card::after, +.sales-chain-hero.page-hero-card::before, +.sales-chain-hero.page-hero-card::after, +.finance-report-hero.page-hero-card::before, +.finance-report-hero.page-hero-card::after, +.system-permission-page .system-permission-hero-card.page-hero-card::before, +.system-permission-page .system-permission-hero-card.page-hero-card::after { + display: none !important; +} + +.page-hero-copy, +.finance-board-title.page-hero-copy, +.system-permission-hero-card .page-hero-copy { + display: flex; + flex: 1 1 auto; + min-width: 0; + align-items: center; + gap: 10px; +} + +.page-hero-card .eyebrow, +.page-hero-card .page-hero-subtitle, +.hero-copy p, +.master-compact-hero .hero-copy p, +.compact-page-hero .hero-copy p { + display: none !important; +} + +.page-hero-card h3, +.system-permission-page .system-permission-hero-card h3 { + margin: 0 !important; + color: #0f172a; + font-family: var(--ui-font-display); + font-size: 18px !important; + font-weight: 850; + line-height: 1.2 !important; + white-space: nowrap; +} + +.page-hero-actions, +.sales-chain-hero .supplier-action-bar, +.finance-filter-ribbon.page-hero-actions { + flex: 0 0 auto; + max-width: 100%; + margin-left: auto; + padding: 4px !important; + border-radius: 12px !important; +} + +.panel-hero, +.master-compact-hero, +.compact-page-hero { + display: flex !important; + align-items: center; + justify-content: space-between; + min-height: 0 !important; + padding: 0 0 10px !important; + border: 0 !important; + border-radius: 0 !important; + background: transparent !important; + box-shadow: none !important; +} + +.panel-hero::after { + display: none !important; +} + +.hero-copy h3, +.master-compact-hero .hero-copy h3, +.compact-page-hero .hero-copy h3 { + margin: 0 !important; + font-size: 18px !important; + line-height: 1.2 !important; + white-space: nowrap; +} + +/* System permission page: keep mind map as the only scroll surface inside its card. */ +.system-permission-page { + gap: 12px; + height: 100%; + min-height: 0; + overflow: hidden; +} + +.system-permission-page .system-permission-hero-card.page-hero-card { + flex: 0 0 auto; + min-height: 0; + margin: 0; + padding: 11px 18px; +} + +.system-permission-page .system-permission-hero-card h3 { + font-size: clamp(18px, 1.7vw, 24px); + line-height: 1.14; +} + +.system-permission-page .system-permission-hero-card .page-hero-subtitle { + max-width: 82ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.system-permission-page .system-permission-main-tabs { + flex: 0 0 auto; + margin: 0; +} + +.system-permission-page .system-permission-map-card { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + max-height: none; + padding: 18px; + overflow: hidden; +} + +.system-permission-page.system-permission-page--maintenance { + height: auto; + min-height: 100%; + overflow-x: clip; + overflow-y: auto; +} + +.system-permission-page.system-permission-page--maintenance .system-permission-maintenance-card { + flex: 0 0 auto; + min-height: auto; + overflow: visible; +} + +.system-permission-page.system-permission-page--maintenance .maintenance-table-panel { + min-height: 0; + overflow: visible; +} + +.system-permission-page .system-permission-map-card.tree-layout-card { + overflow-x: auto; + overflow-y: hidden; +} + +.system-permission-page .system-permission-map-card > .section-head { + flex: 0 0 auto; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} + +.system-permission-page .system-permission-map-card > .section-head h3 { + font-size: 20px; + line-height: 1.18; +} + +.system-permission-page .system-permission-map-card > .section-head .permission-note { + display: none; +} + +.system-permission-page .org-mind-map { + flex: 1 1 auto; + height: auto; + min-height: 0; + max-height: none; +} + +.system-permission-page .org-permission-tree-card { + flex: 1 1 auto; + height: 100%; + width: max(100%, 1040px); + min-width: 1040px; + min-height: 0; + overflow: hidden; +} + +.system-permission-page .org-permission-tree-card .org-tree-panel, +.system-permission-page .org-permission-tree-card .employee-panel { + height: 100%; + min-height: 0; + overflow: hidden; +} + +.system-permission-page .org-permission-tree-card .org-tree-list, +.system-permission-page .org-permission-tree-card .employee-panel .table-wrap { + flex: 1 1 auto; + min-height: 0; +} + +.system-permission-page .org-permission-tree-card .employee-panel .pagination-bar { + flex: 0 0 auto; + min-height: 0; +} + +@media (max-width: 760px) { + .system-permission-page { + height: auto; + overflow: visible; + } + + .system-permission-page .system-permission-map-card { + max-height: none; + overflow: visible; + } + + .system-permission-page .system-permission-map-card > .section-head { + grid-template-columns: 1fr; + } + + .system-permission-page .mindmap-card-actions { + justify-content: flex-start; + } + + .system-permission-page .org-mind-map { + height: 360px; + min-height: 320px; + } + + .system-permission-page .system-permission-map-card.tree-layout-card { + overflow-x: auto; + overflow-y: hidden; + } + + .system-permission-page .org-permission-tree-card { + height: 100%; + min-width: 1040px; + overflow: hidden; + } + + .system-permission-page .org-permission-tree-card .org-tree-panel, + .system-permission-page .org-permission-tree-card .employee-panel { + height: 100%; + min-height: 0; + overflow: hidden; + } +} + +/* Final table header fix: sortable icons must never cover header text. */ +body .data-table:has(thead th:nth-child(8)), +body .compact-table:has(thead th:nth-child(8)) { + min-width: max(100%, 1120px) !important; +} + +body .data-table:has(thead th:nth-child(10)), +body .compact-table:has(thead th:nth-child(10)) { + min-width: max(100%, 1360px) !important; +} + +body .data-table:has(thead th:nth-child(12)), +body .compact-table:has(thead th:nth-child(12)) { + min-width: max(100%, 1632px) !important; +} + +body .data-table:has(thead th:nth-child(13)), +body .compact-table:has(thead th:nth-child(13)) { + min-width: max(100%, 1768px) !important; +} + +body .data-table:has(thead th:nth-child(15)), +body .compact-table:has(thead th:nth-child(15)) { + min-width: max(100%, 2040px) !important; +} + +body .sortable-th { + min-width: 124px !important; +} + +body .sortable-th-button { + display: grid !important; + grid-template-columns: minmax(max-content, 1fr) 20px !important; + align-items: center !important; + column-gap: 8px !important; + width: 100% !important; + min-width: max-content !important; + white-space: nowrap !important; +} + +body .sortable-th-label { + display: inline-block !important; + min-width: max-content !important; + max-width: none !important; + overflow: visible !important; + text-overflow: clip !important; + white-space: nowrap !important; +} + +body .sort-arrow { + justify-self: end !important; + flex: 0 0 18px !important; + width: 18px !important; + min-width: 18px !important; + margin-left: 0 !important; +} + +/* Global table display modes: overview fits every field on screen; wide table shows every value in full. */ +body .table-view-mode-switch { + display: inline-flex; + flex: 0 0 auto; + align-self: end; + gap: 3px; + margin-left: auto; + padding: 3px; + border: 1px solid rgba(196, 210, 228, 0.95); + border-radius: 999px; + background: + linear-gradient(180deg, #ffffff 0%, #f4f8fc 100%); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.96), + 0 8px 18px rgba(15, 23, 42, 0.06); +} + +body .table-view-mode-button { + min-height: 32px; + padding: 0 12px; + border: 0; + border-radius: 999px; + background: transparent; + color: #64748b; + font: inherit; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.02em; + cursor: pointer; + transition: + background 0.16s ease, + color 0.16s ease, + box-shadow 0.16s ease, + transform 0.16s ease; +} + +body .table-view-mode-button:hover { + color: #096dd9; + background: #ecf5ff; +} + +body .table-view-mode-button.table-view-mode-active { + background: + linear-gradient(180deg, #409eff 0%, #1677ff 100%); + color: #ffffff; + box-shadow: + 0 8px 18px rgba(22, 119, 255, 0.24), + inset 0 1px 0 rgba(255, 255, 255, 0.28); +} + +body .table-view-mode-button.table-view-mode-active:hover { + color: #ffffff; + transform: translateY(-1px); +} + +body .table-view-overview { + overflow-x: hidden !important; +} + +body .table-view-overview .data-table, +body .table-view-overview .compact-table, +body .table-view-overview .drawer-data-table, +body .table-view-overview .org-employee-table, +body .table-view-overview .special-adjustment-table, +body .table-view-overview .data-table.data-table-overview, +body .table-view-overview .compact-table.data-table-overview, +body .table-view-overview .drawer-data-table.data-table-overview, +body .table-view-overview .org-employee-table.data-table-overview, +body .table-view-overview .special-adjustment-table.data-table-overview, +body .data-table.data-table-overview, +body .compact-table.data-table-overview, +body .drawer-data-table.data-table-overview, +body .org-employee-table.data-table-overview, +body .special-adjustment-table.data-table-overview { + width: 100% !important; + min-width: 100% !important; + max-width: 100% !important; + table-layout: fixed !important; +} + +body .table-view-overview .data-table th, +body .table-view-overview .data-table th .sortable-th-button, +body .table-view-overview .data-table th .sortable-th-label, +body .data-table.data-table-overview th, +body .data-table.data-table-overview th .sortable-th-button, +body .data-table.data-table-overview th .sortable-th-label { + min-width: 0 !important; + max-width: 100% !important; + overflow: visible !important; + text-overflow: clip !important; + white-space: normal !important; + word-break: keep-all !important; + overflow-wrap: anywhere !important; +} + +body .table-view-overview .data-table th .sortable-th-button, +body .data-table.data-table-overview th .sortable-th-button { + grid-template-columns: minmax(0, 1fr) 18px !important; + column-gap: 5px !important; +} + +body .table-view-overview .data-table td, +body .data-table.data-table-overview td { + min-width: 0 !important; + max-width: 100% !important; + overflow: hidden !important; + text-overflow: ellipsis !important; + white-space: nowrap !important; + word-break: normal !important; + overflow-wrap: normal !important; +} + +body .table-view-overview .data-table td[data-table-tooltip="auto"] { + cursor: help; +} + +body .table-cell-hover-tooltip { + position: fixed; + z-index: 7000; + max-width: min(460px, calc(100vw - 32px)); + padding: 10px 12px; + border: 1px solid rgba(15, 23, 42, 0.1); + border-radius: 10px; + background: + linear-gradient(180deg, rgba(15, 23, 42, 0.96), rgba(30, 41, 59, 0.96)); + box-shadow: + 0 20px 44px rgba(15, 23, 42, 0.24), + inset 0 1px 0 rgba(255, 255, 255, 0.12); + color: #ffffff; + font-size: 12px; + font-weight: 650; + line-height: 1.45; + overflow-wrap: anywhere; + pointer-events: none; + opacity: 0; + transform: translateY(4px) scale(0.98); + transition: + opacity 0.12s ease, + transform 0.12s ease; +} + +body .table-cell-hover-tooltip-visible { + opacity: 1; + transform: translateY(0) scale(1); +} + +body .table-view-wide { + overflow-x: auto !important; +} + +body .table-view-wide .data-table, +body .table-view-wide .compact-table, +body .table-view-wide .drawer-data-table, +body .table-view-wide .org-employee-table, +body .table-view-wide .special-adjustment-table, +body .table-view-wide .data-table.data-table-wide, +body .table-view-wide .compact-table.data-table-wide, +body .table-view-wide .drawer-data-table.data-table-wide, +body .table-view-wide .org-employee-table.data-table-wide, +body .table-view-wide .special-adjustment-table.data-table-wide, +body .data-table.data-table-wide, +body .compact-table.data-table-wide, +body .drawer-data-table.data-table-wide, +body .org-employee-table.data-table-wide, +body .special-adjustment-table.data-table-wide { + width: max-content !important; + min-width: 100% !important; + max-width: none !important; + table-layout: auto !important; +} + +body .table-view-wide .data-table th, +body .table-view-wide .data-table th .sortable-th-button, +body .table-view-wide .data-table th .sortable-th-label, +body .data-table.data-table-wide th, +body .data-table.data-table-wide th .sortable-th-button, +body .data-table.data-table-wide th .sortable-th-label { + min-width: max-content !important; + max-width: none !important; + overflow: visible !important; + text-overflow: clip !important; + white-space: nowrap !important; + word-break: normal !important; + overflow-wrap: normal !important; +} + +body .table-view-wide .data-table td:not(.action-row), +body .data-table.data-table-wide td:not(.action-row) { + min-width: 0 !important; + max-width: 10em !important; + overflow: hidden !important; + text-overflow: ellipsis !important; + white-space: nowrap !important; + word-break: normal !important; + overflow-wrap: normal !important; +} + +body .table-view-wide .data-table td[data-table-tooltip="auto"], +body .data-table.data-table-wide td[data-table-tooltip="auto"] { + cursor: help; +} + +body .table-view-wide .data-table td.action-row, +body .data-table.data-table-wide td.action-row { + display: table-cell !important; + width: var(--table-action-column-width) !important; + min-width: var(--table-action-column-width) !important; + max-width: none !important; + overflow: visible !important; + text-align: center !important; + white-space: nowrap !important; + text-overflow: clip !important; +} + +body .table-view-wide .data-table:has(td.action-row) thead th:last-child, +body .data-table.data-table-wide:has(td.action-row) thead th:last-child { + width: var(--table-action-column-width) !important; + min-width: var(--table-action-column-width) !important; + max-width: none !important; + text-align: center !important; +} + +body .table-view-wide .data-table .action-row .semantic-action-button, +body .table-view-wide .data-table .action-row .ghost-button, +body .table-view-wide .data-table .action-row .primary-button, +body .data-table.data-table-wide .action-row .semantic-action-button, +body .data-table.data-table-wide .action-row .ghost-button, +body .data-table.data-table-wide .action-row .primary-button { + display: inline-flex !important; + width: auto !important; + min-width: max-content !important; + max-width: none !important; + flex: 0 0 auto !important; + gap: 7px !important; + padding: 0 10px 0 8px !important; + margin-right: 6px !important; + vertical-align: middle !important; +} + +body .table-view-wide .data-table .action-row button:last-child, +body .data-table.data-table-wide .action-row button:last-child { + margin-right: 0 !important; +} + +@media (max-width: 720px) { + body .table-view-mode-switch { + width: 100%; + margin-left: 0; + } + + body .table-view-mode-button { + flex: 1 1 0; + } +} + +/* Final semantic action button system: industrial-console clarity, explicit icon + label, no demo buttons. */ +body button.primary-button:not(.icon-button):not(.table-view-mode-button), +body button.ghost-button:not(.icon-button):not(.table-view-mode-button), +body button.action-button:not(.action-button-icon-only), +body button.link-button.semantic-action-button, +body button.workflow-close-button.semantic-action-button, +body button.contract-header-button.semantic-action-button, +body button.purchase-contract-button.semantic-action-button, +body button.small-button.semantic-action-button { + --button-bg: linear-gradient(180deg, #ffffff 0%, #f5f8fc 100%); + --button-border: rgba(148, 163, 184, 0.62); + --button-text: #334155; + --button-shadow: 0 9px 20px rgba(15, 23, 42, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.92); + --button-icon-bg: rgba(15, 23, 42, 0.06); + --button-icon-text: #334155; + --button-rail: #64748b; + position: relative; + isolation: isolate; + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + gap: 8px !important; + min-height: 36px; + padding: 0 14px 0 10px !important; + overflow: hidden; + border: 1px solid var(--button-border) !important; + border-radius: 12px !important; + background: var(--button-bg) !important; + box-shadow: var(--button-shadow); + color: var(--button-text) !important; + font-family: var(--ui-font-body) !important; + font-size: 13px !important; + font-weight: 800 !important; + line-height: 1 !important; + letter-spacing: 0.01em; + text-align: center; + text-decoration: none !important; + text-underline-offset: 0 !important; + cursor: pointer; + transform: translateY(0); + transition: + transform 0.16s ease, + box-shadow 0.16s ease, + border-color 0.16s ease, + background 0.16s ease, + color 0.16s ease; +} + +body button.semantic-action-button::before, +body button.action-button::before { + position: absolute; + inset: 7px auto 7px 0; + width: 3px; + border-radius: 0 999px 999px 0; + background: var(--button-rail); + opacity: 0.92; + content: ""; +} + +body button.semantic-action-button::after, +body button.action-button::after { + position: absolute; + inset: 0; + z-index: -1; + background: + linear-gradient(110deg, transparent 0%, rgba(255, 255, 255, 0.5) 42%, transparent 74%); + opacity: 0; + transform: translateX(-62%); + transition: + opacity 0.18s ease, + transform 0.36s ease; + content: ""; +} + +body button.semantic-action-button:hover:not(:disabled), +body button.action-button:hover:not(:disabled), +body button.primary-button:not(.icon-button):not(.table-view-mode-button):hover:not(:disabled), +body button.ghost-button:not(.icon-button):not(.table-view-mode-button):hover:not(:disabled) { + transform: translateY(-1px); + border-color: color-mix(in srgb, var(--button-rail) 46%, var(--button-border)) !important; + box-shadow: + 0 13px 26px rgba(15, 23, 42, 0.13), + inset 0 1px 0 rgba(255, 255, 255, 0.95); +} + +body button.semantic-action-button:hover:not(:disabled)::after, +body button.action-button:hover:not(:disabled)::after { + opacity: 1; + transform: translateX(68%); +} + +body button.semantic-action-button:active:not(:disabled), +body button.action-button:active:not(:disabled), +body button.primary-button:not(.icon-button):not(.table-view-mode-button):active:not(:disabled), +body button.ghost-button:not(.icon-button):not(.table-view-mode-button):active:not(:disabled) { + transform: translateY(0); + box-shadow: + 0 4px 12px rgba(15, 23, 42, 0.12), + inset 0 2px 6px rgba(15, 23, 42, 0.08); +} + +body button.semantic-action-button:disabled, +body button.action-button:disabled, +body button.primary-button:disabled, +body button.ghost-button:disabled { + cursor: not-allowed; + filter: saturate(0.72); + opacity: 0.54; + transform: none !important; + box-shadow: none; +} + +body .semantic-action-icon, +body .action-button .app-icon { + display: inline-grid; + place-items: center; + flex: 0 0 auto; + width: 24px; + height: 24px; + border-radius: 9px; + background: var(--button-icon-bg); + color: var(--button-icon-text); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.6), + 0 1px 0 rgba(15, 23, 42, 0.04); +} + +body .semantic-action-svg, +body .action-button .app-icon { + width: 16px; + height: 16px; +} + +body .semantic-action-label, +body .action-button-label { + position: relative; + z-index: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +body button.semantic-action-button[data-button-tone="primary"], +body button.action-button[data-button-tone="primary"], +body button.primary-button:not(.semantic-action-button):not(.icon-button):not(.table-view-mode-button) { + --button-bg: linear-gradient(180deg, #2563eb 0%, #0f7fcf 100%); + --button-border: rgba(37, 99, 235, 0.52); + --button-text: #ffffff; + --button-shadow: 0 14px 30px rgba(37, 99, 235, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.24); + --button-icon-bg: rgba(255, 255, 255, 0.18); + --button-icon-text: #ffffff; + --button-rail: #7dd3fc; +} + +body button.semantic-action-button[data-button-tone="info"], +body button.action-button[data-button-tone="info"] { + --button-bg: linear-gradient(180deg, #eff8ff 0%, #dff0ff 100%); + --button-border: rgba(14, 116, 144, 0.24); + --button-text: #075985; + --button-shadow: 0 10px 22px rgba(14, 116, 144, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.86); + --button-icon-bg: rgba(14, 165, 233, 0.14); + --button-icon-text: #0369a1; + --button-rail: #0ea5e9; +} + +body button.semantic-action-button[data-button-tone="success"], +body button.action-button[data-button-tone="success"] { + --button-bg: linear-gradient(180deg, #ecfdf5 0%, #d1fae5 100%); + --button-border: rgba(22, 163, 74, 0.28); + --button-text: #166534; + --button-shadow: 0 10px 22px rgba(22, 163, 74, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.88); + --button-icon-bg: rgba(22, 163, 74, 0.15); + --button-icon-text: #15803d; + --button-rail: #16a34a; +} + +body button.semantic-action-button[data-button-tone="warning"], +body button.action-button[data-button-tone="warning"] { + --button-bg: linear-gradient(180deg, #fff7ed 0%, #ffedd5 100%); + --button-border: rgba(234, 88, 12, 0.28); + --button-text: #9a3412; + --button-shadow: 0 10px 22px rgba(234, 88, 12, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.88); + --button-icon-bg: rgba(234, 88, 12, 0.14); + --button-icon-text: #c2410c; + --button-rail: #f97316; +} + +body button.semantic-action-button[data-button-tone="danger"], +body button.action-button[data-button-tone="danger"], +body button.danger-ghost:not(.icon-button) { + --button-bg: linear-gradient(180deg, #fff1f2 0%, #ffe4e6 100%); + --button-border: rgba(225, 29, 72, 0.3); + --button-text: #be123c; + --button-shadow: 0 10px 22px rgba(225, 29, 72, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.88); + --button-icon-bg: rgba(225, 29, 72, 0.13); + --button-icon-text: #be123c; + --button-rail: #e11d48; +} + +body .data-table button.semantic-action-button.semantic-action-button[data-button-tone="info"], +body .drawer-data-table button.semantic-action-button.semantic-action-button[data-button-tone="info"] { + --button-bg: linear-gradient(180deg, #eff8ff 0%, #bfdbfe 100%); + --button-border: rgba(14, 116, 144, 0.34); + --button-text: #075985; + --button-shadow: 0 6px 14px rgba(14, 116, 144, 0.14), inset 0 1px 0 rgba(255, 255, 255, 0.88); + --button-icon-bg: rgba(14, 165, 233, 0.18); + --button-icon-text: #0369a1; + --button-rail: #0284c7; +} + +body .data-table button.semantic-action-button.semantic-action-button[data-button-tone="danger"], +body .drawer-data-table button.semantic-action-button.semantic-action-button[data-button-tone="danger"] { + --button-bg: linear-gradient(180deg, #fff1f2 0%, #fecdd3 100%); + --button-border: rgba(225, 29, 72, 0.38); + --button-text: #be123c; + --button-shadow: 0 6px 14px rgba(225, 29, 72, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.88); + --button-icon-bg: rgba(225, 29, 72, 0.17); + --button-icon-text: #be123c; + --button-rail: #e11d48; +} + +body button.semantic-action-button[data-button-kind="table"], +body .data-table .semantic-action-button, +body .drawer-data-table .semantic-action-button { + min-height: 28px; + padding: 0 9px 0 7px !important; + border-radius: 9px !important; + font-size: 12px !important; + font-weight: 800 !important; + box-shadow: + 0 4px 10px rgba(15, 23, 42, 0.06), + inset 0 1px 0 rgba(255, 255, 255, 0.88); +} + +body .data-table .semantic-action-icon, +body .drawer-data-table .semantic-action-icon { + width: 21px; + height: 21px; + border-radius: 7px; +} + +body .data-table .semantic-action-svg, +body .drawer-data-table .semantic-action-svg { + width: 14px; + height: 14px; +} + +body .action-row { + gap: 6px !important; +} + +body .form-toolbar .semantic-action-button, +body .drawer-form-actions .semantic-action-button, +body .drawer-actions .semantic-action-button, +body .workflow-inline-actions .semantic-action-button, +body .page-hero-actions .semantic-action-button, +body .operation-filter-ribbon .semantic-action-button { + flex: 0 0 auto; +} + +body .operation-filter-ribbon .semantic-action-button { + align-self: end; +} + +body .link-button.semantic-action-button { + width: auto; + vertical-align: middle; +} + +body .table-view-overview .data-table .action-row { + display: table-cell !important; + width: 104px !important; + min-width: 104px !important; + overflow: visible !important; + text-align: center !important; + white-space: nowrap !important; +} + +body .table-view-overview .data-table:has(td.action-row) thead th:last-child { + width: 104px !important; + min-width: 104px !important; + text-align: center !important; +} + +body .table-view-overview .data-table .action-row .semantic-action-button, +body .table-view-overview .data-table .action-row .ghost-button, +body .table-view-overview .data-table .action-row .primary-button { + width: calc((100% - 6px) / 2) !important; + min-width: 0 !important; + max-width: none !important; + justify-content: center !important; + gap: 0 !important; + padding: 0 !important; + vertical-align: middle !important; +} + +body .table-view-overview .data-table .action-row button + button { + margin-left: 6px !important; +} + +body .table-view-overview .data-table .action-row button:only-child { + width: 100% !important; +} + +body .table-view-overview .data-table .action-row:has(button:nth-of-type(3)) button { + width: calc((100% - 12px) / 3) !important; +} + +body .table-view-overview .data-table .action-row .semantic-action-label { + display: none !important; +} + +body .table-view-overview .data-table .action-row .semantic-action-icon { + margin: 0 auto !important; +} + +@media (prefers-reduced-motion: reduce) { + body button.semantic-action-button, + body button.semantic-action-button::after, + body button.action-button, + body button.action-button::after { + transition: none !important; + } +} + +/* System permission org tree: keep the personnel list readable inside its own panel. */ +body .system-permission-page .system-permission-map-card.tree-layout-card { + height: min(720px, calc(100vh - 220px)) !important; + min-height: 480px !important; + overflow: hidden !important; +} + +body .system-permission-page .tree-layout-card .org-permission-tree-card { + width: 100% !important; + min-width: 0 !important; + height: 100% !important; + min-height: 0 !important; + grid-template-columns: minmax(320px, 360px) minmax(0, 1fr) !important; +} + +body .system-permission-page .tree-layout-card .employee-panel { + min-width: 0 !important; + overflow-x: hidden !important; + overflow-y: auto !important; +} + +body .system-permission-page .tree-layout-card .employee-panel .table-wrap { + flex: 1 1 0 !important; + min-height: 220px !important; + overflow: auto !important; +} + +body .system-permission-page .tree-layout-card .org-employee-table { + width: 100% !important; + min-width: 100% !important; + max-width: 100% !important; + table-layout: fixed !important; +} + +body .system-permission-page .tree-layout-card .org-employee-table th, +body .system-permission-page .tree-layout-card .org-employee-table td { + min-width: 0 !important; + overflow: hidden !important; + text-overflow: ellipsis !important; + white-space: nowrap !important; +} + +body .system-permission-page .tree-layout-card .org-employee-table th:nth-child(1), +body .system-permission-page .tree-layout-card .org-employee-table td:nth-child(1) { + width: 13% !important; +} + +body .system-permission-page .tree-layout-card .org-employee-table th:nth-child(2), +body .system-permission-page .tree-layout-card .org-employee-table td:nth-child(2) { + width: 20% !important; +} + +body .system-permission-page .tree-layout-card .org-employee-table th:nth-child(3), +body .system-permission-page .tree-layout-card .org-employee-table td:nth-child(3) { + width: 14% !important; +} + +body .system-permission-page .tree-layout-card .org-employee-table th:nth-child(4), +body .system-permission-page .tree-layout-card .org-employee-table td:nth-child(4) { + width: 17% !important; +} + +body .system-permission-page .tree-layout-card .org-employee-table th:nth-child(5), +body .system-permission-page .tree-layout-card .org-employee-table td:nth-child(5) { + width: 12% !important; +} + +body .system-permission-page .tree-layout-card .org-employee-table th:nth-child(6), +body .system-permission-page .tree-layout-card .org-employee-table td:nth-child(6) { + width: 24% !important; +} + +body .system-permission-page .tree-layout-card .org-employee-table .action-row { + display: table-cell !important; + overflow: visible !important; + white-space: nowrap !important; +} + +body .system-permission-page .tree-layout-card .org-employee-table .action-row button { + min-height: 30px !important; + padding: 0 8px !important; + font-size: 12px !important; +} + +@media (max-width: 980px) { + body .system-permission-page .system-permission-map-card.tree-layout-card { + overflow: auto !important; + } + + body .system-permission-page .tree-layout-card .org-permission-tree-card { + grid-template-columns: 1fr !important; + grid-auto-rows: minmax(260px, auto) minmax(320px, 1fr) !important; + overflow: visible !important; + } + + body .system-permission-page .tree-layout-card .org-tree-panel, + body .system-permission-page .tree-layout-card .employee-panel { + width: 100% !important; + min-width: 0 !important; + } + +body .system-permission-page .tree-layout-card .employee-panel .table-wrap { + min-height: 260px !important; + } +} + +/* Quality inspection: compact title switch and on-demand remark confirmation. */ +body .quality-inspection-hero .page-hero-copy { + align-items: flex-start !important; + flex-direction: column !important; + gap: 6px !important; +} + +body .quality-title-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + min-width: 0; +} + +body .quality-title-row h3 { + flex: 0 0 auto; + margin: 0 !important; +} + +body .quality-title-tabbar.inventory-tabbar { + width: auto; + margin: 0; + padding: 4px; + gap: 4px; + border-radius: 14px; + box-shadow: 0 8px 20px rgba(31, 45, 61, 0.08); +} + +body .quality-title-tabbar .inventory-tab { + min-height: 34px; + padding: 0 12px; + border-radius: 11px; + gap: 6px; +} + +body .quality-title-tabbar .inventory-tab strong, +body .quality-title-tabbar .inventory-tab span { + font-size: 13px; +} + +body .quality-remark-modal-mask { + position: fixed; + inset: 0; + z-index: 2800; + display: grid; + place-items: center; + padding: 24px; + background: rgba(15, 23, 42, 0.34); + backdrop-filter: blur(8px); +} + +body .quality-remark-modal { + display: grid; + gap: 14px; + width: min(560px, calc(100vw - 48px)); + padding: 22px; + border: 1px solid rgba(64, 158, 255, 0.24); + border-radius: 20px; + background: + radial-gradient(circle at 8% 0%, rgba(64, 158, 255, 0.12), transparent 32%), + linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 251, 255, 0.96)); + color: #303133; + box-shadow: 0 28px 80px rgba(15, 23, 42, 0.24); +} + +body .quality-remark-modal-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +body .quality-remark-modal-head h3 { + margin: 0; + color: #1f2d3d; + font-size: 20px; +} + +body .quality-remark-summary { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +body .quality-remark-summary span { + border: 1px solid rgba(190, 205, 224, 0.8); + border-radius: 999px; + padding: 6px 10px; + background: rgba(255, 255, 255, 0.72); + color: #475569; + font-size: 12px; + font-weight: 700; +} + +body .quality-remark-field { + margin: 0; +} + +body .quality-remark-field textarea { + resize: vertical; +} + +body .quality-remark-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 2px; +} + +@media (max-width: 760px) { + body .quality-title-row { + align-items: stretch; + flex-direction: column; + } + + body .quality-title-tabbar.inventory-tabbar { + width: 100%; + } +} + +/* Login redesign: industrial hardware cockpit with restrained sci-fi motion. */ +.login-shell { + --login-forge-blue: #38bdf8; + --login-steel: #2563eb; + --login-mint: #34d399; + --login-copper: #f59e0b; + --login-ink: #d8e8f7; + --login-muted: #8ca7bd; + --login-panel: rgba(8, 20, 34, 0.78); + position: relative; + display: grid; + place-items: center; + min-height: 100vh; + padding: clamp(18px, 4vw, 44px); + overflow: hidden; + color: var(--login-ink); + background: + radial-gradient(circle at 18% 12%, rgba(56, 189, 248, 0.22), transparent 30%), + radial-gradient(circle at 82% 22%, rgba(52, 211, 153, 0.16), transparent 26%), + linear-gradient(135deg, #07111f 0%, #0c1827 48%, #122235 100%); + cursor: default; + isolation: isolate; +} + +.login-photo-bg { + position: absolute; + inset: 0; + display: block; + background: + linear-gradient(115deg, rgba(4, 11, 20, 0.82), rgba(7, 18, 31, 0.56) 48%, rgba(8, 17, 29, 0.88)), + radial-gradient(circle at 50% 40%, rgba(79, 180, 255, 0.12), transparent 32%); + filter: saturate(1.04) contrast(1.04); + opacity: 0.8; + transform: scale(1.03); + z-index: -5; +} + +.login-forge-grid { + position: absolute; + inset: 0; + z-index: -4; + background: + linear-gradient(rgba(148, 163, 184, 0.08) 1px, transparent 1px), + linear-gradient(90deg, rgba(148, 163, 184, 0.08) 1px, transparent 1px); + background-size: 52px 52px; + mask-image: radial-gradient(circle at 52% 48%, #000 0%, transparent 74%); +} + +.login-forge-grid::after { + position: absolute; + inset: -20% 0; + background: linear-gradient(180deg, transparent, rgba(56, 189, 248, 0.2), transparent); + content: ""; + opacity: 0.72; + transform: translateY(-45%); + animation: loginScanSweep 5.8s ease-in-out infinite; +} + +.login-metal-beam { + position: absolute; + z-index: -3; + width: min(46vw, 680px); + height: 2px; + border-radius: 999px; + background: linear-gradient(90deg, transparent, rgba(56, 189, 248, 0.86), rgba(245, 158, 11, 0.52), transparent); + filter: blur(0.2px); + opacity: 0.72; + animation: loginBeamDrift 7.2s ease-in-out infinite alternate; +} + +.login-metal-beam-a { + top: 18%; + left: -8%; + transform: rotate(-18deg); +} + +.login-metal-beam-b { + right: -12%; + bottom: 22%; + animation-delay: -2s; + transform: rotate(15deg); +} + +.login-hex-field { + position: absolute; + right: min(8vw, 120px); + top: min(10vh, 110px); + z-index: -2; + width: clamp(170px, 22vw, 340px); + aspect-ratio: 1; + border: 1px solid rgba(56, 189, 248, 0.16); + clip-path: polygon(25% 6%, 75% 6%, 100% 50%, 75% 94%, 25% 94%, 0 50%); + background: + radial-gradient(circle at center, rgba(56, 189, 248, 0.12), transparent 62%), + repeating-linear-gradient(60deg, rgba(226, 232, 240, 0.08) 0 1px, transparent 1px 18px); + opacity: 0.88; +} + +.login-cockpit { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: minmax(0, 1.12fr) minmax(360px, 430px); + gap: clamp(22px, 4vw, 58px); + align-items: stretch; + width: min(1180px, 100%); +} + +.login-command-deck, +.login-auth-card { + position: relative; + overflow: hidden; + border: 1px solid rgba(174, 199, 224, 0.26); + border-radius: 32px; + background: + linear-gradient(145deg, rgba(10, 24, 40, 0.84), rgba(7, 18, 31, 0.68)), + rgba(9, 20, 34, 0.74); + box-shadow: + 0 36px 96px rgba(0, 0, 0, 0.36), + inset 0 1px 0 rgba(255, 255, 255, 0.08); + backdrop-filter: blur(24px); +} + +.login-command-deck { + display: grid; + grid-template-rows: auto auto auto minmax(210px, 1fr); + gap: clamp(18px, 3vw, 28px); + min-height: 620px; + padding: clamp(24px, 4vw, 42px); +} + +.login-command-deck::before, +.login-auth-card::before { + position: absolute; + inset: 0; + border-radius: inherit; + background: + linear-gradient(120deg, rgba(255, 255, 255, 0.16), transparent 32%), + radial-gradient(circle at 18% 0%, rgba(56, 189, 248, 0.18), transparent 28%); + content: ""; + pointer-events: none; +} + +.login-command-brand, +.login-hero-copy, +.login-status-strip, +.login-forge-visual, +.login-panel-head, +.login-form, +.login-security-foot { + position: relative; + z-index: 1; +} + +.login-command-brand { + display: inline-grid; + grid-template-columns: 54px minmax(0, auto); + gap: 14px; + align-items: center; + width: fit-content; + padding: 10px 14px; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 20px; + background: rgba(255, 255, 255, 0.07); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12); +} + +.login-command-brand .login-logo, +.login-shell .login-logo { + width: 54px; + height: 54px; + object-fit: contain; + filter: drop-shadow(0 14px 28px rgba(56, 189, 248, 0.34)); +} + +.login-command-brand span, +.login-console-chip, +.login-kicker { + color: #9fdfff; + font-family: var(--ui-font-display); + font-size: 12px; + font-weight: 900; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.login-command-brand strong { + display: block; + margin-top: 3px; + color: #f8fbff; + font-size: 18px; + letter-spacing: 0.02em; +} + +.login-hero-copy { + max-width: 660px; +} + +.login-hero-copy h1 { + margin: 8px 0 14px; + color: #f8fbff; + font-family: var(--ui-font-display); + font-size: clamp(34px, 5vw, 62px); + line-height: 1.02; + letter-spacing: 0.01em; +} + +.login-hero-copy p:last-child { + max-width: 560px; + margin: 0; + color: #abc2d6; + font-size: 15px; + line-height: 1.8; +} + +.login-status-strip { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.login-status-strip article { + display: grid; + gap: 8px; + min-height: 116px; + padding: 16px; + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 22px; + background: + linear-gradient(150deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.035)), + rgba(15, 30, 48, 0.58); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.login-status-strip .app-icon { + width: 25px; + height: 25px; + color: var(--login-forge-blue); +} + +.login-status-strip span { + color: #b8ccdc; + font-size: 13px; + font-weight: 700; +} + +.login-status-strip strong { + color: #f8fbff; + font-family: var(--ui-font-display); + font-size: 13px; + letter-spacing: 0.11em; +} + +.login-forge-visual { + min-height: 260px; + border: 1px solid rgba(56, 189, 248, 0.22); + border-radius: 28px; + background: + radial-gradient(circle at 50% 48%, rgba(56, 189, 248, 0.2), transparent 36%), + linear-gradient(rgba(148, 163, 184, 0.08) 1px, transparent 1px), + linear-gradient(90deg, rgba(148, 163, 184, 0.08) 1px, transparent 1px), + rgba(7, 16, 28, 0.52); + background-size: auto, 32px 32px, 32px 32px, auto; +} + +.login-factory-orbit, +.login-factory-core, +.login-factory-node { + position: absolute; + left: 50%; + top: 50%; +} + +.login-factory-orbit { + width: min(340px, 62%); + aspect-ratio: 1; + transform: translate(-50%, -50%); +} + +.login-factory-orbit span { + position: absolute; + inset: calc(var(--ring, 0) * 22px); + border: 1px solid rgba(56, 189, 248, 0.18); + border-radius: 999px; + animation: loginOrbitGlow 3.8s ease-in-out infinite; +} + +.login-factory-orbit span:nth-child(1) { + --ring: 0; +} + +.login-factory-orbit span:nth-child(2) { + --ring: 1; + animation-delay: -1s; +} + +.login-factory-orbit span:nth-child(3) { + --ring: 2; + animation-delay: -2s; +} + +.login-factory-core { + display: grid; + place-items: center; + width: 96px; + height: 96px; + border: 1px solid rgba(56, 189, 248, 0.36); + border-radius: 28px; + color: #e8f8ff; + background: + radial-gradient(circle at 30% 20%, rgba(255, 255, 255, 0.22), transparent 28%), + linear-gradient(145deg, rgba(37, 99, 235, 0.78), rgba(14, 165, 233, 0.42)); + box-shadow: + 0 22px 52px rgba(56, 189, 248, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.3); + transform: translate(-50%, -50%) rotate(45deg); +} + +.login-factory-core .app-icon, +.login-factory-core strong { + transform: rotate(-45deg); +} + +.login-factory-core .app-icon { + width: 28px; + height: 28px; +} + +.login-factory-core strong { + font-family: var(--ui-font-display); + font-size: 18px; + letter-spacing: 0.18em; +} + +.login-factory-node { + display: inline-grid; + grid-template-columns: 22px auto; + gap: 8px; + align-items: center; + padding: 9px 12px; + border: 1px solid rgba(148, 163, 184, 0.24); + border-radius: 999px; + color: #daf1ff; + background: rgba(9, 22, 36, 0.88); + box-shadow: 0 14px 32px rgba(0, 0, 0, 0.24); +} + +.login-factory-node .app-icon { + width: 20px; + height: 20px; + color: var(--login-mint); +} + +.login-factory-node span { + font-size: 12px; + font-weight: 900; +} + +.login-factory-node-a { + transform: translate(-210%, -170%); +} + +.login-factory-node-b { + transform: translate(84%, -130%); +} + +.login-factory-node-c { + transform: translate(-34%, 154%); +} + +.login-auth-card { + align-self: center; + padding: clamp(24px, 3vw, 34px); +} + +.login-panel-head { + display: grid; + gap: 10px; + margin-bottom: 24px; + text-align: left; +} + +.login-panel-head h2 { + margin: 0; + color: #f8fbff; + font-family: var(--ui-font-display); + font-size: clamp(28px, 4vw, 38px); + line-height: 1.1; + letter-spacing: 0.03em; +} + +.login-panel-head p { + display: block; + margin: 0; + color: var(--login-muted); + font-size: 13px; + line-height: 1.7; +} + +.login-form { + display: grid; + gap: 16px; + margin: 0; +} + +.login-input-shell { + display: grid; + gap: 8px; +} + +.login-input-shell > span { + color: #b9cde0; + font-size: 13px; + font-weight: 800; + letter-spacing: 0.04em; +} + +.login-input-frame { + display: grid; + grid-template-columns: 22px minmax(0, 1fr); + align-items: center; + gap: 10px; + min-height: 52px; + padding: 0 14px; + border: 1px solid rgba(148, 163, 184, 0.26); + border-radius: 16px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.045)), + rgba(7, 17, 29, 0.62); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); + transition: + border-color var(--ui-transition-fast), + box-shadow var(--ui-transition-fast), + background var(--ui-transition-fast); +} + +.login-input-frame:focus-within { + border-color: rgba(56, 189, 248, 0.72); + background: rgba(10, 24, 40, 0.82); + box-shadow: + 0 0 0 4px rgba(56, 189, 248, 0.12), + inset 0 1px 0 rgba(255, 255, 255, 0.12); +} + +.login-input-frame .app-icon { + width: 21px; + height: 21px; + color: var(--login-forge-blue); +} + +.login-input-frame input { + width: 100%; + min-height: 50px; + border: 0; + outline: 0; + color: #f8fbff; + background: transparent; + font-size: 15px; + font-weight: 700; +} + +.login-input-frame input::placeholder { + color: rgba(184, 204, 220, 0.56); +} + +.login-actions { + display: grid; + gap: 10px; + margin-top: 4px; +} + +.login-submit-button.primary-button { + position: relative; + justify-content: space-between; + width: 100%; + min-height: 54px; + overflow: hidden; + border: 1px solid rgba(56, 189, 248, 0.46); + border-radius: 17px; + background: + linear-gradient(90deg, #1d4ed8, #0891b2 52%, #0f766e); + box-shadow: + 0 20px 44px rgba(14, 165, 233, 0.24), + inset 0 1px 0 rgba(255, 255, 255, 0.24); + font-family: var(--ui-font-display); + font-size: 15px; + letter-spacing: 0.08em; +} + +.login-submit-button::after { + position: absolute; + inset: 0; + background: linear-gradient(105deg, transparent 0%, rgba(255, 255, 255, 0.34) 42%, transparent 68%); + content: ""; + transform: translateX(-120%); + animation: loginButtonGlint 3.4s ease-in-out infinite; +} + +.login-submit-button span, +.login-submit-button .app-icon { + position: relative; + z-index: 1; +} + +.login-submit-button .app-icon { + width: 20px; + height: 20px; +} + +.login-auth-card .form-message, +.login-auth-card .form-error { + margin: 0; + padding: 10px 12px; + border-radius: 13px; + font-size: 13px; + font-weight: 800; +} + +.login-auth-card .form-message { + border: 1px solid rgba(52, 211, 153, 0.24); + color: #bbf7d0; + background: rgba(22, 101, 52, 0.24); +} + +.login-auth-card .form-error { + border: 1px solid rgba(248, 113, 113, 0.24); + color: #fecaca; + background: rgba(127, 29, 29, 0.22); +} + +.login-security-foot { + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + gap: 10px; + align-items: center; + margin-top: 18px; + color: #8ca7bd; + font-size: 12px; + line-height: 1.5; +} + +.login-security-foot span { + width: 28px; + height: 28px; + border: 1px solid rgba(52, 211, 153, 0.34); + border-radius: 999px; + background: + radial-gradient(circle at center, rgba(52, 211, 153, 0.9) 0 3px, transparent 4px), + rgba(52, 211, 153, 0.1); + box-shadow: 0 0 24px rgba(52, 211, 153, 0.22); +} + +.login-security-foot p { + margin: 0; +} + +.login-command-deck { + grid-template-rows: auto minmax(420px, 1fr); + min-height: 620px; +} + +.login-forge-stage { + position: relative; + z-index: 1; + display: grid; + grid-template-rows: auto minmax(310px, 1fr) auto; + gap: 18px; + min-height: 520px; + padding: clamp(18px, 2.6vw, 28px); + overflow: hidden; + border: 1px solid rgba(84, 145, 185, 0.32); + border-radius: 30px; + background: + linear-gradient(145deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.03)), + radial-gradient(circle at 24% 24%, rgba(56, 189, 248, 0.18), transparent 30%), + radial-gradient(circle at 72% 64%, rgba(245, 158, 11, 0.1), transparent 34%), + rgba(3, 13, 24, 0.58); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.1), + inset 0 -1px 0 rgba(56, 189, 248, 0.08), + 0 22px 54px rgba(0, 0, 0, 0.24); +} + +.login-forge-stage::before { + position: absolute; + inset: 0; + background: + repeating-linear-gradient(0deg, rgba(148, 163, 184, 0.05) 0 1px, transparent 1px 28px), + repeating-linear-gradient(90deg, rgba(148, 163, 184, 0.04) 0 1px, transparent 1px 28px); + content: ""; + mask-image: linear-gradient(90deg, transparent, #000 18%, #000 82%, transparent); + pointer-events: none; +} + +.login-forge-stage::after { + position: absolute; + left: 8%; + right: 8%; + top: 52%; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(56, 189, 248, 0.54), rgba(52, 211, 153, 0.44), transparent); + box-shadow: 0 0 26px rgba(56, 189, 248, 0.24); + content: ""; + pointer-events: none; +} + +.login-stage-header, +.login-process-map { + position: relative; + z-index: 1; +} + +.login-stage-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-bottom: 14px; + border-bottom: 1px solid rgba(148, 163, 184, 0.16); + color: #dff5ff; + font-family: var(--ui-font-display); + letter-spacing: 0.08em; +} + +.login-stage-header div { + display: inline-flex; + align-items: center; + gap: 10px; + font-size: 14px; + font-weight: 900; +} + +.login-stage-header strong { + padding: 7px 10px; + border: 1px solid rgba(52, 211, 153, 0.32); + border-radius: 999px; + color: #bbf7d0; + background: rgba(15, 118, 110, 0.16); + font-size: 11px; + letter-spacing: 0.14em; +} + +.login-live-dot { + width: 10px; + height: 10px; + border-radius: 999px; + background: var(--login-mint); + box-shadow: 0 0 0 6px rgba(52, 211, 153, 0.12), 0 0 24px rgba(52, 211, 153, 0.7); + animation: loginLivePulse 1.8s ease-in-out infinite; +} + +.login-process-map { + display: grid; + grid-template-rows: minmax(250px, 1fr) auto; + gap: 18px; + min-height: 430px; + overflow: hidden; + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: 26px; + background: + radial-gradient(circle at 50% 42%, rgba(56, 189, 248, 0.18), transparent 34%), + linear-gradient(180deg, rgba(255, 255, 255, 0.055), transparent 42%), + rgba(3, 12, 22, 0.44); +} + +.login-press-showcase { + position: relative; + min-height: 260px; + overflow: hidden; +} + +.login-press-showcase::before { + position: absolute; + left: 8%; + right: 8%; + top: 62%; + height: 10px; + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 999px; + background: + repeating-linear-gradient(90deg, rgba(226, 232, 240, 0.22) 0 13px, rgba(15, 23, 42, 0.34) 13px 25px), + linear-gradient(90deg, rgba(37, 99, 235, 0.08), rgba(52, 211, 153, 0.16)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.12), + 0 12px 28px rgba(0, 0, 0, 0.24); + content: ""; +} + +.login-coil { + position: absolute; + left: 8%; + top: 35%; + width: 96px; + height: 96px; + border: 1px solid rgba(125, 211, 252, 0.28); + border-radius: 999px; + background: + radial-gradient(circle at center, rgba(3, 12, 22, 0.92) 0 18px, transparent 19px), + conic-gradient(from 20deg, rgba(226, 232, 240, 0.86), rgba(71, 85, 105, 0.36), rgba(226, 232, 240, 0.82), rgba(15, 23, 42, 0.5), rgba(226, 232, 240, 0.86)); + box-shadow: + 0 0 44px rgba(56, 189, 248, 0.14), + inset 0 0 0 9px rgba(15, 23, 42, 0.28); + animation: loginCoilTurn 8s linear infinite; +} + +.login-coil span { + position: absolute; + inset: calc(var(--coil-ring, 0) * 13px); + border: 1px solid rgba(226, 232, 240, 0.28); + border-radius: 999px; +} + +.login-coil span:nth-child(1) { + --coil-ring: 1; +} + +.login-coil span:nth-child(2) { + --coil-ring: 2; +} + +.login-coil span:nth-child(3) { + --coil-ring: 3; +} + +.login-feeder-line { + position: absolute; + left: 18%; + right: 22%; + top: calc(62% - 12px); + height: 28px; + overflow: hidden; +} + +.login-metal-strip { + position: absolute; + left: -12%; + top: 2px; + width: 50px; + height: 24px; + border: 1px solid rgba(226, 232, 240, 0.36); + border-radius: 8px; + background: + linear-gradient(135deg, rgba(248, 250, 252, 0.86), rgba(148, 163, 184, 0.42) 42%, rgba(15, 23, 42, 0.2)), + repeating-linear-gradient(90deg, transparent 0 7px, rgba(255, 255, 255, 0.24) 7px 8px); + box-shadow: + 0 0 22px rgba(56, 189, 248, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.58); + opacity: 0; + animation: loginMetalTravel 4.6s linear infinite; +} + +.login-metal-strip-b { + animation-delay: 1.5s; +} + +.login-metal-strip-c { + animation-delay: 3s; +} + +.login-press-machine { + position: absolute; + left: 50%; + top: 21%; + width: 150px; + height: 176px; + transform: translateX(-50%); +} + +.login-press-arm { + position: absolute; + left: 20px; + right: 20px; + top: 4px; + height: 90px; + border: 1px solid rgba(125, 211, 252, 0.3); + border-radius: 26px 26px 14px 14px; + background: + linear-gradient(180deg, rgba(96, 165, 250, 0.34), rgba(15, 23, 42, 0.84)), + repeating-linear-gradient(90deg, transparent 0 12px, rgba(255, 255, 255, 0.08) 12px 13px); + box-shadow: + 0 22px 50px rgba(56, 189, 248, 0.12), + inset 0 1px 0 rgba(255, 255, 255, 0.14); +} + +.login-press-hammer { + position: absolute; + left: 50%; + top: 66px; + width: 52px; + height: 34px; + border: 1px solid rgba(226, 232, 240, 0.4); + border-radius: 9px; + background: linear-gradient(180deg, #dbeafe, #64748b 56%, #1e293b); + box-shadow: + 0 12px 28px rgba(56, 189, 248, 0.18), + inset 0 1px 0 rgba(255, 255, 255, 0.58); + transform: translateX(-50%); + animation: loginPressStamp 1.45s cubic-bezier(0.76, 0, 0.24, 1) infinite; +} + +.login-press-die { + position: absolute; + left: 27px; + right: 27px; + bottom: 18px; + height: 16px; + border: 1px solid rgba(245, 158, 11, 0.28); + border-radius: 999px; + background: linear-gradient(90deg, rgba(245, 158, 11, 0.46), rgba(56, 189, 248, 0.38)); + box-shadow: 0 0 26px rgba(245, 158, 11, 0.2); +} + +.login-spark { + position: absolute; + left: 50%; + top: 132px; + width: 5px; + height: 5px; + border-radius: 999px; + background: #fbbf24; + box-shadow: 0 0 16px rgba(251, 191, 36, 0.82); + opacity: 0; + animation: loginSparkBurst 1.45s ease-out infinite; +} + +.login-spark-a { + --spark-x: -44px; + --spark-y: 18px; +} + +.login-spark-b { + --spark-x: 34px; + --spark-y: 18px; + animation-delay: 0.05s; +} + +.login-spark-c { + --spark-x: 4px; + --spark-y: 34px; + animation-delay: 0.1s; +} + +.login-stamped-stack { + position: absolute; + right: 8%; + top: 44%; + width: 86px; + height: 88px; +} + +.login-stamped-stack span { + position: absolute; + right: calc(var(--part, 0) * 10px); + bottom: calc(var(--part, 0) * 10px); + width: 58px; + height: 34px; + border: 1px solid rgba(56, 189, 248, 0.32); + border-radius: 12px; + background: + radial-gradient(circle at 50% 50%, transparent 0 8px, rgba(226, 232, 240, 0.55) 9px 10px, transparent 11px), + linear-gradient(135deg, rgba(226, 232, 240, 0.72), rgba(71, 85, 105, 0.24)); + box-shadow: 0 12px 26px rgba(0, 0, 0, 0.18); + animation: loginPartGlow 2.6s ease-in-out infinite; +} + +.login-stamped-stack span:nth-child(1) { + --part: 0; +} + +.login-stamped-stack span:nth-child(2) { + --part: 1; + animation-delay: 0.18s; +} + +.login-stamped-stack span:nth-child(3) { + --part: 2; + animation-delay: 0.36s; +} + +.login-flow-ribbon { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + padding: 0 14px 14px; +} + +.login-flow-chip { + display: grid; + grid-template-columns: 20px minmax(0, 1fr); + column-gap: 7px; + align-items: center; + min-height: 62px; + padding: 10px; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 16px; + background: rgba(15, 30, 48, 0.54); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.login-flow-chip .app-icon { + grid-row: span 2; + width: 20px; + height: 20px; + color: var(--login-forge-blue); +} + +.login-flow-chip span { + color: #eef8ff; + font-size: 13px; + font-weight: 900; +} + +.login-flow-chip small { + color: #89a8bd; + font-size: 10px; + font-weight: 800; +} + +@keyframes loginScanSweep { + 0%, + 100% { + transform: translateY(-52%); + opacity: 0.18; + } + 50% { + transform: translateY(52%); + opacity: 0.76; + } +} + +@keyframes loginBeamDrift { + from { + opacity: 0.36; + filter: blur(0.4px); + } + to { + opacity: 0.88; + filter: blur(1.1px); + } +} + +@keyframes loginOrbitGlow { + 0%, + 100% { + opacity: 0.32; + transform: scale(0.96); + } + 50% { + opacity: 0.82; + transform: scale(1.02); + } +} + +@keyframes loginButtonGlint { + 0%, + 48% { + transform: translateX(-120%); + } + 72%, + 100% { + transform: translateX(120%); + } +} + +@keyframes loginLivePulse { + 0%, + 100% { + transform: scale(0.92); + opacity: 0.68; + } + 50% { + transform: scale(1.12); + opacity: 1; + } +} + +@keyframes loginMetalTravel { + 0% { + left: -16%; + opacity: 0; + transform: translateY(0) scale(0.82); + } + 10%, + 72% { + opacity: 1; + } + 46% { + transform: translateY(-4px) scale(1); + } + 100% { + left: 98%; + opacity: 0; + transform: translateY(0) scale(0.86); + } +} + +@keyframes loginPressStamp { + 0%, + 58%, + 100% { + transform: translateX(-50%) translateY(0); + } + 70%, + 82% { + transform: translateX(-50%) translateY(30px); + } +} + +@keyframes loginCoilTurn { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@keyframes loginPartGlow { + 0%, + 100% { + opacity: 0.68; + transform: translateY(0); + } + 50% { + opacity: 1; + transform: translateY(-3px); + } +} + +@keyframes loginSparkBurst { + 0%, + 62% { + opacity: 0; + transform: translate(-50%, 0) scale(0.5); + } + 72% { + opacity: 1; + } + 100% { + opacity: 0; + transform: translate(var(--spark-x), var(--spark-y)) scale(0.08); + } +} + +@keyframes loginQualityScan { + 0%, + 100% { + opacity: 0.18; + transform: scaleX(0.4) skewX(-16deg); + } + 50% { + opacity: 0.78; + transform: scaleX(1) skewX(12deg); + } +} + +@media (max-width: 900px) { + .login-shell { + align-items: start; + padding: 16px; + overflow-y: auto; + } + + .login-cockpit { + grid-template-columns: minmax(0, 1fr); + gap: 16px; + } + + .login-command-deck { + grid-template-rows: auto; + min-height: auto; + padding: 22px; + } + + .login-forge-stage { + grid-template-rows: auto auto; + min-height: auto; + } + + .login-process-map { + grid-template-rows: minmax(230px, 1fr) auto; + min-height: 380px; + } + + .login-flow-ribbon { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .login-status-strip { + grid-template-columns: minmax(0, 1fr); + } + + .login-forge-visual { + display: none; + } + + .login-auth-card { + padding: 22px; + } +} + +@media (max-width: 560px) { + .login-shell { + padding: 10px; + } + + .login-command-deck, + .login-auth-card { + border-radius: 24px; + } + + .login-command-brand { + grid-template-columns: 44px minmax(0, 1fr); + } + + .login-command-brand .login-logo, + .login-shell .login-logo { + width: 44px; + height: 44px; + } + + .login-forge-stage { + padding: 16px; + } + + .login-process-map { + min-height: 330px; + } + + .login-press-showcase { + min-height: 200px; + } + + .login-coil { + left: 7%; + top: 42%; + width: 64px; + height: 64px; + } + + .login-feeder-line { + left: 18%; + right: 18%; + top: calc(62% - 10px); + height: 24px; + } + + .login-metal-strip { + width: 38px; + height: 19px; + } + + .login-press-machine { + top: 24%; + width: 96px; + height: 122px; + transform: translateX(-50%); + } + + .login-press-arm { + left: 14px; + right: 14px; + top: 3px; + width: auto; + height: 62px; + border-radius: 13px 13px 8px 8px; + } + + .login-press-hammer { + left: 50%; + top: 48px; + width: 34px; + height: 22px; + } + + .login-press-die { + left: 16px; + right: 16px; + bottom: 12px; + height: 11px; + } + + .login-spark { + top: 92px; + } + + .login-stamped-stack { + right: 5%; + top: 48%; + transform: scale(0.72); + } + + .login-stage-header { + align-items: flex-start; + } + + .login-stage-header div { + font-size: 12px; + } + + .login-flow-ribbon { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; + padding: 0 10px 10px; + } + + .login-flow-chip { + padding: 9px 7px; + } + + .login-flow-chip span { + font-size: 12px; + } +} + +@media (prefers-reduced-motion: reduce) { + .login-forge-grid::after, + .login-metal-beam, + .login-factory-orbit span, + .login-live-dot, + .login-metal-strip, + .login-press-hammer, + .login-coil, + .login-stamped-stack span, + .login-spark, + .login-submit-button::after { + animation: none !important; /* disables loginScanSweep, loginBeamDrift, loginOrbitGlow, loginMetalTravel, loginPressStamp and loginButtonGlint */ + } +} + +/* Login final direction: unified A+C industrial cockpit, no split hero/login cards. */ +body .login-industrial-stage { + --login-forge-blue: #38bdf8; + --login-steel: #2563eb; + --login-mint: #34d399; + --login-copper: #f59e0b; + --login-ink: #e7f5ff; + --login-muted: #91aec4; + position: relative; + display: grid; + min-height: 100vh; + place-items: center; + overflow: hidden; + padding: clamp(18px, 4vw, 44px); + color: var(--login-ink); + background: + radial-gradient(circle at 27% 45%, rgba(56, 189, 248, 0.22), transparent 30%), + radial-gradient(circle at 68% 26%, rgba(52, 211, 153, 0.14), transparent 28%), + radial-gradient(circle at 76% 78%, rgba(245, 158, 11, 0.08), transparent 24%), + linear-gradient(135deg, #030914 0%, #071627 46%, #0d2033 100%); + cursor: default; + isolation: isolate; +} + +body .login-industrial-stage::before { + position: absolute; + inset: 0; + z-index: -7; + background: + linear-gradient(rgba(148, 163, 184, 0.055) 1px, transparent 1px), + linear-gradient(90deg, rgba(148, 163, 184, 0.055) 1px, transparent 1px); + background-size: 58px 58px; + content: ""; + mask-image: radial-gradient(circle at 50% 46%, #000 0%, transparent 78%); +} + +body .login-industrial-stage::after { + position: absolute; + inset: 0; + z-index: -1; + background: + radial-gradient(circle at 50% 50%, transparent 0 46%, rgba(0, 0, 0, 0.38) 100%), + linear-gradient(180deg, rgba(2, 6, 12, 0.04), rgba(2, 6, 12, 0.44)); + content: ""; + pointer-events: none; +} + +body .login-kinetic-field { + position: absolute; + inset: 0; + z-index: -2; + overflow: hidden; +} + +body .login-depth-grid { + position: absolute; + left: 8%; + right: 6%; + bottom: 9%; + height: 42%; + border: 1px solid rgba(56, 189, 248, 0.12); + background: + linear-gradient(rgba(56, 189, 248, 0.1) 1px, transparent 1px), + linear-gradient(90deg, rgba(56, 189, 248, 0.1) 1px, transparent 1px); + background-size: 64px 34px; + opacity: 0.46; + transform: perspective(780px) rotateX(62deg); + transform-origin: bottom; + animation: loginFactoryDepth 8s linear infinite; +} + +body .login-scan-plane { + position: absolute; + width: min(48vw, 720px); + height: 2px; + border-radius: 999px; + background: linear-gradient(90deg, transparent, rgba(56, 189, 248, 0.88), rgba(245, 158, 11, 0.45), transparent); + box-shadow: 0 0 30px rgba(56, 189, 248, 0.24); + opacity: 0.74; + animation: loginBeamDrift 6.8s ease-in-out infinite alternate; +} + +body .login-scan-plane-a { + left: -7%; + top: 20%; + transform: rotate(-16deg); +} + +body .login-scan-plane-b { + right: -10%; + bottom: 18%; + animation-delay: -2s; + transform: rotate(13deg); +} + +body .login-data-thread { + position: absolute; + width: 190px; + height: 190px; + border: 1px solid rgba(56, 189, 248, 0.16); + clip-path: polygon(25% 6%, 75% 6%, 100% 50%, 75% 94%, 25% 94%, 0 50%); + background: + radial-gradient(circle at center, rgba(56, 189, 248, 0.12), transparent 62%), + repeating-linear-gradient(60deg, rgba(226, 232, 240, 0.08) 0 1px, transparent 1px 18px); + opacity: 0.65; + animation: loginHudFloat 7.5s ease-in-out infinite; +} + +body .login-data-thread-a { + left: 11%; + top: 14%; +} + +body .login-data-thread-b { + right: 8%; + top: 10%; + animation-delay: -2.6s; +} + +body .login-unified-cockpit { + position: relative; + z-index: 2; + display: grid; + grid-template-columns: minmax(300px, 0.82fr) minmax(380px, 430px); + grid-template-rows: 1fr auto; + gap: clamp(20px, 4vw, 64px); + align-items: center; + width: min(1240px, 100%); + min-height: min(760px, calc(100vh - 72px)); +} + +body .login-brand-panel { + align-self: start; + display: grid; + gap: 18px; + max-width: 420px; + padding-top: clamp(8px, 4vh, 48px); +} + +body .login-command-brand { + display: inline-grid; + grid-template-columns: 54px minmax(0, auto); + gap: 14px; + align-items: center; + width: fit-content; + padding: 10px 14px; + border: 1px solid rgba(174, 199, 224, 0.24); + border-radius: 20px; + background: + linear-gradient(145deg, rgba(255, 255, 255, 0.11), rgba(255, 255, 255, 0.035)), + rgba(9, 22, 36, 0.42); + box-shadow: + 0 28px 70px rgba(0, 0, 0, 0.18), + inset 0 1px 0 rgba(255, 255, 255, 0.12); + backdrop-filter: blur(18px); +} + +body .login-command-brand .login-logo, +body .login-shell .login-logo { + width: 54px; + height: 54px; + object-fit: contain; + filter: drop-shadow(0 14px 28px rgba(56, 189, 248, 0.34)); +} + +body .login-command-brand span, +body .login-console-chip { + color: #9fdfff; + font-family: var(--ui-font-display); + font-size: 12px; + font-weight: 900; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +body .login-command-brand strong { + display: block; + margin-top: 3px; + color: #f8fbff; + font-size: 18px; + letter-spacing: 0.02em; +} + +body .login-live-caption { + display: grid; + grid-template-columns: 14px minmax(0, 1fr); + gap: 8px 10px; + align-items: center; + width: fit-content; + max-width: 100%; + padding: 12px 14px; + border: 1px solid rgba(52, 211, 153, 0.2); + border-radius: 18px; + background: rgba(6, 18, 31, 0.46); + color: #dff5ff; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); + backdrop-filter: blur(14px); +} + +body .login-live-caption strong { + color: #f3fbff; + font-size: 14px; +} + +body .login-live-caption small { + grid-column: 2; + color: var(--login-muted); + font-size: 12px; + font-weight: 800; +} + +body .login-live-dot { + width: 10px; + height: 10px; + border-radius: 999px; + background: var(--login-mint); + box-shadow: 0 0 0 6px rgba(52, 211, 153, 0.12), 0 0 24px rgba(52, 211, 153, 0.7); + animation: loginLivePulse 1.8s ease-in-out infinite; +} + +body .login-holo-press { + position: absolute; + left: 46%; + top: 50%; + z-index: -1; + width: min(54vw, 700px); + height: min(54vw, 700px); + min-width: 430px; + min-height: 430px; + transform: translate(-50%, -50%); + pointer-events: none; +} + +body .login-holo-ring { + position: absolute; + inset: 16%; + border: 1px solid rgba(56, 189, 248, 0.13); + border-radius: 999px; + box-shadow: inset 0 0 46px rgba(56, 189, 248, 0.05); + animation: loginOrbitGlow 4.2s ease-in-out infinite; +} + +body .login-holo-ring-b { + inset: 26%; + animation-delay: -1.6s; +} + +body .login-coil { + position: absolute; + left: 8%; + top: 48%; + width: 128px; + height: 128px; + border: 1px solid rgba(125, 211, 252, 0.28); + border-radius: 999px; + background: + radial-gradient(circle at center, rgba(3, 12, 22, 0.92) 0 23px, transparent 24px), + conic-gradient(from 20deg, rgba(226, 232, 240, 0.9), rgba(71, 85, 105, 0.38), rgba(226, 232, 240, 0.84), rgba(15, 23, 42, 0.5), rgba(226, 232, 240, 0.9)); + box-shadow: + 0 0 54px rgba(56, 189, 248, 0.16), + inset 0 0 0 12px rgba(15, 23, 42, 0.28); + animation: loginCoilTurn 8s linear infinite; +} + +body .login-coil span { + position: absolute; + inset: calc(var(--coil-ring, 0) * 17px); + border: 1px solid rgba(226, 232, 240, 0.28); + border-radius: 999px; +} + +body .login-coil span:nth-child(1) { + --coil-ring: 1; +} + +body .login-coil span:nth-child(2) { + --coil-ring: 2; +} + +body .login-coil span:nth-child(3) { + --coil-ring: 3; +} + +body .login-feeder-line { + position: absolute; + left: 18%; + right: 20%; + top: calc(62% - 15px); + height: 32px; + overflow: hidden; +} + +body .login-feeder-line::before { + position: absolute; + inset: 10px 0; + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 999px; + background: + repeating-linear-gradient(90deg, rgba(226, 232, 240, 0.22) 0 13px, rgba(15, 23, 42, 0.34) 13px 25px), + linear-gradient(90deg, rgba(37, 99, 235, 0.08), rgba(52, 211, 153, 0.16)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.12), + 0 12px 28px rgba(0, 0, 0, 0.24); + content: ""; +} + +body .login-metal-strip { + position: absolute; + left: -16%; + top: 3px; + width: 56px; + height: 26px; + border: 1px solid rgba(226, 232, 240, 0.36); + border-radius: 9px; + background: + linear-gradient(135deg, rgba(248, 250, 252, 0.86), rgba(148, 163, 184, 0.42) 42%, rgba(15, 23, 42, 0.2)), + repeating-linear-gradient(90deg, transparent 0 7px, rgba(255, 255, 255, 0.24) 7px 8px); + box-shadow: + 0 0 22px rgba(56, 189, 248, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.58); + opacity: 0; + animation: loginMetalTravel 4.6s linear infinite; +} + +body .login-metal-strip-b { + animation-delay: 1.5s; +} + +body .login-metal-strip-c { + animation-delay: 3s; +} + +body .login-press-machine { + position: absolute; + left: 50%; + top: 30%; + width: 170px; + height: 206px; + transform: translateX(-50%); +} + +body .login-press-arm { + position: absolute; + left: 20px; + right: 20px; + top: 4px; + height: 112px; + border: 1px solid rgba(125, 211, 252, 0.3); + border-radius: 32px 32px 16px 16px; + background: + linear-gradient(180deg, rgba(96, 165, 250, 0.34), rgba(15, 23, 42, 0.84)), + repeating-linear-gradient(90deg, transparent 0 12px, rgba(255, 255, 255, 0.08) 12px 13px); + box-shadow: + 0 22px 50px rgba(56, 189, 248, 0.12), + inset 0 1px 0 rgba(255, 255, 255, 0.14); +} + +body .login-press-hammer { + position: absolute; + left: 50%; + top: 88px; + width: 58px; + height: 36px; + border: 1px solid rgba(226, 232, 240, 0.4); + border-radius: 10px; + background: linear-gradient(180deg, #dbeafe, #64748b 56%, #1e293b); + box-shadow: + 0 12px 28px rgba(56, 189, 248, 0.18), + inset 0 1px 0 rgba(255, 255, 255, 0.58); + transform: translateX(-50%); + animation: loginPressStamp 1.45s cubic-bezier(0.76, 0, 0.24, 1) infinite; +} + +body .login-press-die { + position: absolute; + left: 29px; + right: 29px; + bottom: 22px; + height: 17px; + border: 1px solid rgba(245, 158, 11, 0.28); + border-radius: 999px; + background: linear-gradient(90deg, rgba(245, 158, 11, 0.46), rgba(56, 189, 248, 0.38)); + box-shadow: 0 0 26px rgba(245, 158, 11, 0.2); +} + +body .login-spark { + position: absolute; + left: 50%; + top: 158px; + width: 5px; + height: 5px; + border-radius: 999px; + background: #fbbf24; + box-shadow: 0 0 16px rgba(251, 191, 36, 0.82); + opacity: 0; + animation: loginSparkBurst 1.45s ease-out infinite; +} + +body .login-spark-a { + --spark-x: -50px; + --spark-y: 20px; +} + +body .login-spark-b { + --spark-x: 40px; + --spark-y: 18px; + animation-delay: 0.05s; +} + +body .login-spark-c { + --spark-x: 4px; + --spark-y: 36px; + animation-delay: 0.1s; +} + +body .login-stamped-stack { + position: absolute; + right: 9%; + top: 51%; + width: 100px; + height: 96px; +} + +body .login-stamped-stack span { + position: absolute; + right: calc(var(--part, 0) * 11px); + bottom: calc(var(--part, 0) * 10px); + width: 66px; + height: 38px; + border: 1px solid rgba(56, 189, 248, 0.32); + border-radius: 13px; + background: + radial-gradient(circle at 50% 50%, transparent 0 9px, rgba(226, 232, 240, 0.55) 10px 11px, transparent 12px), + linear-gradient(135deg, rgba(226, 232, 240, 0.72), rgba(71, 85, 105, 0.24)); + box-shadow: 0 12px 26px rgba(0, 0, 0, 0.18); + animation: loginPartGlow 2.6s ease-in-out infinite; +} + +body .login-stamped-stack span:nth-child(1) { + --part: 0; +} + +body .login-stamped-stack span:nth-child(2) { + --part: 1; + animation-delay: 0.18s; +} + +body .login-stamped-stack span:nth-child(3) { + --part: 2; + animation-delay: 0.36s; +} + +body .login-control-console { + position: relative; + align-self: center; + padding: clamp(24px, 3vw, 36px); + overflow: hidden; + border: 1px solid rgba(174, 199, 224, 0.25); + border-radius: 32px; + background: + linear-gradient(145deg, rgba(10, 24, 40, 0.76), rgba(7, 18, 31, 0.54)), + rgba(9, 20, 34, 0.56); + box-shadow: + 0 38px 100px rgba(0, 0, 0, 0.36), + inset 0 1px 0 rgba(255, 255, 255, 0.09); + backdrop-filter: blur(25px); +} + +body .login-control-console::before { + position: absolute; + inset: 0; + border-radius: inherit; + background: + linear-gradient(120deg, rgba(255, 255, 255, 0.14), transparent 34%), + radial-gradient(circle at 18% 0%, rgba(56, 189, 248, 0.18), transparent 28%); + content: ""; + pointer-events: none; +} + +body .login-panel-head, +body .login-form, +body .login-security-foot { + position: relative; + z-index: 1; +} + +body .login-panel-head { + display: grid; + gap: 10px; + margin-bottom: 24px; + text-align: left; +} + +body .login-panel-head h2 { + margin: 0; + color: #f8fbff; + font-family: var(--ui-font-display); + font-size: clamp(30px, 4vw, 40px); + line-height: 1.1; + letter-spacing: 0.03em; +} + +body .login-panel-head p { + margin: 0; + color: var(--login-muted); + font-size: 13px; + line-height: 1.7; +} + +body .login-form { + display: grid; + gap: 16px; + margin: 0; +} + +body .login-input-shell { + display: grid; + gap: 8px; +} + +body .login-input-shell > span { + color: #b9cde0; + font-size: 13px; + font-weight: 800; + letter-spacing: 0.04em; +} + +body .login-input-frame { + display: grid; + grid-template-columns: 22px minmax(0, 1fr); + align-items: center; + gap: 10px; + min-height: 54px; + padding: 0 14px; + border: 1px solid rgba(148, 163, 184, 0.26); + border-radius: 17px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.045)), + rgba(7, 17, 29, 0.62); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); + transition: + border-color var(--ui-transition-fast), + box-shadow var(--ui-transition-fast), + background var(--ui-transition-fast); +} + +body .login-input-frame:focus-within { + border-color: rgba(56, 189, 248, 0.72); + background: rgba(10, 24, 40, 0.82); + box-shadow: + 0 0 0 4px rgba(56, 189, 248, 0.12), + inset 0 1px 0 rgba(255, 255, 255, 0.12); +} + +body .login-input-frame .app-icon { + width: 21px; + height: 21px; + color: var(--login-forge-blue); +} + +body .login-input-frame input { + width: 100%; + min-height: 50px; + border: 0; + outline: 0; + color: #f8fbff; + background: transparent; + font-size: 15px; + font-weight: 700; +} + +body .login-input-frame input::placeholder { + color: rgba(184, 204, 220, 0.56); +} + +body .login-actions { + display: grid; + gap: 10px; + margin-top: 4px; +} + +body .login-submit-button.primary-button { + position: relative; + justify-content: space-between; + width: 100%; + min-height: 54px; + overflow: hidden; + border: 1px solid rgba(56, 189, 248, 0.46); + border-radius: 17px; + background: + linear-gradient(90deg, #1d4ed8, #0891b2 52%, #0f766e); + box-shadow: + 0 20px 44px rgba(14, 165, 233, 0.24), + inset 0 1px 0 rgba(255, 255, 255, 0.24); + font-family: var(--ui-font-display); + font-size: 15px; + letter-spacing: 0.08em; +} + +body .login-submit-button::after { + position: absolute; + inset: 0; + background: linear-gradient(105deg, transparent 0%, rgba(255, 255, 255, 0.34) 42%, transparent 68%); + content: ""; + transform: translateX(-120%); + animation: loginButtonGlint 3.4s ease-in-out infinite; +} + +body .login-submit-button span, +body .login-submit-button .app-icon { + position: relative; + z-index: 1; +} + +body .login-submit-button .app-icon { + width: 20px; + height: 20px; +} + +body .login-control-console .form-message, +body .login-control-console .form-error { + margin: 0; + padding: 10px 12px; + border-radius: 13px; + font-size: 13px; + font-weight: 800; +} + +body .login-control-console .form-message { + border: 1px solid rgba(52, 211, 153, 0.24); + color: #bbf7d0; + background: rgba(22, 101, 52, 0.24); +} + +body .login-control-console .form-error { + border: 1px solid rgba(248, 113, 113, 0.24); + color: #fecaca; + background: rgba(127, 29, 29, 0.22); +} + +body .login-security-foot { + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + gap: 10px; + align-items: center; + margin-top: 18px; + color: #8ca7bd; + font-size: 12px; + line-height: 1.5; +} + +body .login-security-foot span { + width: 28px; + height: 28px; + border: 1px solid rgba(52, 211, 153, 0.34); + border-radius: 999px; + background: + radial-gradient(circle at center, rgba(52, 211, 153, 0.9) 0 3px, transparent 4px), + rgba(52, 211, 153, 0.1); + box-shadow: 0 0 24px rgba(52, 211, 153, 0.22); +} + +body .login-security-foot p { + margin: 0; +} + +body .login-flow-ribbon { + grid-column: 1 / -1; + align-self: end; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + width: min(760px, 100%); +} + +body .login-flow-chip { + display: grid; + grid-template-columns: 21px minmax(0, 1fr); + column-gap: 8px; + align-items: center; + min-height: 64px; + padding: 11px 12px; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 18px; + background: + linear-gradient(150deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.028)), + rgba(10, 24, 40, 0.48); + box-shadow: + 0 18px 38px rgba(0, 0, 0, 0.18), + inset 0 1px 0 rgba(255, 255, 255, 0.08); + backdrop-filter: blur(15px); +} + +body .login-flow-chip .app-icon { + grid-row: span 2; + width: 20px; + height: 20px; + color: var(--login-forge-blue); +} + +body .login-flow-chip span { + color: #eef8ff; + font-size: 13px; + font-weight: 900; +} + +body .login-flow-chip small { + color: #89a8bd; + font-size: 10px; + font-weight: 800; +} + +@keyframes loginFactoryDepth { + from { + background-position: 0 0, 0 0; + } + to { + background-position: 0 68px, 128px 0; + } +} + +@keyframes loginHudFloat { + 0%, + 100% { + opacity: 0.45; + transform: translateY(0) rotate(0deg); + } + 50% { + opacity: 0.78; + transform: translateY(-12px) rotate(4deg); + } +} + +@media (max-width: 900px) { + body .login-industrial-stage { + align-items: start; + min-height: 100vh; + padding: 14px; + overflow-y: auto; + } + + body .login-unified-cockpit { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto auto auto; + gap: 16px; + min-height: auto; + } + + body .login-brand-panel { + align-self: auto; + padding-top: 0; + } + + body .login-holo-press { + position: relative; + left: auto; + top: auto; + grid-row: 2; + width: min(100%, 460px); + height: 320px; + min-width: 0; + min-height: 0; + margin: -20px auto -8px; + transform: none; + } + + body .login-control-console { + grid-row: 3; + padding: 22px; + } + + body .login-flow-ribbon { + grid-row: 4; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 560px) { + body .login-industrial-stage { + padding: 10px; + } + + body .login-command-brand { + grid-template-columns: 44px minmax(0, 1fr); + } + + body .login-command-brand .login-logo, + body .login-shell .login-logo { + width: 44px; + height: 44px; + } + + body .login-holo-press { + height: 260px; + margin-top: -8px; + } + + body .login-coil { + left: 4%; + top: 48%; + width: 72px; + height: 72px; + } + + body .login-coil span { + inset: calc(var(--coil-ring, 0) * 10px); + } + + body .login-feeder-line { + left: 16%; + right: 16%; + top: calc(62% - 12px); + } + + body .login-metal-strip { + width: 40px; + height: 20px; + } + + body .login-press-machine { + top: 28%; + width: 108px; + height: 136px; + } + + body .login-press-arm { + left: 14px; + right: 14px; + height: 72px; + border-radius: 18px 18px 11px 11px; + } + + body .login-press-hammer { + top: 54px; + width: 36px; + height: 24px; + } + + body .login-press-die { + left: 18px; + right: 18px; + bottom: 14px; + height: 12px; + } + + body .login-spark { + top: 104px; + } + + body .login-stamped-stack { + right: 1%; + top: 51%; + transform: scale(0.72); + } + + body .login-flow-ribbon { + gap: 7px; + } + + body .login-flow-chip { + min-height: 58px; + padding: 9px 8px; + } + + body .login-flow-chip span { + font-size: 12px; + } +} + +@media (prefers-reduced-motion: reduce) { + body .login-industrial-stage::before, + body .login-depth-grid, + body .login-scan-plane, + body .login-data-thread, + body .login-holo-ring, + body .login-live-dot, + body .login-metal-strip, + body .login-press-hammer, + body .login-coil, + body .login-stamped-stack span, + body .login-spark, + body .login-submit-button::after { + animation: none !important; + } +} + +/* Login page v3: separated forge line + access console */ +body .login-industrial-stage.login-production-layout { + --login-ink: #eaf6ff; + --login-muted: #8eabc2; + --login-forge-blue: #38bdf8; + --login-forge-cyan: #22d3ee; + --login-forge-green: #34d399; + --login-hot-metal: #f59e0b; + --login-steel-line: rgba(148, 163, 184, 0.22); + isolation: isolate; + display: grid; + place-items: center; + min-height: 100vh; + padding: clamp(22px, 4vw, 56px); + overflow: hidden; + color: var(--login-ink); + background: + radial-gradient(circle at 17% 24%, rgba(56, 189, 248, 0.18), transparent 26%), + radial-gradient(circle at 73% 26%, rgba(52, 211, 153, 0.14), transparent 27%), + linear-gradient(135deg, #050914 0%, #091827 48%, #07111d 100%); +} + +body .login-industrial-stage.login-production-layout::before { + position: absolute; + inset: -20%; + z-index: -3; + background: + linear-gradient(115deg, transparent 0 36%, rgba(56, 189, 248, 0.1) 37% 38%, transparent 39% 100%), + repeating-linear-gradient(90deg, rgba(148, 163, 184, 0.035) 0 1px, transparent 1px 92px); + content: ""; + transform: rotate(-7deg); + animation: loginFactoryDepth 18s linear infinite; +} + +body .login-industrial-stage.login-production-layout::after { + position: absolute; + inset: 0; + z-index: -1; + background: + radial-gradient(circle at 50% 120%, rgba(56, 189, 248, 0.16), transparent 42%), + linear-gradient(180deg, rgba(2, 6, 23, 0.05), rgba(2, 6, 23, 0.72)); + content: ""; + pointer-events: none; +} + +body .login-production-layout .login-kinetic-field { + position: absolute; + inset: 0; + z-index: -2; + overflow: hidden; + pointer-events: none; +} + +body .login-production-layout .login-depth-grid { + position: absolute; + inset: 10% 5% 8%; + background: + linear-gradient(rgba(56, 189, 248, 0.08) 1px, transparent 1px), + linear-gradient(90deg, rgba(56, 189, 248, 0.08) 1px, transparent 1px); + background-size: 58px 58px; + mask-image: radial-gradient(ellipse at 42% 48%, #000 0 42%, transparent 72%); + opacity: 0.58; + transform: perspective(760px) rotateX(62deg) translateY(70px); + transform-origin: center bottom; + animation: loginFactoryDepth 14s linear infinite; +} + +body .login-production-layout .login-scan-plane { + position: absolute; + width: 58vw; + height: 1px; + border-radius: 999px; + background: linear-gradient(90deg, transparent, rgba(56, 189, 248, 0.8), transparent); + opacity: 0.5; + filter: drop-shadow(0 0 14px rgba(56, 189, 248, 0.45)); + animation: loginScanSweep 7s ease-in-out infinite; +} + +body .login-production-layout .login-scan-plane-a { + left: 5%; + top: 29%; +} + +body .login-production-layout .login-scan-plane-b { + right: 8%; + top: 70%; + animation-delay: -3.2s; +} + +body .login-production-layout .login-data-thread { + position: absolute; + height: 2px; + border-radius: 999px; + background: + linear-gradient(90deg, transparent, rgba(34, 211, 238, 0.72), transparent), + repeating-linear-gradient(90deg, transparent 0 16px, rgba(255, 255, 255, 0.42) 16px 21px); + opacity: 0.34; + transform-origin: left center; + animation: loginSteelPulse 5.8s ease-in-out infinite; +} + +body .login-production-layout .login-data-thread-a { + left: 12%; + top: 17%; + width: 36%; + transform: rotate(5deg); +} + +body .login-production-layout .login-data-thread-b { + right: 10%; + top: 83%; + width: 30%; + transform: rotate(-4deg); + animation-delay: -2s; +} + +body .login-production-layout .login-data-thread-c { + left: 42%; + top: 50%; + width: 26%; + transform: rotate(-12deg); + animation-delay: -3.4s; +} + +body .login-production-layout .login-heat-column { + position: absolute; + bottom: -18%; + width: 24vw; + height: 68vh; + border-radius: 999px 999px 0 0; + background: + radial-gradient(ellipse at 50% 18%, rgba(56, 189, 248, 0.18), transparent 42%), + linear-gradient(180deg, transparent, rgba(34, 211, 238, 0.1), transparent); + filter: blur(18px); + opacity: 0.2; + transform: translateY(16%); + animation: loginHeatRise 7.2s ease-in-out infinite; +} + +body .login-production-layout .login-heat-column-a { + left: 18%; +} + +body .login-production-layout .login-heat-column-b { + left: 42%; + width: 30vw; + animation-delay: -2.4s; +} + +body .login-production-layout .login-heat-column-c { + right: 8%; + animation-delay: -4.6s; +} + +body .login-production-cockpit { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: minmax(570px, 1fr) minmax(450px, 520px); + gap: clamp(12px, 2.5vw, 34px); + align-items: center; + width: min(1360px, 100%); + min-height: min(760px, calc(100vh - 64px)); +} + +body .login-line-area { + position: relative; + display: grid; + grid-template-rows: auto minmax(390px, 1fr) auto; + min-width: 0; + min-height: 630px; + padding: clamp(6px, 1.8vw, 18px) 0; +} + +body .login-line-brand { + position: relative; + z-index: 2; + display: inline-grid; + grid-template-columns: 48px minmax(0, max-content); + gap: 12px; + align-items: center; + justify-self: start; + max-width: min(280px, 100%); + padding: 10px 15px 10px 10px; + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 20px; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.035)), + rgba(8, 20, 34, 0.46); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.1), + 0 20px 46px rgba(2, 6, 23, 0.28); + backdrop-filter: blur(18px); +} + +body .login-line-brand .login-logo, +body .login-production-layout .login-logo { + width: 48px; + height: 48px; + border-radius: 16px; + object-fit: contain; + background: rgba(255, 255, 255, 0.94); + box-shadow: 0 12px 32px rgba(2, 6, 23, 0.28); +} + +body .login-line-brand span { + display: block; + color: #f8fbff; + font-family: var(--ui-font-display); + font-size: 20px; + font-weight: 950; + letter-spacing: 0.12em; +} + +body .login-line-brand strong { + display: block; + margin-top: 2px; + color: var(--login-muted); + font-family: var(--ui-font-display); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +body .login-line-status { + position: absolute; + top: clamp(78px, 10vh, 118px); + left: clamp(8px, 3vw, 56px); + z-index: 2; + display: grid; + grid-template-columns: 12px minmax(0, max-content); + column-gap: 10px; + row-gap: 3px; + align-items: center; + max-width: min(370px, calc(100% - 40px)); + padding: 10px 13px; + border: 1px solid rgba(56, 189, 248, 0.18); + border-radius: 999px; + background: rgba(6, 18, 31, 0.44); + color: var(--login-muted); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 16px 44px rgba(2, 6, 23, 0.18); + backdrop-filter: blur(16px); +} + +body .login-line-status .login-live-dot { + grid-row: span 2; + width: 10px; + height: 10px; + border-radius: 999px; + background: var(--login-forge-green); + box-shadow: 0 0 22px rgba(52, 211, 153, 0.78); + animation: loginLivePulse 1.8s ease-in-out infinite; +} + +body .login-line-status strong { + color: #effaff; + font-size: 12px; + font-weight: 900; + letter-spacing: 0.08em; +} + +body .login-line-status small { + color: rgba(184, 204, 220, 0.76); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.04em; +} + +body .login-production-line { + position: relative; + align-self: center; + width: min(850px, 100%); + height: clamp(390px, 49vw, 560px); + margin: 34px 0 20px; + overflow: visible; +} + +body .login-line-floor { + position: absolute; + left: 0; + right: 0; + bottom: 58px; + height: 98px; + border: 1px solid rgba(148, 163, 184, 0.14); + border-radius: 50% 50% 26px 26px / 36% 36% 24px 24px; + background: + linear-gradient(90deg, transparent, rgba(56, 189, 248, 0.08), transparent), + repeating-linear-gradient(90deg, rgba(148, 163, 184, 0.12) 0 1px, transparent 1px 38px); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 30px 80px rgba(0, 0, 0, 0.2); + transform: perspective(520px) rotateX(62deg); + transform-origin: center bottom; +} + +body .login-line-floor::after { + position: absolute; + inset: 12px 11%; + border-radius: 999px; + background: linear-gradient(90deg, transparent, rgba(56, 189, 248, 0.28), transparent); + content: ""; + opacity: 0; + animation: loginForgeBeam 3.7s ease-in-out infinite; +} + +body .login-forge-beam { + position: absolute; + z-index: 1; + width: 2px; + height: 72%; + border-radius: 999px; + background: linear-gradient(180deg, transparent, rgba(56, 189, 248, 0.92), transparent); + box-shadow: + 0 0 18px rgba(56, 189, 248, 0.52), + 0 0 42px rgba(56, 189, 248, 0.24); + opacity: 0; + transform: skewX(-13deg); + animation: loginForgeBeam 3.2s ease-in-out infinite; + pointer-events: none; +} + +body .login-forge-beam-a { + left: 39%; + top: 12%; +} + +body .login-forge-beam-b { + left: 67%; + top: 20%; + height: 54%; + animation-delay: -1.5s; +} + +body .login-production-line .login-coil { + position: absolute; + left: 1%; + top: 45%; + width: 136px; + height: 136px; + border: 1px solid rgba(125, 211, 252, 0.34); + border-radius: 999px; + background: + radial-gradient(circle at center, rgba(3, 12, 22, 0.94) 0 24px, transparent 25px), + conic-gradient(from 20deg, rgba(241, 245, 249, 0.9), rgba(71, 85, 105, 0.38), rgba(241, 245, 249, 0.84), rgba(15, 23, 42, 0.5), rgba(241, 245, 249, 0.9)); + box-shadow: + 0 0 58px rgba(56, 189, 248, 0.18), + inset 0 0 0 13px rgba(15, 23, 42, 0.26); + animation: loginCoilTurn 8s linear infinite; +} + +body .login-production-line .login-coil::before { + position: absolute; + inset: -18px; + border: 1px dashed rgba(56, 189, 248, 0.14); + border-radius: inherit; + content: ""; +} + +body .login-production-line .login-coil span { + position: absolute; + inset: calc(var(--coil-ring, 0) * 18px); + border: 1px solid rgba(226, 232, 240, 0.28); + border-radius: 999px; +} + +body .login-production-line .login-coil span:nth-child(1) { + --coil-ring: 1; +} + +body .login-production-line .login-coil span:nth-child(2) { + --coil-ring: 2; +} + +body .login-production-line .login-coil span:nth-child(3) { + --coil-ring: 3; +} + +body .login-production-line .login-feeder-line { + position: absolute; + left: 13%; + right: 13%; + top: calc(61% - 16px); + height: 36px; + overflow: hidden; +} + +body .login-production-line .login-feeder-line::before { + position: absolute; + inset: 10px 0; + border: 1px solid rgba(148, 163, 184, 0.23); + border-radius: 999px; + background: + repeating-linear-gradient(90deg, rgba(226, 232, 240, 0.2) 0 14px, rgba(15, 23, 42, 0.38) 14px 27px), + linear-gradient(90deg, rgba(37, 99, 235, 0.1), rgba(52, 211, 153, 0.17)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.12), + 0 12px 28px rgba(0, 0, 0, 0.24); + content: ""; + animation: loginConveyorRoll 1.2s linear infinite; +} + +body .login-production-line .login-feeder-line::after { + position: absolute; + inset: 12px 10%; + border-radius: 999px; + background: linear-gradient(90deg, transparent, rgba(52, 211, 153, 0.28), transparent); + content: ""; + opacity: 0.7; + transform: translateX(-40%); + animation: loginConveyorSignal 2.4s ease-in-out infinite; +} + +body .login-production-line .login-metal-strip { + position: absolute; + left: -16%; + top: 3px; + width: 58px; + height: 28px; + border: 1px solid rgba(226, 232, 240, 0.38); + border-radius: 9px; + background: + linear-gradient(135deg, rgba(248, 250, 252, 0.88), rgba(148, 163, 184, 0.44) 42%, rgba(15, 23, 42, 0.2)), + repeating-linear-gradient(90deg, transparent 0 7px, rgba(255, 255, 255, 0.24) 7px 8px); + box-shadow: + 0 0 22px rgba(56, 189, 248, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.58); + opacity: 0; + animation: loginMetalTravel 3.7s linear infinite; +} + +body .login-production-line .login-metal-strip-b { + animation-delay: 1.23s; +} + +body .login-production-line .login-metal-strip-c { + animation-delay: 2.46s; +} + +body .login-line-machine { + position: absolute; + left: 45%; + top: 21%; + width: 230px; + height: 275px; + transform: translateX(-50%); +} + +body .login-line-machine::before { + position: absolute; + inset: 6px 0 auto; + height: 232px; + border: 1px solid rgba(56, 189, 248, 0.16); + border-radius: 34px; + background: + linear-gradient(145deg, rgba(56, 189, 248, 0.08), transparent 36%), + rgba(8, 20, 34, 0.26); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 28px 74px rgba(2, 6, 23, 0.28); + content: ""; +} + +body .login-production-line .login-press-machine { + position: absolute; + left: 50%; + top: 22px; + width: 174px; + height: 210px; + transform: translateX(-50%); +} + +body .login-production-line .login-press-aura { + position: absolute; + left: 50%; + top: 122px; + width: 116px; + height: 54px; + border-radius: 50%; + background: + radial-gradient(ellipse at center, rgba(245, 158, 11, 0.52), rgba(56, 189, 248, 0.18) 45%, transparent 72%); + filter: blur(3px); + opacity: 0; + transform: translateX(-50%) scale(0.7); + animation: loginPressAura 1.45s cubic-bezier(0.76, 0, 0.24, 1) infinite; +} + +body .login-production-line .login-press-arm { + position: absolute; + left: 20px; + right: 20px; + top: 4px; + height: 112px; + border: 1px solid rgba(125, 211, 252, 0.3); + border-radius: 32px 32px 16px 16px; + background: + linear-gradient(180deg, rgba(96, 165, 250, 0.34), rgba(15, 23, 42, 0.84)), + repeating-linear-gradient(90deg, transparent 0 12px, rgba(255, 255, 255, 0.08) 12px 13px); + box-shadow: + 0 22px 50px rgba(56, 189, 248, 0.12), + inset 0 1px 0 rgba(255, 255, 255, 0.14); +} + +body .login-production-line .login-press-hammer { + position: absolute; + left: 50%; + top: 88px; + width: 58px; + height: 36px; + border: 1px solid rgba(226, 232, 240, 0.42); + border-radius: 10px; + background: linear-gradient(180deg, #dbeafe, #64748b 56%, #1e293b); + box-shadow: + 0 12px 28px rgba(56, 189, 248, 0.18), + inset 0 1px 0 rgba(255, 255, 255, 0.58); + transform: translateX(-50%); + animation: loginPressStamp 1.45s cubic-bezier(0.76, 0, 0.24, 1) infinite; +} + +body .login-production-line .login-press-die { + position: absolute; + left: 29px; + right: 29px; + bottom: 22px; + height: 17px; + border: 1px solid rgba(245, 158, 11, 0.3); + border-radius: 999px; + background: linear-gradient(90deg, rgba(245, 158, 11, 0.48), rgba(56, 189, 248, 0.4)); + box-shadow: 0 0 26px rgba(245, 158, 11, 0.2); +} + +body .login-production-line .login-spark { + position: absolute; + left: 50%; + top: 158px; + width: 5px; + height: 5px; + border-radius: 999px; + background: #fbbf24; + box-shadow: 0 0 16px rgba(251, 191, 36, 0.82); + opacity: 0; + animation: loginSparkBurst 1.45s ease-out infinite; +} + +body .login-production-line .login-spark-a { + --spark-x: -50px; + --spark-y: 20px; +} + +body .login-production-line .login-spark-b { + --spark-x: 40px; + --spark-y: 18px; + animation-delay: 0.05s; +} + +body .login-production-line .login-spark-c { + --spark-x: 4px; + --spark-y: 36px; + animation-delay: 0.1s; +} + +body .login-inspection-gate { + position: absolute; + right: 20%; + top: 40%; + width: 76px; + height: 145px; + border: 1px solid rgba(34, 211, 238, 0.24); + border-radius: 22px; + background: + linear-gradient(180deg, rgba(34, 211, 238, 0.12), transparent), + rgba(8, 20, 34, 0.3); + box-shadow: + 0 0 44px rgba(34, 211, 238, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +body .login-inspection-gate::before { + position: absolute; + left: 13px; + right: 13px; + top: 18px; + height: 4px; + border-radius: 999px; + background: var(--login-forge-green); + box-shadow: 0 0 22px rgba(52, 211, 153, 0.62); + content: ""; + animation: loginSteelPulse 2.5s ease-in-out infinite; +} + +body .login-inspection-gate span { + position: absolute; + left: 16px; + right: 16px; + height: 1px; + background: rgba(56, 189, 248, 0.45); + box-shadow: 0 0 16px rgba(56, 189, 248, 0.5); + animation: loginScanSweep 2.4s ease-in-out infinite; +} + +body .login-inspection-gate span:first-child { + top: 54px; +} + +body .login-inspection-gate span:last-child { + top: 86px; + animation-delay: -1.1s; +} + +body .login-production-line .login-stamped-stack { + position: absolute; + right: 2%; + top: 50%; + width: 116px; + height: 108px; +} + +body .login-production-line .login-stamped-stack span { + position: absolute; + right: calc(var(--part, 0) * 12px); + bottom: calc(var(--part, 0) * 10px); + width: 72px; + height: 42px; + border: 1px solid rgba(56, 189, 248, 0.32); + border-radius: 13px; + background: + radial-gradient(circle at 50% 50%, transparent 0 9px, rgba(226, 232, 240, 0.55) 10px 11px, transparent 12px), + linear-gradient(135deg, rgba(226, 232, 240, 0.74), rgba(71, 85, 105, 0.26)); + box-shadow: 0 12px 26px rgba(0, 0, 0, 0.2); + animation: loginPartGlow 2.6s ease-in-out infinite; +} + +body .login-production-line .login-stamped-stack span:nth-child(1) { + --part: 0; +} + +body .login-production-line .login-stamped-stack span:nth-child(2) { + --part: 1; + animation-delay: 0.18s; +} + +body .login-production-line .login-stamped-stack span:nth-child(3) { + --part: 2; + animation-delay: 0.36s; +} + +body .login-part-runner { + position: absolute; + left: 50%; + right: 5%; + top: calc(61% - 19px); + height: 42px; + overflow: hidden; + pointer-events: none; +} + +body .login-part-runner span { + position: absolute; + left: 0; + top: 4px; + width: 54px; + height: 30px; + border: 1px solid rgba(125, 211, 252, 0.38); + border-radius: 12px; + background: + radial-gradient(circle at 50% 50%, transparent 0 7px, rgba(226, 232, 240, 0.62) 8px 9px, transparent 10px), + linear-gradient(135deg, rgba(226, 232, 240, 0.72), rgba(71, 85, 105, 0.22)); + box-shadow: + 0 0 24px rgba(56, 189, 248, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.32); + opacity: 0; + animation: loginPartRun 3.2s linear infinite; +} + +body .login-part-runner span:nth-child(2) { + animation-delay: 1.06s; +} + +body .login-part-runner span:nth-child(3) { + animation-delay: 2.12s; +} + +body .login-console-link { + position: absolute; + right: -38px; + top: 60%; + width: min(240px, 30%); + height: 32px; + border-top: 1px solid rgba(56, 189, 248, 0.35); + border-bottom: 1px solid rgba(56, 189, 248, 0.18); + border-radius: 999px; + background: + linear-gradient(90deg, rgba(56, 189, 248, 0.02), rgba(56, 189, 248, 0.2), rgba(52, 211, 153, 0.08)), + repeating-linear-gradient(90deg, transparent 0 18px, rgba(255, 255, 255, 0.26) 18px 22px); + box-shadow: + 0 0 30px rgba(56, 189, 248, 0.16), + inset 0 1px 0 rgba(255, 255, 255, 0.08); + animation: loginSteelPulse 3.2s ease-in-out infinite; +} + +body .login-console-link::after { + position: absolute; + right: -5px; + top: 50%; + width: 10px; + height: 10px; + border-radius: 999px; + background: var(--login-forge-blue); + box-shadow: 0 0 24px rgba(56, 189, 248, 0.72); + content: ""; + transform: translateY(-50%); +} + +body .login-console-dock { + position: relative; + align-self: center; + display: grid; + align-items: center; + min-height: 560px; + padding: 56px 0 58px 48px; + overflow: visible; +} + +body .login-console-dock::before { + position: absolute; + left: -28%; + right: -4%; + top: 14%; + bottom: 10%; + border: 1px solid rgba(56, 189, 248, 0.12); + border-left-color: rgba(56, 189, 248, 0.32); + border-radius: 28px 36px 36px 28px; + background: + linear-gradient(90deg, rgba(56, 189, 248, 0.06), rgba(7, 18, 31, 0.35) 26%, rgba(7, 18, 31, 0.1)), + repeating-linear-gradient(90deg, rgba(148, 163, 184, 0.05) 0 1px, transparent 1px 38px); + box-shadow: + inset 12px 0 28px rgba(56, 189, 248, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.06); + content: ""; + pointer-events: none; +} + +body .login-console-dock::after { + position: absolute; + left: -32%; + right: -10%; + top: 51%; + height: 68px; + border-radius: 999px; + background: + linear-gradient(90deg, rgba(56, 189, 248, 0), rgba(56, 189, 248, 0.16) 42%, rgba(52, 211, 153, 0.12), transparent), + repeating-linear-gradient(90deg, transparent 0 22px, rgba(255, 255, 255, 0.16) 22px 27px); + box-shadow: 0 0 46px rgba(56, 189, 248, 0.12); + content: ""; + opacity: 0.78; + transform: translateY(-50%); + animation: loginDockSignal 3.6s ease-in-out infinite; + pointer-events: none; +} + +body .login-dock-rail { + position: absolute; + left: -28%; + right: 8%; + height: 1px; + border-radius: 999px; + background: linear-gradient(90deg, transparent, rgba(56, 189, 248, 0.46), rgba(52, 211, 153, 0.18), transparent); + box-shadow: 0 0 22px rgba(56, 189, 248, 0.28); + opacity: 0.64; + pointer-events: none; +} + +body .login-dock-rail-a { + top: 31%; +} + +body .login-dock-rail-b { + bottom: 25%; + animation: loginDockSignal 4.4s ease-in-out infinite reverse; +} + +body .login-dock-floor { + position: absolute; + left: -18%; + right: 3%; + bottom: 40px; + height: 98px; + border: 1px solid rgba(148, 163, 184, 0.12); + border-radius: 50% 50% 24px 24px / 38% 38% 22px 22px; + background: + linear-gradient(90deg, transparent, rgba(56, 189, 248, 0.08), rgba(52, 211, 153, 0.04), transparent), + repeating-linear-gradient(90deg, rgba(148, 163, 184, 0.12) 0 1px, transparent 1px 38px); + box-shadow: 0 28px 70px rgba(2, 6, 23, 0.2); + transform: perspective(520px) rotateX(62deg); + transform-origin: center bottom; + pointer-events: none; +} + +body .login-console-frame { + position: relative; + z-index: 2; + padding: 20px 20px 22px 24px; + border-left: 2px solid rgba(56, 189, 248, 0.48); + border-radius: 34px; + background: + linear-gradient(90deg, rgba(56, 189, 248, 0.12), transparent 22%), + linear-gradient(145deg, rgba(10, 24, 40, 0.38), rgba(7, 18, 31, 0.2)); + box-shadow: + -18px 0 48px rgba(56, 189, 248, 0.08), + inset 1px 0 0 rgba(255, 255, 255, 0.08); + animation: loginDockBreathe 4.8s ease-in-out infinite; +} + +body .login-line-labels { + display: grid; + grid-template-columns: repeat(4, minmax(0, max-content)); + gap: 9px; + align-items: center; + justify-content: start; + max-width: 100%; +} + +body .login-conveyor-label { + display: inline-grid; + grid-template-columns: 17px max-content; + gap: 7px; + align-items: center; + min-height: 34px; + padding: 7px 10px; + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: 999px; + background: + linear-gradient(120deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.025)), + rgba(8, 20, 34, 0.44); + color: #dff6ff; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); + backdrop-filter: blur(14px); +} + +body .login-conveyor-label .app-icon { + width: 17px; + height: 17px; + color: var(--login-forge-blue); +} + +body .login-conveyor-label span { + font-size: 12px; + font-weight: 900; + letter-spacing: 0.04em; + white-space: nowrap; +} + +body .login-production-layout .login-control-console { + position: relative; + z-index: 3; + width: 100%; + padding: clamp(28px, 3vw, 40px); + overflow: hidden; + border: 1px solid rgba(174, 199, 224, 0.18); + border-left-color: rgba(56, 189, 248, 0.36); + border-radius: 24px; + background: + radial-gradient(circle at 0% 48%, rgba(56, 189, 248, 0.2), transparent 32%), + linear-gradient(90deg, rgba(8, 24, 38, 0.82), rgba(7, 18, 31, 0.6) 52%, rgba(6, 16, 29, 0.48)), + repeating-linear-gradient(90deg, rgba(148, 163, 184, 0.035) 0 1px, transparent 1px 34px); + box-shadow: + -8px 0 22px rgba(56, 189, 248, 0.12), + 0 22px 64px rgba(2, 6, 23, 0.26), + inset 0 1px 0 rgba(255, 255, 255, 0.08), + inset 18px 0 34px rgba(56, 189, 248, 0.04); + backdrop-filter: blur(18px); +} + +body .login-production-layout .login-control-console::before { + position: absolute; + inset: 0; + border-radius: inherit; + background: + linear-gradient(120deg, rgba(255, 255, 255, 0.08), transparent 28%), + radial-gradient(circle at 0% 52%, rgba(56, 189, 248, 0.18), transparent 26%); + content: ""; + pointer-events: none; +} + +body .login-production-layout .login-control-console::after { + position: absolute; + left: -1px; + top: 25%; + width: 3px; + height: 50%; + border-radius: 999px; + background: linear-gradient(180deg, transparent, var(--login-forge-blue), var(--login-forge-green), transparent); + box-shadow: 0 0 30px rgba(56, 189, 248, 0.5); + content: ""; + animation: loginSteelPulse 3.2s ease-in-out infinite; +} + +body .login-production-layout .login-panel-head, +body .login-production-layout .login-form, +body .login-production-layout .login-security-foot { + position: relative; + z-index: 1; +} + +body .login-production-layout .login-panel-head { + display: grid; + gap: 10px; + margin-bottom: 28px; + text-align: left; +} + +body .login-production-layout .login-panel-head h2 { + margin: 0; + color: #f8fbff; + font-family: var(--ui-font-display); + font-size: clamp(32px, 3.4vw, 40px); + line-height: 1.1; + letter-spacing: 0.03em; + white-space: nowrap; +} + +body .login-production-layout .login-panel-head p { + margin: 0; + color: var(--login-muted); + font-size: 13px; + line-height: 1.7; +} + +body .login-production-layout .login-console-chip { + justify-self: start; + padding: 6px 10px; + border: 1px solid rgba(56, 189, 248, 0.26); + border-radius: 999px; + color: #bdefff; + background: rgba(56, 189, 248, 0.08); + font-family: var(--ui-font-display); + font-size: 11px; + font-weight: 950; + letter-spacing: 0.14em; +} + +body .login-production-layout .login-form { + display: grid; + gap: 16px; + margin: 0; +} + +body .login-production-layout .login-input-shell { + display: grid; + gap: 8px; +} + +body .login-production-layout .login-input-shell > span { + color: #bdd3e6; + font-size: 13px; + font-weight: 900; + letter-spacing: 0.04em; +} + +body .login-production-layout .login-input-frame { + display: grid; + grid-template-columns: 22px minmax(0, 1fr); + align-items: center; + gap: 10px; + min-height: 56px; + padding: 0 14px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 18px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.11), rgba(255, 255, 255, 0.045)), + rgba(7, 17, 29, 0.68); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); + transition: + border-color var(--ui-transition-fast), + box-shadow var(--ui-transition-fast), + background var(--ui-transition-fast), + transform var(--ui-transition-fast); +} + +body .login-production-layout .login-input-frame:focus-within { + border-color: rgba(56, 189, 248, 0.76); + background: rgba(10, 24, 40, 0.86); + box-shadow: + 0 0 0 4px rgba(56, 189, 248, 0.12), + 0 14px 34px rgba(2, 6, 23, 0.18), + inset 0 1px 0 rgba(255, 255, 255, 0.12); + transform: translateY(-1px); +} + +body .login-production-layout .login-input-frame .app-icon { + width: 21px; + height: 21px; + color: var(--login-forge-blue); +} + +body .login-production-layout .login-input-frame input { + width: 100%; + min-height: 52px; + border: 0; + outline: 0; + color: #f8fbff; + background: transparent; + font-size: 15px; + font-weight: 800; +} + +body .login-production-layout .login-input-frame input::placeholder { + color: rgba(184, 204, 220, 0.56); +} + +body .login-production-layout .login-actions { + display: grid; + gap: 10px; + margin-top: 4px; +} + +body .login-production-layout .login-submit-button.primary-button { + position: relative; + justify-content: space-between; + width: 100%; + min-height: 56px; + overflow: hidden; + border: 1px solid rgba(56, 189, 248, 0.5); + border-radius: 18px; + background: + linear-gradient(90deg, #1d4ed8, #0891b2 52%, #0f766e); + box-shadow: + 0 20px 44px rgba(14, 165, 233, 0.24), + inset 0 1px 0 rgba(255, 255, 255, 0.24); + font-family: var(--ui-font-display); + font-size: 15px; + letter-spacing: 0.08em; + transition: + transform var(--ui-transition-fast), + box-shadow var(--ui-transition-fast), + filter var(--ui-transition-fast); +} + +body .login-production-layout .login-submit-button.primary-button::before { + position: absolute; + inset: 1px; + border-radius: inherit; + background: + radial-gradient(circle at var(--button-light-x, 20%) 50%, rgba(255, 255, 255, 0.32), transparent 22%), + linear-gradient(90deg, transparent, rgba(34, 211, 238, 0.24), transparent); + content: ""; + opacity: 0.58; + animation: loginConveyorSignal 2.8s ease-in-out infinite; +} + +body .login-production-layout .login-submit-button.primary-button:hover { + filter: saturate(1.08); + box-shadow: + 0 24px 52px rgba(14, 165, 233, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.3); + transform: translateY(-2px); +} + +body .login-production-layout .login-submit-button::after { + position: absolute; + inset: 0; + background: linear-gradient(105deg, transparent 0%, rgba(255, 255, 255, 0.34) 42%, transparent 68%); + content: ""; + transform: translateX(-120%); + animation: loginButtonGlint 3.4s ease-in-out infinite; +} + +body .login-production-layout .login-submit-button span, +body .login-production-layout .login-submit-button .app-icon { + position: relative; + z-index: 1; +} + +body .login-production-layout .login-submit-button .app-icon { + width: 20px; + height: 20px; +} + +body .login-production-layout .login-control-console .form-message, +body .login-production-layout .login-control-console .form-error { + margin: 0; + padding: 10px 12px; + border-radius: 13px; + font-size: 13px; + font-weight: 900; +} + +body .login-production-layout .login-control-console .form-message { + border: 1px solid rgba(52, 211, 153, 0.24); + color: #bbf7d0; + background: rgba(22, 101, 52, 0.24); +} + +body .login-production-layout .login-control-console .form-error { + border: 1px solid rgba(248, 113, 113, 0.24); + color: #fecaca; + background: rgba(127, 29, 29, 0.22); +} + +body .login-production-layout .login-security-foot { + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + gap: 10px; + align-items: center; + margin-top: 18px; + color: #8ca7bd; + font-size: 12px; + line-height: 1.5; +} + +body .login-production-layout .login-security-foot span { + width: 28px; + height: 28px; + border: 1px solid rgba(52, 211, 153, 0.34); + border-radius: 999px; + background: + radial-gradient(circle at center, rgba(52, 211, 153, 0.9) 0 3px, transparent 4px), + rgba(52, 211, 153, 0.1); + box-shadow: 0 0 24px rgba(52, 211, 153, 0.22); +} + +body .login-production-layout .login-security-foot p { + margin: 0; +} + +@keyframes loginSteelPulse { + 0%, + 100% { + opacity: 0.34; + filter: drop-shadow(0 0 0 rgba(56, 189, 248, 0)); + } + 50% { + opacity: 0.92; + filter: drop-shadow(0 0 18px rgba(56, 189, 248, 0.4)); + } +} + +@keyframes loginForgeBeam { + 0%, + 100% { + opacity: 0; + transform: translateX(-34px) skewX(-13deg) scaleY(0.72); + } + 42%, + 58% { + opacity: 0.85; + transform: translateX(18px) skewX(-13deg) scaleY(1); + } +} + +@keyframes loginPartRun { + 0% { + opacity: 0; + transform: translateX(0) scale(0.88); + } + 12%, + 68% { + opacity: 0.82; + } + 100% { + opacity: 0; + transform: translateX(210px) scale(1.04); + } +} + +@keyframes loginHeatRise { + 0%, + 100% { + opacity: 0.16; + transform: translateY(16%) scaleX(0.82); + } + 50% { + opacity: 0.38; + transform: translateY(-9%) scaleX(1.08); + } +} + +@keyframes loginConveyorRoll { + from { + background-position: 0 0, 0 0; + } + to { + background-position: 54px 0, 0 0; + } +} + +@keyframes loginConveyorSignal { + 0% { + opacity: 0; + transform: translateX(-42%); + } + 38%, + 58% { + opacity: 0.78; + } + 100% { + opacity: 0; + transform: translateX(44%); + } +} + +@keyframes loginPressAura { + 0%, + 46%, + 100% { + opacity: 0; + transform: translateX(-50%) scale(0.68); + } + 58% { + opacity: 0.9; + transform: translateX(-50%) scale(1.15); + } +} + +@keyframes loginDockSignal { + 0%, + 100% { + opacity: 0.26; + background-position: -140px 0, 0 0; + filter: drop-shadow(0 0 0 rgba(56, 189, 248, 0)); + } + 48%, + 58% { + opacity: 0.86; + background-position: 140px 0, 56px 0; + filter: drop-shadow(0 0 18px rgba(56, 189, 248, 0.34)); + } +} + +@keyframes loginDockBreathe { + 0%, + 100% { + border-left-color: rgba(56, 189, 248, 0.38); + box-shadow: + -18px 0 48px rgba(56, 189, 248, 0.08), + inset 1px 0 0 rgba(255, 255, 255, 0.08); + } + 50% { + border-left-color: rgba(52, 211, 153, 0.52); + box-shadow: + -24px 0 62px rgba(56, 189, 248, 0.14), + inset 1px 0 0 rgba(255, 255, 255, 0.12); + } +} + +@media (max-width: 1080px) { + body .login-production-cockpit { + grid-template-columns: minmax(460px, 1fr) minmax(390px, 450px); + gap: 18px; + } + + body .login-line-area { + min-height: 560px; + } + + body .login-production-line { + height: 430px; + } + + body .login-line-labels { + grid-template-columns: repeat(2, minmax(0, max-content)); + } +} + +@media (max-width: 900px) { + body .login-industrial-stage.login-production-layout { + align-items: start; + min-height: 100vh; + padding: 14px; + overflow-y: auto; + } + + body .login-production-cockpit { + grid-template-columns: minmax(0, 1fr); + gap: 18px; + width: min(560px, 100%); + min-height: auto; + } + + body .login-line-area { + order: 1; + grid-template-rows: auto auto auto; + min-height: auto; + padding: 0; + } + + body .login-production-layout .login-control-console { + padding: 24px; + } + + body .login-console-dock { + order: 2; + min-height: auto; + padding: 12px 0 0; + } + + body .login-console-dock::before, + body .login-console-dock::after, + body .login-dock-rail, + body .login-dock-floor { + display: none; + } + + body .login-console-frame { + padding: 12px; + border-radius: 28px; + } + + body .login-line-status { + display: none; + } + + body .login-production-line { + height: 260px; + margin: 18px 0 12px; + overflow: hidden; + } + + body .login-production-line .login-coil { + left: 2%; + top: 46%; + width: 78px; + height: 78px; + } + + body .login-production-line .login-coil span { + inset: calc(var(--coil-ring, 0) * 10px); + } + + body .login-line-machine { + left: 46%; + top: 18%; + width: 134px; + height: 168px; + } + + body .login-line-machine::before { + height: 146px; + border-radius: 24px; + } + + body .login-production-line .login-press-machine { + top: 14px; + width: 108px; + height: 136px; + } + + body .login-production-line .login-press-arm { + left: 14px; + right: 14px; + height: 72px; + border-radius: 18px 18px 11px 11px; + } + + body .login-production-line .login-press-hammer { + top: 54px; + width: 36px; + height: 24px; + } + + body .login-production-line .login-press-die { + left: 18px; + right: 18px; + bottom: 14px; + height: 12px; + } + + body .login-production-line .login-spark { + top: 104px; + } + + body .login-production-line .login-press-aura { + top: 74px; + width: 78px; + height: 38px; + } + + body .login-inspection-gate { + right: 20%; + top: 39%; + width: 48px; + height: 92px; + border-radius: 15px; + } + + body .login-production-line .login-stamped-stack { + right: 1%; + top: 49%; + transform: scale(0.72); + transform-origin: right center; + } + + body .login-console-link { + display: none; + } + + body .login-forge-beam { + height: 54%; + } + + body .login-part-runner { + left: 48%; + right: 0; + } + + body .login-line-labels { + grid-template-columns: repeat(2, minmax(0, max-content)); + gap: 7px; + } + + body .login-conveyor-label { + min-height: 30px; + padding: 6px 9px; + } +} + +@media (max-width: 560px) { + body .login-industrial-stage.login-production-layout { + padding: 10px; + } + + body .login-line-brand { + grid-template-columns: 42px minmax(0, max-content); + border-radius: 17px; + } + + body .login-line-brand .login-logo, + body .login-production-layout .login-logo { + width: 42px; + height: 42px; + } + + body .login-line-brand span { + font-size: 16px; + } + + body .login-production-layout .login-panel-head h2 { + font-size: 28px; + white-space: normal; + } + + body .login-production-line { + height: 220px; + } + + body .login-line-status { + grid-template-columns: 10px minmax(0, 1fr); + border-radius: 18px; + } + + body .login-line-labels { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + } + + body .login-conveyor-label { + grid-template-columns: 16px minmax(0, 1fr); + } +} + +@media (prefers-reduced-motion: reduce) { + body .login-production-layout::before, + body .login-production-layout .login-depth-grid, + body .login-production-layout .login-scan-plane, + body .login-production-layout .login-data-thread, + body .login-production-layout .login-live-dot, + body .login-production-layout .login-coil, + body .login-production-layout .login-metal-strip, + body .login-production-layout .login-press-hammer, + body .login-production-layout .login-spark, + body .login-production-layout .login-stamped-stack span, + body .login-production-layout .login-console-link, + body .login-production-layout .login-dock-rail, + body .login-production-layout .login-console-frame, + body .login-production-layout .login-forge-beam, + body .login-production-layout .login-press-aura, + body .login-production-layout .login-part-runner span, + body .login-production-layout .login-heat-column, + body .login-production-layout .login-submit-button::after { + animation: none !important; + } +} + +/* Industrial archive document forms */ +.document-paper-shell { + --document-paper: #fff6df; + --document-paper-deep: #f5e8c7; + --document-paper-edge: #c8ad73; + --document-paper-ink: #17120a; + --document-paper-muted: #6a5631; + --document-paper-grid: rgba(20, 14, 6, 0.88); + --document-paper-red: #b42318; + --document-paper-green: #1f7a4a; + --document-paper-stamp: var(--document-paper-red); + position: relative; + isolation: isolate; + max-width: 1040px; + margin: 0 auto; + padding: 26px 22px 32px; + color: var(--document-paper-ink); + font-family: "Songti SC", "SimSun", var(--ui-font-body); +} + +.document-paper-tone-green { + --document-paper-stamp: var(--document-paper-green); +} + +.document-paper-tone-red { + --document-paper-stamp: var(--document-paper-red); +} + +.document-paper-stack, +.document-paper-sheet { + border: 2px solid var(--document-paper-grid); + background: + linear-gradient(90deg, rgba(78, 55, 20, 0.06) 1px, transparent 1px) 0 0 / 42px 42px, + linear-gradient(0deg, rgba(78, 55, 20, 0.045) 1px, transparent 1px) 0 0 / 42px 42px, + radial-gradient(circle at 18% 12%, rgba(255, 255, 255, 0.82), transparent 30%), + linear-gradient(135deg, var(--document-paper) 0%, #fffaf0 48%, var(--document-paper-deep) 100%); + box-shadow: + 0 22px 52px rgba(52, 39, 15, 0.22), + inset 0 0 0 1px rgba(255, 255, 255, 0.62); +} + +.document-paper-stack { + position: absolute; + inset: 28px 20px 30px; + z-index: -2; + pointer-events: none; +} + +.document-paper-stack-back { + transform: rotate(-2deg) translate(-8px, 9px); + opacity: 0.62; +} + +.document-paper-stack-mid { + transform: rotate(1.35deg) translate(7px, 3px); + opacity: 0.78; +} + +.document-paper-sheet { + position: relative; + min-height: 640px; + padding: clamp(24px, 4vw, 44px); + overflow: hidden; +} + +.document-paper-sheet::before { + position: absolute; + inset: 9px; + content: ""; + border: 1px solid rgba(20, 14, 6, 0.5); + pointer-events: none; +} + +.document-paper-sheet::after { + position: absolute; + inset: 0; + content: ""; + background: + radial-gradient(circle at 22% 20%, rgba(106, 86, 49, 0.08) 0 1px, transparent 1.6px), + radial-gradient(circle at 74% 66%, rgba(106, 86, 49, 0.06) 0 1px, transparent 1.8px); + background-size: 18px 18px, 23px 23px; + mix-blend-mode: multiply; + opacity: 0.52; + pointer-events: none; +} + +.document-paper-clip { + position: absolute; + top: -8px; + left: 50%; + z-index: 3; + width: 110px; + height: 44px; + transform: translateX(-50%); + filter: drop-shadow(0 8px 12px rgba(37, 29, 15, 0.24)); +} + +.document-paper-clip-bar, +.document-paper-clip-jaw { + position: absolute; + left: 50%; + transform: translateX(-50%); + border: 2px solid rgba(39, 43, 43, 0.92); + background: linear-gradient(180deg, #e7ece9, #8e9894 54%, #f7fbf7); +} + +.document-paper-clip-bar { + top: 0; + width: 92px; + height: 17px; + border-radius: 0 0 12px 12px; +} + +.document-paper-clip-jaw { + top: 12px; + width: 66px; + height: 26px; + border-top: 0; + border-radius: 0 0 18px 18px; +} + +.document-paper-binder { + position: absolute; + left: 15px; + top: 88px; + bottom: 88px; + z-index: 2; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.document-paper-binder-hole { + width: 15px; + height: 15px; + border: 2px solid rgba(31, 24, 12, 0.82); + border-radius: 999px; + background: + radial-gradient(circle at 48% 45%, rgba(77, 57, 24, 0.42), transparent 45%), + #efe0b9; + box-shadow: + inset 0 2px 4px rgba(57, 42, 16, 0.34), + 0 0 0 4px rgba(255, 246, 223, 0.72); +} + +.document-retained-mark { + position: absolute; + right: 17px; + top: 118px; + z-index: 2; + writing-mode: vertical-rl; + letter-spacing: 0.22em; + color: rgba(23, 18, 10, 0.58); + font-family: var(--ui-font-display); + font-size: 13px; + font-weight: 800; +} + +.document-paper-header { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: end; + gap: 18px; + min-height: 122px; + padding: 18px 22px 22px; + border: 2px solid var(--document-paper-grid); + border-bottom-width: 3px; +} + +.document-company-name { + margin: 0; + align-self: start; + color: var(--document-paper-muted); + font-family: var(--ui-font-display); + font-size: 14px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.document-paper-title { + margin: 0; + text-align: center; + color: var(--document-paper-ink); + font-family: "STKaiti", "KaiTi", "Songti SC", serif; + font-size: clamp(30px, 5vw, 52px); + font-weight: 900; + letter-spacing: 0.18em; + line-height: 1; +} + +.document-number-stamp { + justify-self: end; + min-width: 150px; + padding: 9px 12px; + color: var(--document-paper-stamp); + text-align: center; + border: 2px solid currentColor; + transform: rotate(-2.2deg); + font-family: var(--ui-font-display); + font-weight: 900; + letter-spacing: 0.06em; + box-shadow: inset 0 0 0 2px rgba(255, 246, 223, 0.72); +} + +.document-number-label { + display: block; + font-size: 11px; + letter-spacing: 0.18em; +} + +.document-number-stamp strong { + display: block; + margin-top: 2px; + font-size: 16px; +} + +.document-paper-body { + position: relative; + z-index: 1; + padding-top: 18px; +} + +.document-grid { + display: grid; + grid-template-columns: repeat(var(--document-grid-columns, 4), minmax(0, 1fr)); + margin: 0; + border-top: 2px solid var(--document-paper-grid); + border-left: 2px solid var(--document-paper-grid); +} + +.document-grid-cell { + min-width: 0; + border-right: 2px solid var(--document-paper-grid); + border-bottom: 2px solid var(--document-paper-grid); + background: rgba(255, 250, 240, 0.5); +} + +.document-grid-label, +.document-grid-value { + margin: 0; +} + +.document-grid-label { + min-height: 30px; + padding: 7px 9px; + color: rgba(23, 18, 10, 0.74); + font-size: 12px; + font-weight: 800; + letter-spacing: 0.06em; + background: rgba(227, 207, 161, 0.62); + border-bottom: 1px solid rgba(20, 14, 6, 0.56); +} + +.document-grid-value { + min-height: 42px; + padding: 10px 9px; + font-size: 14px; + font-weight: 700; + line-height: 1.45; + word-break: break-word; +} + +.document-line-table { + margin-top: 18px; + border: 2px solid var(--document-paper-grid); + background: rgba(255, 250, 240, 0.38); +} + +.document-line-table-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + min-height: 46px; + padding: 8px 12px; + border-bottom: 2px solid var(--document-paper-grid); + background: + linear-gradient(90deg, rgba(23, 18, 10, 0.1), transparent 40%), + rgba(227, 207, 161, 0.72); +} + +.document-line-table-title { + margin: 0; + font-family: var(--ui-font-display); + font-size: 15px; + font-weight: 900; + letter-spacing: 0.1em; +} + +.document-line-table-actions { + display: inline-flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.document-line-table-scroll { + width: 100%; + overflow-x: auto; +} + +.document-line-table table { + width: 100%; + min-width: 720px; + border-collapse: collapse; + font-size: 13px; +} + +.document-line-table th, +.document-line-table td { + min-height: 38px; + padding: 9px 10px; + color: var(--document-paper-ink); + text-align: left; + vertical-align: top; + border-right: 1.5px solid var(--document-paper-grid); + border-bottom: 1.5px solid var(--document-paper-grid); +} + +.document-line-table th:last-child, +.document-line-table td:last-child { + border-right: 0; +} + +.document-line-table tbody tr:last-child td { + border-bottom: 0; +} + +.document-line-table th { + font-weight: 900; + background: rgba(227, 207, 161, 0.5); +} + +.document-signature-footer { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); + gap: 0; + margin-top: 24px; + border-top: 2px solid var(--document-paper-grid); + border-left: 2px solid var(--document-paper-grid); +} + +.document-signature-cell { + min-height: 74px; + padding: 12px; + border-right: 2px solid var(--document-paper-grid); + border-bottom: 2px solid var(--document-paper-grid); +} + +.document-signature-label { + display: block; + color: rgba(23, 18, 10, 0.74); + font-size: 13px; + font-weight: 900; + letter-spacing: 0.12em; +} + +.document-signature-line { + display: block; + margin-top: 24px; + border-bottom: 1px solid rgba(23, 18, 10, 0.62); +} + +.document-archive-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px; + border: 1px solid rgba(20, 14, 6, 0.2); + border-radius: 14px; + background: rgba(255, 250, 240, 0.82); +} + +.document-archive-status { + display: inline-flex; + align-items: center; + gap: 7px; + min-height: 30px; + padding: 0 10px; + color: var(--document-paper-muted, #6a5631); + border: 1px solid rgba(106, 86, 49, 0.24); + border-radius: 999px; + background: rgba(255, 255, 255, 0.62); + font-size: 12px; + font-weight: 900; +} + +.document-archive-status-dot { + width: 7px; + height: 7px; + border-radius: 999px; + background: currentColor; + box-shadow: 0 0 0 3px rgba(106, 86, 49, 0.12); +} + +.document-archive-actions-archived .document-archive-status { + color: var(--document-paper-green, #1f7a4a); + border-color: rgba(31, 122, 74, 0.3); +} + +.document-archive-actions-failed .document-archive-status { + color: var(--document-paper-red, #b42318); + border-color: rgba(180, 35, 24, 0.34); +} + +.document-archive-actions-pending .document-archive-status { + color: #906312; + border-color: rgba(144, 99, 18, 0.3); +} + +.document-archive-buttons { + display: inline-flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.document-archive-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + min-height: 31px; + padding: 0 10px; + color: var(--document-paper-ink, #17120a); + border: 1.5px solid rgba(23, 18, 10, 0.82); + border-radius: 4px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.36), transparent 52%), + rgba(255, 246, 223, 0.9); + box-shadow: + 0 2px 0 rgba(23, 18, 10, 0.2), + inset 0 0 0 1px rgba(255, 255, 255, 0.54); + cursor: pointer; + font-family: var(--ui-font-display); + font-size: 12px; + font-weight: 900; + line-height: 1; + letter-spacing: 0.04em; + text-transform: none; + transition: + transform var(--ui-transition-fast), + box-shadow var(--ui-transition-fast), + background var(--ui-transition-fast), + color var(--ui-transition-fast); + white-space: nowrap; +} + +.document-archive-button:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: + 0 4px 0 rgba(23, 18, 10, 0.18), + 0 10px 18px rgba(52, 39, 15, 0.1), + inset 0 0 0 1px rgba(255, 255, 255, 0.62); +} + +.document-archive-button:active:not(:disabled) { + transform: translateY(0); + box-shadow: + 0 1px 0 rgba(23, 18, 10, 0.22), + inset 0 2px 5px rgba(52, 39, 15, 0.14); +} + +.document-archive-button:disabled { + color: rgba(106, 86, 49, 0.48); + border-color: rgba(106, 86, 49, 0.34); + background: + repeating-linear-gradient(-45deg, rgba(106, 86, 49, 0.06) 0 4px, transparent 4px 8px), + rgba(239, 224, 185, 0.56); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.42); + cursor: not-allowed; + transform: none; +} + +.document-archive-button-preview, +.document-archive-button-download { + color: var(--document-paper-green, #1f7a4a); + border-color: rgba(31, 122, 74, 0.68); +} + +.document-archive-button-preview:not(:disabled), +.document-archive-button-download:not(:disabled) { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.38), transparent 52%), + rgba(232, 246, 232, 0.74); +} + +.document-archive-button-regenerate { + color: var(--document-paper-red, #b42318); + border-color: rgba(180, 35, 24, 0.74); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.34), transparent 52%), + rgba(255, 238, 224, 0.78); +} + +.document-archive-icon { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + font-family: var(--ui-font-display); + font-size: 10px; + font-weight: 900; + line-height: 1; + border: 1px solid currentColor; + border-radius: 4px; +} + +@media (max-width: 780px) { + .document-paper-shell { + padding: 14px 8px 20px; + } + + .document-paper-stack { + inset: 18px 8px 18px; + } + + .document-paper-sheet { + min-height: 560px; + padding: 22px 16px 24px 28px; + } + + .document-paper-header { + grid-template-columns: 1fr; + align-items: center; + justify-items: center; + gap: 12px; + padding: 18px 16px; + text-align: center; + } + + .document-company-name, + .document-number-stamp { + justify-self: center; + } + + .document-retained-mark { + right: 8px; + top: 98px; + font-size: 11px; + } + + .document-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .document-line-table-toolbar, + .document-archive-actions { + align-items: stretch; + flex-direction: column; + } + + .document-line-table-actions, + .document-archive-buttons { + justify-content: flex-start; + } +} + +@media (max-width: 520px) { + .document-paper-sheet { + padding-right: 12px; + padding-left: 24px; + } + + .document-paper-title { + letter-spacing: 0.1em; + } + + .document-grid { + grid-template-columns: 1fr; + } + + .document-archive-buttons { + display: grid; + grid-template-columns: 1fr; + width: 100%; + } + + .document-archive-button { + width: 100%; + } +} + +@media (prefers-reduced-motion: reduce) { + .document-archive-button { + transition: none; + } +} + +/* Warehouse paper-form sample */ +.warehouse-document-operation-form { + display: grid; + gap: 14px; + padding-bottom: 86px; +} + +.warehouse-document-operation-form .document-paper-shell { + max-width: 1080px; + padding: 18px 12px 26px; +} + +.warehouse-document-operation-form .document-paper-sheet { + min-height: 0; + padding-bottom: 34px; +} + +.warehouse-document-operation-form .document-paper-title { + font-size: clamp(34px, 4.2vw, 48px); +} + +.warehouse-document-operation-form .document-number-stamp { + min-width: 196px; +} + +.warehouse-document-meta-strip { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr); + gap: 10px; + align-items: center; + margin-bottom: 14px; + padding: 9px 12px; + color: rgba(23, 18, 10, 0.72); + border: 2px solid rgba(20, 14, 6, 0.82); + background: + linear-gradient(90deg, rgba(31, 122, 74, 0.12), transparent 42%), + rgba(255, 250, 240, 0.54); + font-family: "Songti SC", "SimSun", var(--ui-font-body); + font-size: 13px; + font-weight: 800; + letter-spacing: 0.08em; +} + +.warehouse-document-meta-strip strong { + display: inline-flex; + align-items: center; + min-height: 26px; + padding: 0 10px; + color: #145c39; + border: 1.5px solid rgba(31, 122, 74, 0.64); + background: rgba(232, 246, 232, 0.62); +} + +.warehouse-paper-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + border-top: 2px solid rgba(20, 14, 6, 0.88); + border-left: 2px solid rgba(20, 14, 6, 0.88); + background: rgba(255, 250, 240, 0.34); +} + +.warehouse-paper-field { + display: grid; + grid-template-rows: auto minmax(48px, auto); + min-width: 0; + margin: 0; + border-right: 2px solid rgba(20, 14, 6, 0.88); + border-bottom: 2px solid rgba(20, 14, 6, 0.88); + background: rgba(255, 250, 240, 0.44); +} + +.warehouse-paper-field-wide { + grid-column: span 2; +} + +.warehouse-paper-field-full { + grid-column: 1 / -1; +} + +.warehouse-paper-field > span { + display: flex; + align-items: center; + min-height: 32px; + padding: 7px 10px; + color: rgba(23, 18, 10, 0.78); + border-bottom: 1px solid rgba(20, 14, 6, 0.56); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.24), transparent), + rgba(227, 207, 161, 0.62); + font-family: var(--ui-font-display); + font-size: 12px; + font-weight: 900; + letter-spacing: 0.08em; +} + +.warehouse-paper-field input, +.warehouse-paper-field select, +.warehouse-paper-field textarea { + width: 100%; + min-width: 0; + min-height: 48px; + padding: 10px 11px; + color: #17120a; + border: 0; + border-radius: 0; + outline: 0; + background: transparent; + box-shadow: none; + font-family: "Songti SC", "SimSun", var(--ui-font-body); + font-size: 14px; + font-weight: 800; + line-height: 1.45; +} + +.warehouse-paper-field select { + cursor: pointer; +} + +.warehouse-paper-field textarea { + min-height: 104px; + resize: vertical; +} + +.warehouse-paper-field input:focus, +.warehouse-paper-field select:focus, +.warehouse-paper-field textarea:focus { + background: + linear-gradient(90deg, rgba(31, 122, 74, 0.12), transparent 42%), + rgba(255, 255, 255, 0.48); + box-shadow: inset 0 0 0 2px rgba(31, 122, 74, 0.42); +} + +.warehouse-paper-field input[readonly] { + color: #145c39; + background: + repeating-linear-gradient(-45deg, rgba(31, 122, 74, 0.06) 0 5px, transparent 5px 10px), + rgba(232, 246, 232, 0.32); +} + +.warehouse-paper-readonly-value { + display: flex; + align-items: center; + min-height: 48px; + margin: 0; + padding: 10px 11px; + color: #145c39; + background: + repeating-linear-gradient(-45deg, rgba(31, 122, 74, 0.06) 0 5px, transparent 5px 10px), + rgba(232, 246, 232, 0.32); + font-family: "Songti SC", "SimSun", var(--ui-font-body); + font-size: 14px; + font-weight: 900; + line-height: 1.5; + overflow-wrap: anywhere; + word-break: break-word; +} + +.warehouse-paper-field input::placeholder, +.warehouse-paper-field textarea::placeholder { + color: rgba(106, 86, 49, 0.58); +} + +.warehouse-paper-upload-field { + grid-template-rows: auto minmax(48px, auto); +} + +.warehouse-paper-upload { + position: relative; + display: flex; + align-items: center; + justify-content: center; + gap: 9px; + min-height: 48px; + padding: 10px 12px; + overflow: hidden; + color: #145c39; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.36), transparent 60%), + rgba(232, 246, 232, 0.36); + cursor: pointer; + font-family: var(--ui-font-display); + font-size: 12px; + font-weight: 900; + letter-spacing: 0.04em; + transition: + background var(--ui-transition-fast), + box-shadow var(--ui-transition-fast), + color var(--ui-transition-fast); +} + +.warehouse-paper-upload:hover:not(.is-busy) { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.5), transparent 60%), + rgba(219, 246, 231, 0.68); + box-shadow: inset 0 0 0 2px rgba(31, 122, 74, 0.28); +} + +.warehouse-paper-upload.is-uploaded { + color: #0f6b3d; + background: rgba(220, 252, 231, 0.62); +} + +.warehouse-paper-upload.is-busy { + cursor: wait; + opacity: 0.72; +} + +.warehouse-paper-upload input { + position: absolute; + inset: 0; + opacity: 0; + cursor: inherit; +} + +.warehouse-paper-upload b { + display: inline-grid; + place-items: center; + width: 24px; + height: 24px; + color: currentColor; + border: 1.5px solid currentColor; + border-radius: 999px; + font-size: 14px; + line-height: 1; +} + +.warehouse-paper-upload strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.warehouse-document-action-anchor { + display: none; +} + +.document-paper-action-host { + position: relative; + z-index: 1; + display: flex; + justify-content: flex-end; + margin-top: 18px; +} + +.warehouse-document-action-bar { + position: relative; + z-index: 1; + display: flex; + justify-content: flex-end; + align-items: center; + width: fit-content; + max-width: 1080px; + margin: 0 0 0 auto; + padding: 10px; + border: 1px solid rgba(31, 122, 74, 0.24); + border-radius: 18px; + background: + linear-gradient(90deg, rgba(232, 246, 232, 0.92), rgba(255, 250, 240, 0.96)), + rgba(255, 255, 255, 0.92); + box-shadow: + 0 -12px 28px rgba(5, 11, 18, 0.22), + inset 0 1px 0 rgba(255, 255, 255, 0.68); + backdrop-filter: blur(14px); +} + +.warehouse-document-action-bar .primary-button { + flex: 0 0 auto; +} + +@media (max-width: 960px) { + .warehouse-paper-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .warehouse-document-meta-strip { + grid-template-columns: 1fr; + } +} + +@media (max-width: 620px) { + .warehouse-document-operation-form .document-paper-shell { + padding-inline: 0; + } + + .warehouse-paper-grid { + grid-template-columns: 1fr; + } + + .warehouse-paper-field, + .warehouse-paper-field-wide, + .warehouse-paper-field-full { + grid-column: 1 / -1; + } + + .warehouse-document-action-bar { + align-items: stretch; + width: 100%; + } + + .warehouse-document-action-bar .primary-button { + width: 100%; + } +} + +/* Shared warehouse paper-form components */ +.warehouse-document-form { + display: block; + color: var(--document-paper-ink, #17120a); + font-family: "Songti SC", "SimSun", var(--ui-font-body); +} + +.warehouse-document-form .document-paper-shell { + max-width: 1080px; + padding: 18px 12px 26px; +} + +.warehouse-document-form .document-paper-sheet { + min-height: 0; + padding-bottom: 34px; +} + +.warehouse-document-form .document-paper-title { + font-size: clamp(34px, 4.2vw, 48px); +} + +.warehouse-document-form .document-number-stamp { + min-width: 196px; +} + +.warehouse-document-form-meta { + display: grid; + grid-template-columns: auto minmax(120px, max-content) auto minmax(120px, 1fr); + gap: 0; + margin-bottom: 16px; + border-top: 2px solid var(--document-paper-grid, rgba(20, 14, 6, 0.88)); + border-left: 2px solid var(--document-paper-grid, rgba(20, 14, 6, 0.88)); + background: rgba(255, 250, 240, 0.42); +} + +.warehouse-document-form-meta span, +.warehouse-document-form-meta strong { + display: flex; + align-items: center; + min-height: 38px; + margin: 0; + padding: 8px 11px; + border-right: 2px solid var(--document-paper-grid, rgba(20, 14, 6, 0.88)); + border-bottom: 2px solid var(--document-paper-grid, rgba(20, 14, 6, 0.88)); + line-height: 1.4; +} + +.warehouse-document-form-meta span { + color: rgba(23, 18, 10, 0.76); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.22), transparent), + rgba(227, 207, 161, 0.62); + font-family: var(--ui-font-display); + font-size: 12px; + font-weight: 900; + letter-spacing: 0.08em; +} + +.warehouse-document-form-meta strong { + color: var(--document-paper-stamp, #1f7a4a); + background: + repeating-linear-gradient(-45deg, rgba(31, 122, 74, 0.045) 0 5px, transparent 5px 10px), + rgba(255, 250, 240, 0.48); + font-size: 14px; + font-weight: 900; + overflow-wrap: anywhere; +} + +.warehouse-document-body { + display: grid; + gap: 18px; +} + +.warehouse-document-section { + position: relative; + border: 2px solid var(--document-paper-grid, rgba(20, 14, 6, 0.88)); + background: + linear-gradient(90deg, rgba(78, 55, 20, 0.045) 1px, transparent 1px) 0 0 / 32px 32px, + linear-gradient(0deg, rgba(78, 55, 20, 0.035) 1px, transparent 1px) 0 0 / 32px 32px, + rgba(255, 250, 240, 0.5); +} + +.warehouse-document-section::before { + position: absolute; + inset: 5px; + content: ""; + border: 1px solid rgba(20, 14, 6, 0.22); + pointer-events: none; +} + +.warehouse-document-section-head { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 10px; + min-height: 46px; + padding: 8px 12px; + border-bottom: 2px solid var(--document-paper-grid, rgba(20, 14, 6, 0.88)); + background: + linear-gradient(90deg, rgba(31, 122, 74, 0.12), transparent 44%), + rgba(227, 207, 161, 0.72); + color: var(--document-paper-ink, #17120a); + font-family: var(--ui-font-display); + font-size: 15px; + font-weight: 900; + letter-spacing: 0.1em; +} + +.warehouse-document-section-index { + display: inline-grid; + place-items: center; + min-width: 28px; + height: 28px; + padding: 0 7px; + color: var(--document-paper-stamp, #1f7a4a); + border: 1.5px solid currentColor; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.36), transparent 58%), + rgba(232, 246, 232, 0.58); + font-size: 12px; + line-height: 1; + box-shadow: inset 0 0 0 1px rgba(255, 246, 223, 0.7); +} + +.warehouse-document-section-grid { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: repeat(3, minmax(180px, 1fr)); + gap: 0; + padding: 14px; +} + +.warehouse-document-section-grid > .stack-form, +.warehouse-document-section-grid > .double-field, +.warehouse-document-section-grid > .triple-field, +.warehouse-document-section-grid > .table-wrap, +.warehouse-document-section-grid > .feedback-banner, +.warehouse-document-section-grid > .workflow-tip, +.warehouse-document-section-grid > .inventory-live-summary, +.warehouse-document-section-grid > .work-order-settle-panel, +.warehouse-document-section-grid > .subsection-head, +.warehouse-document-section-grid > .form-field, +.warehouse-document-section-grid > label { + grid-column: 1 / -1; +} + +.warehouse-document-action-bar--shared { + justify-content: flex-end; + gap: 14px; +} + +.warehouse-document-action-bar--shared.warehouse-document-action-bar--with-state { + justify-content: space-between; + width: min(100%, 1080px); +} + +.warehouse-document-action-state { + min-width: 0; + color: var(--document-paper-muted, #6a5631); + font-size: 13px; + font-weight: 900; + line-height: 1.45; +} + +.warehouse-document-action-buttons { + display: inline-flex; + flex: 0 0 auto; + flex-wrap: wrap; + gap: 10px; + justify-content: flex-end; +} + +@media (max-width: 960px) { + .warehouse-document-form-meta, + .warehouse-document-section-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 620px) { + .warehouse-document-form .document-paper-shell { + padding-inline: 0; + } + + .warehouse-document-form .document-number-stamp { + min-width: min(196px, 100%); + } + + .warehouse-document-form-meta, + .warehouse-document-section-grid { + grid-template-columns: 1fr; + } + + .warehouse-document-action-bar--shared, + .warehouse-document-action-bar--shared.warehouse-document-action-bar--with-state { + align-items: stretch; + width: 100%; + } + + .warehouse-document-action-state, + .warehouse-document-action-buttons { + width: 100%; + } + + .warehouse-document-action-buttons { + display: grid; + grid-template-columns: 1fr; + } + + .warehouse-document-action-buttons > * { + width: 100%; + } +} + +/* Final warehouse ledger drawer UI override: keep this after global table and paper-document styles. */ +body .workflow-drawer.form-drawer:has(.warehouse-ledger-drawer) { + overflow-x: hidden !important; +} + +body .workflow-drawer.form-drawer:has(.warehouse-ledger-drawer) .form-drawer-body { + min-width: 0 !important; +} + +body .warehouse-ledger-drawer { + gap: 14px !important; +} + +body .warehouse-ledger-summary { + display: grid !important; + grid-template-columns: repeat(auto-fit, minmax(158px, 1fr)) !important; + gap: 10px !important; + min-width: 0 !important; +} + +body .warehouse-ledger-summary-card { + display: flex !important; + align-items: center !important; + justify-content: space-between !important; + gap: 10px !important; + min-width: 0 !important; + min-height: 64px !important; + padding: 12px 13px !important; +} + +body .warehouse-ledger-summary-card span, +body .warehouse-ledger-summary-card strong { + min-width: 0 !important; + writing-mode: horizontal-tb !important; + word-break: keep-all !important; + overflow-wrap: anywhere !important; +} + +body .warehouse-ledger-summary-card span { + flex: 0 0 auto !important; + margin: 0 !important; + white-space: nowrap !important; +} + +body .warehouse-ledger-summary-card strong { + flex: 1 1 auto !important; + margin: 0 !important; + text-align: right !important; + line-height: 1.2 !important; +} + +body .warehouse-ledger-filters { + display: grid !important; + grid-template-columns: 1fr !important; + align-items: stretch !important; + gap: 12px !important; + padding: 12px !important; + border: 1px solid rgba(37, 99, 235, 0.14) !important; + border-radius: 18px !important; + background: + radial-gradient(circle at 0 0, rgba(14, 165, 233, 0.08), transparent 38%), + linear-gradient(135deg, rgba(248, 251, 255, 0.98), rgba(241, 247, 255, 0.92)) !important; + box-shadow: + 0 14px 30px rgba(15, 23, 42, 0.07), + inset 0 1px 0 rgba(255, 255, 255, 0.96) !important; +} + +body .warehouse-ledger-filter-fields { + display: grid !important; + grid-template-columns: minmax(240px, 1.45fr) minmax(112px, 0.55fr) minmax(190px, 1fr) repeat(2, minmax(132px, 0.68fr)) !important; + gap: 12px !important; + min-width: 0 !important; +} + +body .warehouse-ledger-filter-fields .form-field { + min-width: 0 !important; +} + +body .warehouse-ledger-filter-fields input, +body .warehouse-ledger-filter-fields select { + min-width: 0 !important; +} + +body .warehouse-ledger-filter-actions { + display: inline-flex !important; + flex: 0 0 auto !important; + flex-wrap: nowrap !important; + align-items: end !important; + justify-self: end !important; + justify-content: flex-end !important; + gap: 8px !important; + min-width: max-content !important; +} + +body .warehouse-ledger-filter-actions > button { + display: inline-flex !important; + flex: 0 0 auto !important; + width: auto !important; + min-width: 72px !important; + max-width: none !important; + min-height: 38px !important; + padding: 0 14px !important; + border-radius: 13px !important; + white-space: nowrap !important; +} + +body .warehouse-ledger-filter-actions .warehouse-ledger-export-button { + position: relative !important; + isolation: isolate !important; + display: inline-flex !important; + flex: 0 0 auto !important; + align-items: center !important; + justify-content: center !important; + gap: 7px !important; + box-sizing: border-box !important; + overflow: hidden !important; + min-width: 126px !important; + min-height: 38px !important; + padding: 0 16px !important; + border-radius: 11px !important; + color: #1f5f45 !important; + border: 1px solid rgba(100, 116, 139, 0.22) !important; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(245, 248, 251, 0.94)) !important; + box-shadow: + 0 8px 16px rgba(15, 23, 42, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.88) !important; + font-family: var(--ui-font-body) !important; + font-size: 13px !important; + font-weight: 800 !important; + line-height: 1 !important; + letter-spacing: 0.01em !important; + white-space: nowrap !important; +} + +body .warehouse-ledger-filter-actions .warehouse-ledger-export-button::before { + content: "⇩" !important; + display: inline-grid !important; + place-items: center !important; + flex: 0 0 auto !important; + width: 18px !important; + height: 18px !important; + margin: 0 !important; + border: 1px solid rgba(22, 101, 52, 0.2) !important; + border-radius: 6px !important; + color: #166534 !important; + background: rgba(240, 253, 244, 0.86) !important; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.84) !important; + font-size: 11px !important; + line-height: 1 !important; +} + +body .warehouse-ledger-filter-actions .warehouse-ledger-export-button:hover:not(:disabled) { + transform: translateY(-1px) !important; + border-color: rgba(22, 101, 52, 0.28) !important; + background: + linear-gradient(180deg, rgba(255, 255, 255, 1), rgba(240, 253, 244, 0.84)) !important; + box-shadow: + 0 10px 20px rgba(15, 23, 42, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.96) !important; +} + +body .warehouse-ledger-table-wrap { + width: 100% !important; + max-width: 100% !important; + max-height: min(58vh, 620px) !important; + margin-top: 12px !important; + overflow: auto !important; + border: 1px solid rgba(148, 163, 184, 0.24) !important; + border-radius: 18px !important; + background: rgba(255, 255, 255, 0.94) !important; + box-shadow: + 0 16px 34px rgba(15, 23, 42, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.96) !important; +} + +body .warehouse-ledger-table.data-table.compact-table { + width: 100% !important; + min-width: max(100%, 1480px) !important; + max-width: none !important; + table-layout: fixed !important; + border-collapse: separate !important; + border-spacing: 0 !important; +} + +body .warehouse-ledger-table .ledger-col-time { + width: 132px !important; +} + +body .warehouse-ledger-table .ledger-col-direction { + width: 76px !important; +} + +body .warehouse-ledger-table .ledger-col-type { + width: 104px !important; +} + +body .warehouse-ledger-table .ledger-col-item { + width: 176px !important; +} + +body .warehouse-ledger-table .ledger-col-lot { + width: 172px !important; +} + +body .warehouse-ledger-table .ledger-col-qty, +body .warehouse-ledger-table .ledger-col-weight { + width: 116px !important; +} + +body .warehouse-ledger-table .ledger-col-price, +body .warehouse-ledger-table .ledger-col-amount { + width: 92px !important; +} + +body .warehouse-ledger-table .ledger-col-source { + width: 132px !important; +} + +body .warehouse-ledger-table .ledger-col-operator { + width: 96px !important; +} + +body .warehouse-ledger-table .ledger-col-logistics { + width: 116px !important; +} + +body .warehouse-ledger-table .ledger-col-remark { + width: 162px !important; +} + +body .warehouse-ledger-table .ledger-col-archive { + width: 166px !important; +} + +body .warehouse-ledger-table th, +body .warehouse-ledger-table td { + height: 56px !important; + min-width: 0 !important; + padding: 10px 12px !important; + border-bottom: 1px solid rgba(203, 213, 225, 0.7) !important; + color: #26364d !important; + line-height: 1.35 !important; + writing-mode: horizontal-tb !important; +} + +body .warehouse-ledger-table th { + position: sticky !important; + top: 0 !important; + z-index: 5 !important; + overflow: visible !important; + text-overflow: clip !important; + white-space: nowrap !important; + color: #34445d !important; + background: + linear-gradient(180deg, rgba(248, 251, 255, 0.99), rgba(237, 244, 255, 0.97)) !important; + font-family: var(--ui-font-display) !important; + font-size: 12px !important; + font-weight: 900 !important; + text-align: left !important; +} + +body .warehouse-ledger-table td { + overflow: hidden !important; + text-overflow: ellipsis !important; + white-space: nowrap !important; + background: rgba(255, 255, 255, 0.97) !important; +} + +body .warehouse-ledger-table tbody tr:hover td { + background: rgba(239, 246, 255, 0.94) !important; +} + +body .warehouse-ledger-table th:last-child, +body .warehouse-ledger-table td:last-child, +body .warehouse-ledger-table .warehouse-ledger-archive-cell { + position: sticky !important; + right: 0 !important; + overflow: visible !important; + z-index: 6 !important; + background: + linear-gradient(90deg, rgba(248, 251, 255, 0.98), rgba(255, 255, 255, 0.98)) !important; + box-shadow: -12px 0 24px rgba(15, 23, 42, 0.08) !important; +} + +body .warehouse-ledger-table th:last-child { + z-index: 8 !important; + background: + linear-gradient(180deg, rgba(248, 251, 255, 1), rgba(237, 244, 255, 0.98)) !important; +} + +body .warehouse-ledger-table .link-button { + display: inline-flex !important; + min-height: 24px !important; + margin-left: 6px !important; + padding: 0 7px !important; + border: 1px solid rgba(14, 116, 144, 0.2) !important; + border-radius: 8px !important; + background: rgba(240, 249, 255, 0.9) !important; + color: #0369a1 !important; + font-size: 12px !important; + font-weight: 850 !important; + text-decoration: none !important; +} + +body .warehouse-ledger-table .document-archive-actions { + display: grid !important; + grid-template-columns: 1fr !important; + align-items: stretch !important; + justify-content: stretch !important; + width: 138px !important; + min-width: 138px !important; + max-width: 138px !important; + gap: 5px !important; + padding: 6px !important; + overflow: visible !important; + border: 1px solid rgba(37, 99, 235, 0.16) !important; + border-radius: 13px !important; + background: + radial-gradient(circle at 12% 0, rgba(14, 165, 233, 0.1), transparent 38%), + linear-gradient(135deg, rgba(255, 255, 255, 0.99), rgba(241, 247, 255, 0.95)) !important; + box-shadow: + 0 8px 18px rgba(15, 23, 42, 0.07), + inset 0 1px 0 rgba(255, 255, 255, 0.96) !important; +} + +body .warehouse-ledger-table .document-archive-status { + justify-content: center !important; + min-height: 22px !important; + padding: 0 7px !important; + border: 1px solid rgba(100, 116, 139, 0.2) !important; + border-radius: 999px !important; + background: rgba(255, 255, 255, 0.76) !important; + font-size: 11px !important; + font-weight: 900 !important; + letter-spacing: 0.01em !important; + line-height: 1 !important; +} + +body .warehouse-ledger-table .document-archive-status-dot { + width: 6px !important; + height: 6px !important; + box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 12%, transparent) !important; +} + +body .warehouse-ledger-table .document-archive-buttons { + display: grid !important; + grid-template-columns: repeat(3, minmax(0, 1fr)) !important; + align-items: center !important; + justify-content: stretch !important; + gap: 4px !important; + width: 100% !important; +} + +body .warehouse-ledger-table .document-archive-button { + display: inline-grid !important; + place-items: center !important; + width: 100% !important; + min-width: 0 !important; + max-width: none !important; + min-height: 26px !important; + padding: 0 !important; + gap: 0 !important; + overflow: hidden !important; + border-width: 1px !important; + border-radius: 8px !important; + box-shadow: none !important; + font-size: 0 !important; + letter-spacing: 0 !important; + line-height: 1 !important; + white-space: nowrap !important; +} + +body .warehouse-ledger-table .document-archive-button:hover:not(:disabled) { + transform: translateY(-1px) !important; + box-shadow: 0 7px 14px rgba(15, 23, 42, 0.1) !important; +} + +body .warehouse-ledger-table .document-archive-icon { + display: inline-grid !important; + place-items: center !important; + min-width: 22px !important; + width: 22px !important; + height: 20px !important; + border: 0 !important; + border-radius: 7px !important; + background: rgba(255, 255, 255, 0.78) !important; + font-size: 10px !important; + line-height: 1 !important; +} + +@media (max-width: 1180px) { + body .warehouse-ledger-filter-actions { + justify-self: start !important; + justify-content: flex-start !important; + } +} + +@media (max-width: 820px) { + body .warehouse-ledger-filter-fields { + grid-template-columns: 1fr 1fr !important; + } +} + +@media (max-width: 560px) { + body .workflow-drawer.form-drawer:has(.warehouse-ledger-drawer) { + width: calc(100vw - 16px) !important; + max-height: calc(100dvh - 16px) !important; + padding: 12px !important; + } + + body .workflow-drawer.form-drawer:has(.warehouse-ledger-drawer) .workflow-drawer-head { + margin-bottom: 10px !important; + } + + body .workflow-drawer.form-drawer:has(.warehouse-ledger-drawer) .form-drawer-body { + margin-top: 10px !important; + padding: 12px !important; + } + + body .warehouse-ledger-summary { + grid-template-columns: 1fr !important; + } + + body .warehouse-ledger-summary-card { + min-height: 54px !important; + padding: 10px 12px !important; + } + + body .warehouse-ledger-summary-card strong { + font-size: 16px !important; + } + + body .warehouse-ledger-filter-fields, + body .warehouse-ledger-filter-actions { + display: grid !important; + grid-template-columns: 1fr !important; + min-width: 0 !important; + } + + body .warehouse-ledger-filter-actions > button { + width: 100% !important; + } +} + +/* Final warehouse document action contract: submit buttons live at the paper document bottom. */ +body .warehouse-document-action-bar { + position: relative !important; + right: auto !important; + bottom: auto !important; + z-index: 1 !important; + margin: 0 0 0 auto !important; + transform: none !important; +} + +@media (max-width: 620px) { + body .warehouse-document-action-bar, + body .warehouse-document-action-bar--shared, + body .warehouse-document-action-bar--shared.warehouse-document-action-bar--with-state { + right: auto !important; + bottom: auto !important; + width: 100% !important; + } +} diff --git a/frontend/src/utils/actionIconButtons.js b/frontend/src/utils/actionIconButtons.js new file mode 100644 index 0000000..ec3c3da --- /dev/null +++ b/frontend/src/utils/actionIconButtons.js @@ -0,0 +1,196 @@ +import { resolveButtonSemantics } from "./buttonSemantics"; +import { ICON_PATHS } from "./iconMap"; + +const LEGACY_ICON_CLASSES = [ + "icon-add", + "icon-edit", + "icon-delete", + "icon-close", + "icon-refresh", + "icon-reset", + "icon-save", + "icon-pass", + "icon-reject", + "icon-records", + "icon-dispose", + "icon-cost", + "icon-upload", + "icon-download", + "icon-prev", + "icon-next", + "icon-open", + "icon-generate" +]; + +const ACTION_BUTTON_SELECTOR = [ + "button.primary-button", + "button.ghost-button", + "button.link-button", + "button.workflow-close-button", + "button.small-button", + "button.contract-header-button", + "button.purchase-contract-button" +].join(","); + +const SKIP_CONTAINER_SELECTOR = [ + ".pagination-bar", + ".tags-view-container", + ".tags-context-menu", + ".user-menu-dropdown", + ".global-select-layer", + ".table-view-mode-switch", + ".nav-list" +].join(","); + +function buttonText(button) { + const label = button.querySelector(".semantic-action-label"); + return String(label?.textContent || button.textContent || "").replace(/\s+/g, " ").trim(); +} + +function isTransientLabel(text) { + return /中(\.{1,3}|…)?$/.test(text) || text.includes("提交中") || text.includes("上传中") || text.includes("导入中"); +} + +function shouldSkipButton(button) { + return ( + button.classList.contains("no-auto-icon") || + button.classList.contains("icon-button") || + button.classList.contains("table-view-mode-button") || + button.classList.contains("sortable-th-button") || + button.classList.contains("hamburger-container") || + button.classList.contains("assistant-bot-button") || + button.classList.contains("user-avatar-button") || + button.classList.contains("tags-view-open") || + button.classList.contains("tags-view-close") || + button.classList.contains("pagination-arrow") || + button.classList.contains("pagination-page") || + button.classList.contains("tag-remove") || + button.closest(SKIP_CONTAINER_SELECTOR) + ); +} + +function createSemanticIcon(iconName) { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.classList.add("semantic-action-svg"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("aria-hidden", "true"); + svg.setAttribute("focusable", "false"); + + (ICON_PATHS[iconName] || ICON_PATHS.cursorClick || ICON_PATHS.dashboard).forEach((pathValue) => { + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", pathValue); + path.setAttribute("fill", "none"); + path.setAttribute("stroke", "currentColor"); + path.setAttribute("stroke-linecap", "round"); + path.setAttribute("stroke-linejoin", "round"); + path.setAttribute("stroke-width", "1.85"); + svg.appendChild(path); + }); + + const icon = document.createElement("span"); + icon.className = "semantic-action-icon"; + icon.appendChild(svg); + return icon; +} + +function setButtonContent(button, semantics) { + let icon = button.querySelector(".semantic-action-icon"); + let label = button.querySelector(".semantic-action-label"); + + if (button.dataset.semanticActionIcon !== semantics.icon || !icon || !label) { + button.textContent = ""; + icon = createSemanticIcon(semantics.icon); + label = document.createElement("span"); + label.className = "semantic-action-label"; + button.append(icon, label); + } + + label.textContent = semantics.label; +} + +function clearSemanticButton(button) { + if (button.dataset.autoSemanticButton !== "true") { + return; + } + + const label = button.querySelector(".semantic-action-label")?.textContent || button.textContent; + button.querySelector(".semantic-action-icon")?.remove(); + button.querySelector(".semantic-action-label")?.replaceWith(document.createTextNode(label)); + button.classList.remove("semantic-action-button"); + delete button.dataset.autoSemanticButton; + delete button.dataset.buttonKind; + delete button.dataset.buttonTone; + delete button.dataset.semanticActionIcon; +} + +function enhanceButton(button) { + LEGACY_ICON_CLASSES.forEach((className) => button.classList.remove(className)); + + if (shouldSkipButton(button)) { + clearSemanticButton(button); + return; + } + + const text = buttonText(button); + if (!text || isTransientLabel(text)) { + return; + } + + const semantics = resolveButtonSemantics(text, button.className); + if (!semantics) { + clearSemanticButton(button); + return; + } + + button.classList.add("semantic-action-button"); + button.dataset.autoSemanticButton = "true"; + button.dataset.buttonKind = semantics.kind; + button.dataset.buttonTone = semantics.tone; + button.dataset.semanticActionIcon = semantics.icon; + setButtonContent(button, semantics); + button.setAttribute("aria-label", semantics.label); + if (!button.title || button.dataset.autoIconTitle === "true") { + button.title = semantics.label; + button.dataset.autoIconTitle = "true"; + } +} + +function scanActionButtons(root = document) { + if (root instanceof HTMLButtonElement && root.matches(ACTION_BUTTON_SELECTOR)) { + enhanceButton(root); + } + root.querySelectorAll?.(ACTION_BUTTON_SELECTOR).forEach(enhanceButton); +} + +export function setupActionIconButtons() { + scanActionButtons(); + + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === "characterData") { + const parent = mutation.target.parentElement; + if (parent) { + scanActionButtons(parent); + } + return; + } + + if (mutation.target instanceof Element) { + scanActionButtons(mutation.target); + } + mutation.addedNodes.forEach((node) => { + if (node instanceof Element) { + scanActionButtons(node); + } + }); + }); + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + characterData: true + }); + + return () => observer.disconnect(); +} diff --git a/frontend/src/utils/buttonSemantics.js b/frontend/src/utils/buttonSemantics.js new file mode 100644 index 0000000..6509975 --- /dev/null +++ b/frontend/src/utils/buttonSemantics.js @@ -0,0 +1,113 @@ +const EXACT_ACTIONS = new Map([ + ["刷新", { label: "刷新数据", icon: "refresh", tone: "neutral", kind: "tool" }], + ["查看", { label: "查看详情", icon: "view", tone: "info", kind: "table" }], + ["详情", { label: "查看详情", icon: "view", tone: "info", kind: "table" }], + ["编辑", { label: "编辑资料", icon: "edit", tone: "info", kind: "table" }], + ["删除", { label: "删除记录", icon: "delete", tone: "danger", kind: "danger" }], + ["移除", { label: "移除记录", icon: "delete", tone: "danger", kind: "danger" }], + ["增加一行", { label: "添加明细行", icon: "rowAdd", tone: "info", kind: "secondary" }], + ["新增工序", { label: "添加工序", icon: "rowAdd", tone: "info", kind: "secondary" }], + ["添加联系人", { label: "添加联系人", icon: "plus", tone: "info", kind: "secondary" }], + ["保存", { label: "保存信息", icon: "save", tone: "primary", kind: "primary" }], + ["取消", { label: "取消操作", icon: "close", tone: "neutral", kind: "secondary" }], + ["关闭", { label: "关闭窗口", icon: "close", tone: "neutral", kind: "secondary" }], + ["放大", { label: "放大视图", icon: "zoomIn", tone: "neutral", kind: "tool" }], + ["缩小", { label: "缩小视图", icon: "zoomOut", tone: "neutral", kind: "tool" }], + ["一屏展示", { label: "一屏展示", icon: "fitScreen", tone: "neutral", kind: "tool" }], + ["授权", { label: "人员授权", icon: "authorize", tone: "primary", kind: "primary" }], + ["放行", { label: "质检放行", icon: "check", tone: "success", kind: "success" }], + ["处置", { label: "处理退货", icon: "edit", tone: "warning", kind: "warning" }], + ["处置记录", { label: "查看处置记录", icon: "ledger", tone: "info", kind: "table" }] +]); + +const PARTIAL_ACTIONS = [ + { match: "保存授权", icon: "authorize", tone: "primary", kind: "primary" }, + { match: "保存单据", icon: "save", tone: "primary", kind: "primary" }, + { match: "保存", icon: "save", tone: "primary", kind: "primary" }, + { match: "确认", icon: "check", tone: "primary", kind: "primary" }, + { match: "登记", icon: "save", tone: "primary", kind: "primary" }, + { match: "提交", icon: "save", tone: "primary", kind: "primary" }, + { match: "新建", icon: "plus", tone: "primary", kind: "primary" }, + { match: "新增", icon: "plus", tone: "primary", kind: "primary", labelPrefix: "新建" }, + { match: "导入", icon: "import", tone: "primary", kind: "primary" }, + { match: "导出", icon: "export", tone: "success", kind: "tool" }, + { match: "下载", icon: "download", tone: "success", kind: "tool" }, + { match: "上传", icon: "upload", tone: "primary", kind: "primary" }, + { match: "打印", icon: "print", tone: "neutral", kind: "tool" }, + { match: "刷新", icon: "refresh", tone: "neutral", kind: "tool", explicitLabel: "刷新数据" }, + { match: "重置", icon: "refresh", tone: "neutral", kind: "tool" }, + { match: "搜索", icon: "search", tone: "info", kind: "tool" }, + { match: "查看", icon: "view", tone: "info", kind: "table" }, + { match: "详情", icon: "view", tone: "info", kind: "table" }, + { match: "编辑", icon: "edit", tone: "info", kind: "table" }, + { match: "质检放行", icon: "check", tone: "success", kind: "success" }, + { match: "合格", icon: "check", tone: "success", kind: "success" }, + { match: "放行", icon: "check", tone: "success", kind: "success" }, + { match: "驳回", icon: "reject", tone: "danger", kind: "danger" }, + { match: "不合格", icon: "reject", tone: "danger", kind: "danger" }, + { match: "拒绝", icon: "reject", tone: "danger", kind: "danger" }, + { match: "删除", icon: "delete", tone: "danger", kind: "danger" }, + { match: "移除", icon: "delete", tone: "danger", kind: "danger" }, + { match: "结单", icon: "check", tone: "warning", kind: "warning" }, + { match: "添加", icon: "rowAdd", tone: "info", kind: "secondary" }, + { match: "增加", icon: "rowAdd", tone: "info", kind: "secondary" }, + { match: "下一步", icon: "arrowRight", tone: "primary", kind: "primary" }, + { match: "上一步", icon: "arrowLeft", tone: "neutral", kind: "secondary" } +]; + +function normalizeLabel(label) { + return String(label || "").replace(/\s+/g, " ").trim(); +} + +function hasDangerClass(className) { + return /\b(danger|danger-ghost|error|reject)\b/i.test(String(className || "")); +} + +function normalizeExplicitLabel(label, rule) { + if (rule.explicitLabel) { + return rule.explicitLabel; + } + + if (rule.labelPrefix && label.startsWith(rule.match)) { + return `${rule.labelPrefix}${label.slice(rule.match.length)}`; + } + + return label; +} + +export function resolveButtonSemantics(label, className = "") { + const normalizedLabel = normalizeLabel(label); + if (!normalizedLabel) { + return null; + } + + if (hasDangerClass(className) && ["删除", "移除"].includes(normalizedLabel)) { + return EXACT_ACTIONS.get(normalizedLabel); + } + + const exact = EXACT_ACTIONS.get(normalizedLabel); + if (exact) { + return exact; + } + + const partial = PARTIAL_ACTIONS.find((rule) => normalizedLabel.includes(rule.match)); + if (partial) { + return { + label: normalizeExplicitLabel(normalizedLabel, partial), + icon: partial.icon, + tone: hasDangerClass(className) ? "danger" : partial.tone, + kind: hasDangerClass(className) ? "danger" : partial.kind + }; + } + + if (hasDangerClass(className)) { + return { + label: normalizedLabel, + icon: "warning", + tone: "danger", + kind: "danger" + }; + } + + return null; +} diff --git a/frontend/src/utils/buttonSemantics.test.js b/frontend/src/utils/buttonSemantics.test.js new file mode 100644 index 0000000..c8822d3 --- /dev/null +++ b/frontend/src/utils/buttonSemantics.test.js @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { resolveButtonSemantics } from "./buttonSemantics.js"; + +describe("resolveButtonSemantics", () => { + it("expands terse common action labels into clear ERP labels", () => { + assert.deepEqual(resolveButtonSemantics("刷新"), { + label: "刷新数据", + icon: "refresh", + tone: "neutral", + kind: "tool" + }); + assert.deepEqual(resolveButtonSemantics("查看"), { + label: "查看详情", + icon: "view", + tone: "info", + kind: "table" + }); + assert.deepEqual(resolveButtonSemantics("编辑"), { + label: "编辑资料", + icon: "edit", + tone: "info", + kind: "table" + }); + assert.deepEqual(resolveButtonSemantics("删除"), { + label: "删除记录", + icon: "delete", + tone: "danger", + kind: "danger" + }); + assert.deepEqual(resolveButtonSemantics("增加一行"), { + label: "添加明细行", + icon: "rowAdd", + tone: "info", + kind: "secondary" + }); + assert.deepEqual(resolveButtonSemantics("保存"), { + label: "保存信息", + icon: "save", + tone: "primary", + kind: "primary" + }); + }); + + it("keeps already explicit business labels while assigning icon and tone", () => { + assert.deepEqual(resolveButtonSemantics("新增采购订单"), { + label: "新建采购订单", + icon: "plus", + tone: "primary", + kind: "primary" + }); + assert.deepEqual(resolveButtonSemantics("保存授权"), { + label: "保存授权", + icon: "authorize", + tone: "primary", + kind: "primary" + }); + assert.deepEqual(resolveButtonSemantics("质检放行"), { + label: "质检放行", + icon: "check", + tone: "success", + kind: "success" + }); + assert.deepEqual(resolveButtonSemantics("保存产品需规"), { + label: "保存产品需规", + icon: "save", + tone: "primary", + kind: "primary" + }); + }); + + it("uses distinct tones for transfer actions instead of rendering every utility button alike", () => { + assert.deepEqual(resolveButtonSemantics("导入"), { + label: "导入", + icon: "import", + tone: "primary", + kind: "primary" + }); + assert.deepEqual(resolveButtonSemantics("导出"), { + label: "导出", + icon: "export", + tone: "success", + kind: "tool" + }); + assert.deepEqual(resolveButtonSemantics("下载模板"), { + label: "下载模板", + icon: "download", + tone: "success", + kind: "tool" + }); + }); + + it("treats danger classes as risk actions even when the label is short", () => { + assert.deepEqual(resolveButtonSemantics("移除", "ghost-button danger-ghost"), { + label: "移除记录", + icon: "delete", + tone: "danger", + kind: "danger" + }); + }); + + it("does not decorate business identifier links that are not actions", () => { + assert.equal(resolveButtonSemantics("产品需规00001", "link-button"), null); + }); +}); diff --git a/frontend/src/utils/codeRules.js b/frontend/src/utils/codeRules.js new file mode 100644 index 0000000..48b748a --- /dev/null +++ b/frontend/src/utils/codeRules.js @@ -0,0 +1,169 @@ +const 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" +}; + +function normalizeText(value) { + return String(value || "").trim(); +} + +export function normalizeCodeToken(value, maxLength = 24) { + const text = normalizeText(value).toUpperCase(); + if (!text) { + return ""; + } + + const chunks = []; + let current = ""; + let currentKind = ""; + + const flushCurrent = () => { + if (current) { + chunks.push(current); + } + current = ""; + currentKind = ""; + }; + + for (const char of text.replaceAll("×", "X").replaceAll("*", "X")) { + let kind = ""; + let tokenChar = ""; + + if (/^[A-Z0-9.]$/.test(char)) { + kind = "LATIN"; + tokenChar = char; + } else if (/^[\u4e00-\u9fff]$/.test(char)) { + const mapped = CHINESE_CODE_INITIALS[char]; + if (mapped) { + kind = "CJK"; + tokenChar = mapped; + } else { + kind = "CJK_RAW"; + tokenChar = char; + } + } else { + flushCurrent(); + continue; + } + + if (currentKind && currentKind !== kind) { + flushCurrent(); + } + currentKind = kind; + current += tokenChar; + } + + flushCurrent(); + return chunks.join("-").slice(0, maxLength).replace(/^[.-]+|[.-]+$/g, ""); +} + +function buildCodePrefix(parts, maxLength = 42) { + const tokens = parts.map((part) => normalizeCodeToken(part)).filter(Boolean); + if (!tokens.length) { + return ""; + } + return tokens.join("-").slice(0, maxLength).replace(/^[.-]+|[.-]+$/g, ""); +} + +export function buildNextProductSpecCode(products = []) { + let maxNo = 0; + for (const product of products || []) { + const code = normalizeText(product?.item_code); + const match = code.match(/^产品需规(\d{5})$/); + if (match) { + maxNo = Math.max(maxNo, Number(match[1])); + } + } + return `产品需规${String(maxNo + 1).padStart(5, "0")}`; +} + +export function buildProductCodePreview(form, savedCode = "", nextCode = "") { + if (normalizeText(savedCode)) { + return savedCode; + } + return normalizeText(nextCode) || "产品需规00001"; +} + +export function buildNextMaterialCode(materials = []) { + let maxNo = 0; + for (const material of materials || []) { + const code = normalizeText(material?.item_code); + const match = code.match(/^原材料(\d{5})$/); + if (match) { + maxNo = Math.max(maxNo, Number(match[1])); + } + } + return `原材料${String(maxNo + 1).padStart(5, "0")}`; +} + +export function buildMaterialCodePreview(form, savedCode = "", nextCode = "") { + if (normalizeText(savedCode)) { + return savedCode; + } + return normalizeText(nextCode) || "原材料00001"; +} + +export function buildBomCodePreview(productCode, savedCode = "") { + if (normalizeText(savedCode)) { + return savedCode; + } + const prefix = buildCodePrefix(["BOM", productCode || "产品编码"], 50) || "BOM-产品编码"; + return prefix; +} + +export function buildRouteCodePreview(productCode, savedCode = "") { + if (normalizeText(savedCode)) { + return savedCode; + } + const prefix = buildCodePrefix(["ROUTE", productCode || "产品编码"], 50) || "ROUTE-产品编码"; + return prefix; +} diff --git a/frontend/src/utils/dictionaries.js b/frontend/src/utils/dictionaries.js new file mode 100644 index 0000000..91d02a1 --- /dev/null +++ b/frontend/src/utils/dictionaries.js @@ -0,0 +1,347 @@ +export const STATUS_LABELS = { + ACTIVE: "启用", + ENABLED: "启用", + INACTIVE: "停用", + DISABLED: "停用", + OPEN: "未开始", + NEW: "新建", + DRAFT: "草稿", + SUBMITTED: "已提交", + APPROVED: "已审批", + PARTIAL: "部分完成", + PARTIAL_RECEIVED: "部分收货", + CLOSED: "已关闭", + CANCELED: "已取消", + CANCELLED: "已取消", + ORDERED: "已下单", + RECEIVED: "已收货", + RELEASED: "已下达", + IN_PROGRESS: "进行中", + READY_TO_RECEIPT: "待成品入库", + COMPLETED: "已完工", + SETTLED: "已结单", + DONE: "已完成", + PENDING: "待处理", + PENDING_QC: "待质检", + PASSED: "已放行", + REJECTED: "已拒收", + POSTED: "已过账", + AVAILABLE: "可用", + LOCKED: "已锁单", + DEPLETED: "已耗尽", + CONFIRMED: "已确认", + OVERRIDDEN: "已覆盖", + CREATED: "已创建", + IDLE: "空闲", + READY: "就绪", + ERROR: "异常", + FAILED: "失败", + EXPORTED: "已导出", + IMPORTED: "已导入", + SUPERSEDED: "已替代", + MINIAPP_ONLY: "小程序清单", + HAS_RETURN: "存在退货" +}; + +export const BOM_USAGE_LABELS = { + WEIGHT: "重量" +}; + +export const PURCHASE_CALC_MODE_LABELS = { + BY_WEIGHT: "按重量采购" +}; + +export const QUALITY_STATUS_LABELS = { + PENDING_QC: "待质检", + PASS: "已放行", + PASSED: "已放行", + REJECT: "已拒收", + REJECTED: "已拒收", + REWORK: "返工中", + SCRAP: "已报废", + PENDING: "待处理" +}; + +export const DISPOSAL_TYPE_LABELS = { + SCRAP: "报废", + REWORK: "返工" +}; + +export const INVENTORY_TXN_TYPE_LABELS = { + OPENING_IN: "期初入库", + PURCHASE_IN: "采购入库", + CUSTOMER_SUPPLIED_IN: "客供料入库", + MATERIAL_ISSUE: "生产领料", + PRODUCTION_OUT: "生产出库", + PRODUCTION_SURPLUS_IN: "生产余料入库", + OUTSOURCING_SURPLUS_IN: "委外余料入库", + WIP_IN: "产中入库", + WIP_OUT: "产中出库", + OUTSOURCING_IN: "委外入库", + OUTSOURCING_OUT: "委外出库", + PRODUCTION_FINISHED_IN: "生产入库", + REWORK_FINISHED_IN: "返工入库", + FG_IN: "成品入库", + SALES_OUT: "发货出库", + SCRAP_OUT: "报废出库", + PRODUCTION_SCRAP_IN: "生产废料入库", + REWORK_SCRAP_IN: "返工废料入库", + OUTSOURCING_SCRAP_IN: "委外废料入库", + SCRAP_SALE_OUT: "废料售卖出库", + PURCHASE_RETURN_OUT: "采购退货出库", + RETURN_IN: "退货入库", + RETURN_SCRAP_IN: "退货废料入库", + RETURN_OUT: "返工出库", + STOCKTAKE_GAIN: "盘库盘盈", + STOCKTAKE_LOSS: "盘库盘亏", + STOCKTAKE_NEW_LOT: "盘库新增批次" +}; + +export const SOURCE_DOC_TYPE_LABELS = { + OPENING_STOCK: "期初入库", + PURCHASE_RECEIPT: "采购到货", + PURCHASE_ORDER: "采购订单", + CUSTOMER_SUPPLIED_IN: "客供料入库", + WORK_ORDER: "生产工单", + FG_RECEIPT: "成品入库", + DELIVERY: "发货单", + SALES_ORDER: "销售订单", + PRODUCTION_OUT: "生产出库", + PRODUCTION_SURPLUS_IN: "生产余料入库", + OUTSOURCING_SURPLUS_IN: "委外余料入库", + WIP_IN: "产中入库", + WIP_OUT: "产中出库", + OUTSOURCING_IN: "委外入库", + OUTSOURCING_OUT: "委外出库", + PRODUCTION_FINISHED_IN: "生产入库", + REWORK_RECEIPT: "返工入库", + REWORK_SCRAP_IN: "返工废料入库", + SCRAP_OUT: "报废出库", + PRODUCTION_SCRAP_IN: "生产废料入库", + OUTSOURCING_SCRAP_IN: "委外废料入库", + SCRAP_SALE_OUT: "废料售卖出库", + PURCHASE_RETURN: "采购退货", + RETURN_ORDER: "客户退货", + RETURN_DISPOSITION: "退货处置", + RETURN_OUT: "返工出库", + STOCKTAKE: "盘库", + STOCKTAKE_ADJUSTMENT: "盘库调整" +}; + +export const WAREHOUSE_TYPE_LABELS = { + RAW: "原材料库", + SEMI: "半成品库", + FINISHED: "成品库", + AUX: "辅料库", + SCRAP: "废料库", + RETURN: "退货库" +}; + +export const ITEM_TYPE_LABELS = { + RAW_MATERIAL: "原材料", + FINISHED_GOOD: "成品", + PRODUCT: "产品", + SEMI_FINISHED: "半成品", + AUXILIARY: "辅料", + CONSUMABLE: "辅料", + SCRAP: "废料", + SPARE_PART: "备件" +}; + +export const PURCHASE_SOURCE_TYPE_LABELS = { + SALES_ORDER: "销售订单采购", + STOCKING: "备料采购" +}; + +export const MAINTENANCE_TYPE_LABELS = { + PERIODIC: "周期保养", + PREVENTIVE: "周期保养", + ROUTINE: "例行保养", + REPAIR: "维修保养", + INSPECTION: "点检保养", + EMERGENCY: "紧急维修", + MOULD: "模具保养", + MOLD: "模具保养" +}; + +export const MINIAPP_ROLE_LABELS = { + WORKER: "员工", + MANAGER: "经理", + ADMIN: "管理员" +}; + +export const STOCKTAKE_ROW_STATUS_LABELS = { + READY: "可确认", + ERROR: "异常", + IGNORED: "已忽略" +}; + +export const DASHBOARD_NODE_TYPE_LABELS = { + RAW_MATERIAL_LOT: "原材料库存批次", + MATERIAL_ISSUE_BATCH: "生产出库批次", + WORK_ORDER: "生产工单", + WORK_ORDER_OPERATION: "工单工序", + OPERATION_REPORT: "工序报工", + COMPLETION_RECEIPT: "成品入库", + DELIVERY: "销售出库", + SALES_ORDER: "销售订单", + SALES_ORDER_ITEM: "销售订单明细", + MRP_DEMAND: "物料需求", + PURCHASE_ORDER_ITEM: "采购订单明细", + PURCHASE_RECEIPT_ITEM: "到货入库明细", + SCRAP_REWORK: "报废返工", + RETURN: "客户退货", + RETURN_DISPOSITION: "退货处置" +}; + +export function normalizeDictionaryKey(value) { + return String(value ?? "").trim().toUpperCase(); +} + +export function isEnumLikeValue(value) { + const text = String(value ?? "").trim(); + return /^[A-Z][A-Z0-9_]*$/.test(text) || /^[a-z][a-z0-9_]*$/.test(text); +} + +export function dictionaryFallback(value, fallback = "-", unknownLabel = "未识别类型") { + if (value === null || value === undefined || value === "") { + return fallback; + } + return isEnumLikeValue(value) ? unknownLabel : String(value); +} + +export function getLabel(map, value, fallback = "-", unknownLabel = "未识别类型") { + if (value === null || value === undefined || value === "") { + return fallback; + } + const key = normalizeDictionaryKey(value); + return map[key] || map[value] || dictionaryFallback(value, fallback, unknownLabel); +} + +export function formatStatusLabel(value) { + return getLabel(STATUS_LABELS, value, "-", "未识别状态"); +} + +export function formatUsageTypeLabel(value) { + return getLabel(BOM_USAGE_LABELS, value, "-", "未识别用量类型"); +} + +export function formatPurchaseCalcModeLabel(value) { + return getLabel(PURCHASE_CALC_MODE_LABELS, value, "-", "未识别采购方式"); +} + +export function formatQualityStatusLabel(value) { + return getLabel(QUALITY_STATUS_LABELS, value, "-", "未识别质检状态"); +} + +export function formatDisposalTypeLabel(value) { + return getLabel(DISPOSAL_TYPE_LABELS, value, "-", "未识别处置方式"); +} + +export function formatInventoryTxnTypeLabel(value) { + return getLabel(INVENTORY_TXN_TYPE_LABELS, value, "-", "未识别流水类型"); +} + +export function formatSourceDocTypeLabel(value) { + return getLabel(SOURCE_DOC_TYPE_LABELS, value, "-", "未识别来源"); +} + +export function formatWarehouseTypeLabel(value) { + return getLabel(WAREHOUSE_TYPE_LABELS, value, "-", "未识别仓库"); +} + +export function formatItemTypeLabel(value) { + return getLabel(ITEM_TYPE_LABELS, value, "-", "未识别物料类型"); +} + +export function formatPurchaseSourceTypeLabel(value) { + return getLabel(PURCHASE_SOURCE_TYPE_LABELS, value, "-", "未识别采购来源"); +} + +export function formatMaintenanceTypeLabel(value) { + return getLabel(MAINTENANCE_TYPE_LABELS, value, "-", "未识别保养类型"); +} + +export function formatMiniappRoleLabel(value) { + return getLabel(MINIAPP_ROLE_LABELS, value, "-", "未识别角色"); +} + +export function formatStocktakeRowStatusLabel(value) { + return getLabel(STOCKTAKE_ROW_STATUS_LABELS, value, "-", "未识别行状态"); +} + +export function formatDashboardNodeTypeLabel(value) { + return getLabel(DASHBOARD_NODE_TYPE_LABELS, value, "-", "未识别节点类型"); +} + +export function formatStocktakeStatusLabel(status) { + return { + LOCKED: "盘库中", + EXPORTED: "已导出", + IMPORTED: "已导入", + CONFIRMED: "已确认", + CANCELED: "已取消" + }[normalizeDictionaryKey(status)] || dictionaryFallback(status, "-", "未识别盘库状态"); +} + +export function formatStocktakeDiffTypeLabel(type) { + return { + MATCH: "一致", + GAIN: "盘盈", + LOSS: "盘亏", + NEW_LOT: "新增批次", + MISSING: "缺失批次" + }[normalizeDictionaryKey(type)] || dictionaryFallback(type, "-", "未识别差异类型"); +} + +export function formatProductLabel(product) { + if (!product) { + return "-"; + } + const code = product.item_code || product.product_code; + const name = product.item_name || product.product_name; + const version = product.version_no || product.product_version_no; + const parts = [code, name]; + if (version) { + parts.push(version); + } + return parts.filter(Boolean).join(" · "); +} + +export function formatBomLabel(bom) { + if (!bom) { + return "-"; + } + return [bom.bom_code, bom.version_no].filter(Boolean).join(" · "); +} + +export function formatRouteLabel(route) { + if (!route) { + return "-"; + } + return [route.route_code, route.route_name, route.version_no].filter(Boolean).join(" · "); +} + +export function buildVersionedRows(rows, { codeKey, idKey }) { + const groups = new Map(); + + rows.forEach((row) => { + const code = row[codeKey]; + if (!groups.has(code)) { + groups.set(code, []); + } + groups.get(code).push(row); + }); + + return Array.from(groups.entries()) + .map(([code, versionRows]) => { + const sortedRows = [...versionRows].sort((a, b) => Number(b[idKey] || 0) - Number(a[idKey] || 0)); + const activeRow = sortedRows.find((row) => row.is_default) || sortedRows[0]; + return { + code, + active_id: activeRow?.[idKey], + rows: sortedRows + }; + }) + .sort((a, b) => String(a.code || "").localeCompare(String(b.code || ""), "zh-CN", { numeric: true })); +} diff --git a/frontend/src/utils/formatters.js b/frontend/src/utils/formatters.js new file mode 100644 index 0000000..6123df0 --- /dev/null +++ b/frontend/src/utils/formatters.js @@ -0,0 +1,35 @@ +export function formatDate(value) { + if (!value) { + return "-"; + } + return String(value).slice(0, 10); +} + +export function formatDateTime(value) { + if (!value) { + return "-"; + } + return String(value).replace("T", " ").slice(0, 16); +} + +export function formatAmount(value) { + return Number(value || 0).toLocaleString("zh-CN", { + minimumFractionDigits: 0, + maximumFractionDigits: 2 + }); +} + +export function formatQty(value) { + return Number(value || 0).toLocaleString("zh-CN", { + minimumFractionDigits: 0, + maximumFractionDigits: 3 + }); +} + +export function formatWeight(value) { + return `${formatQty(value)} kg`; +} + +export function emptyToNull(value) { + return value === "" || value === undefined ? null : value; +} diff --git a/frontend/src/utils/iconMap.js b/frontend/src/utils/iconMap.js new file mode 100644 index 0000000..8483bd0 --- /dev/null +++ b/frontend/src/utils/iconMap.js @@ -0,0 +1,55 @@ +export const ICON_PATHS = { + dashboard: ["M4 13h6v7H4z", "M14 4h6v16h-6z", "M4 4h6v5H4z"], + masterData: ["M5 5h14v4H5z", "M5 15h14v4H5z", "M8 9v6", "M16 9v6"], + order: ["M7 3h8l4 4v14H7z", "M15 3v5h5", "M9 12h8", "M9 16h6"], + warehouse: ["M4 9l8-5 8 5-8 5z", "M4 9v7l8 5 8-5V9", "M12 14v7"], + warehouseRaw: ["M4 8.5 12 4l8 4.5-8 4.5-8-4.5Z", "M4 12l8 4.5 8-4.5", "M4 15.5l8 4.5 8-4.5", "M8 6.2l8 4.6"], + warehouseSemi: ["M5 7h8.5a3.5 3.5 0 0 1 0 7H10", "M5 7v10h6", "M16 15.5l3 2.5-3 2.5", "M19 18h-8"], + warehouseFinished: ["M4 8.5 12 4l8 4.5v8.8L12 21l-8-3.7V8.5Z", "M4 8.5 12 13l8-4.5", "M12 13v8", "M9 16l1.7 1.7L15.5 13"], + warehouseAuxiliary: ["M5 18.5 14.5 9", "M13 7l4-4 4 4-4 4-4-4Z", "M4 20h7", "M15.5 14.5h4", "M17.5 12.5v4"], + warehouseScrap: ["M6 7h12l-1 13H7L6 7Z", "M9 7V4h6v3", "M4 7h16", "M9.5 11v5", "M14.5 11v5"], + warehouseReturn: ["M7 7h10l2 4H5l2-4Z", "M5 11v7h14v-7", "M9 15h6", "M10 13l-2 2 2 2", "M8 15h8"], + production: ["M12 3v3", "M12 18v3", "M4.8 7.2l2.1 2.1", "M17.1 16.7l2.1 2.1", "M3 12h3", "M18 12h3", "M7.2 19.2l2.1-2.1", "M16.7 6.9l2.1-2.1", "M8 12a4 4 0 1 0 8 0 4 4 0 0 0-8 0z"], + delivery: ["M3 7h11v10H3z", "M14 10h4l3 3v4h-7z", "M6.5 19a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z", "M17.5 19a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z"], + finance: ["M4 19V5", "M4 19h16", "M8 15l3-4 3 2 5-7"], + system: ["M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z", "M19.4 15a8 8 0 0 0 .1-5.8l-2.2.4a6.4 6.4 0 0 0-1.1-1.9l1.2-1.9a8 8 0 0 0-5.1-2.9l-.8 2.1a6.8 6.8 0 0 0-2.2 0l-.8-2.1a8 8 0 0 0-5.1 2.9l1.2 1.9a6.4 6.4 0 0 0-1.1 1.9l-2.2-.4a8 8 0 0 0 .1 5.8l2.1-.6a6.6 6.6 0 0 0 1.2 1.7l-1 2a8 8 0 0 0 5.1 2.8l.7-2.1a6.7 6.7 0 0 0 2.4 0l.7 2.1a8 8 0 0 0 5.1-2.8l-1-2a6.6 6.6 0 0 0 1.2-1.7z"], + plus: ["M12 5v14", "M5 12h14"], + minus: ["M5 12h14"], + save: ["M5 4h12l2 2v14H5z", "M8 4v6h8V4", "M8 16h8", "M10 19v-3"], + edit: ["M5 19h4l10-10a2.8 2.8 0 0 0-4-4L5 15z", "M13.5 6.5l4 4", "M4 21h16"], + delete: ["M4 7h16", "M9 7V4h6v3", "M7 7l1 13h8l1-13", "M10.5 11v5", "M13.5 11v5"], + view: ["M3 12s3.5-6 9-6 9 6 9 6-3.5 6-9 6-9-6-9-6z", "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"], + import: ["M12 3v10", "M8 9l4 4 4-4", "M5 15v3a3 3 0 0 0 3 3h8a3 3 0 0 0 3-3v-3"], + export: ["M12 21V11", "M8 15l4-4 4 4", "M5 9V6a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3v3"], + upload: ["M12 21V9", "M8 13l4-4 4 4", "M5 5h14"], + download: ["M12 3v12", "M8 11l4 4 4-4", "M5 19h14"], + authorize: ["M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z", "M3 21c0-3.4 2.7-6 6-6 1.4 0 2.7.4 3.7 1.1", "M17 14v7", "M13.5 17.5h7"], + check: ["M5 12.5l4.2 4.2L19 7"], + reject: ["M6 6l12 12", "M18 6L6 18"], + rowAdd: ["M4 6h10", "M4 12h8", "M4 18h10", "M17 10v8", "M13 14h8"], + zoomIn: ["M10.5 18a7.5 7.5 0 1 0 0-15 7.5 7.5 0 0 0 0 15z", "M16 16l5 5", "M10.5 7.5v6", "M7.5 10.5h6"], + zoomOut: ["M10.5 18a7.5 7.5 0 1 0 0-15 7.5 7.5 0 0 0 0 15z", "M16 16l5 5", "M7.5 10.5h6"], + fitScreen: ["M4 9V4h5", "M15 4h5v5", "M20 15v5h-5", "M9 20H4v-5", "M9 4 4 9", "M15 4l5 5", "M20 15l-5 5", "M4 15l5 5"], + arrowRight: ["M5 12h14", "M13 6l6 6-6 6"], + arrowLeft: ["M19 12H5", "M11 6l-6 6 6 6"], + print: ["M7 8V4h10v4", "M7 17H5a2 2 0 0 1-2-2v-3a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v3a2 2 0 0 1-2 2h-2", "M7 14h10v7H7z"], + cursorClick: ["M5 4l9 16 1.6-6.2L22 12z", "M13.5 13.5 19 19"], + refresh: ["M20 6v5h-5", "M4 18v-5h5", "M18.5 9A7 7 0 0 0 6.8 6.8L4 11", "M5.5 15a7 7 0 0 0 11.7 2.2L20 13"], + ledger: ["M6 4h12v16H6z", "M9 8h6", "M9 12h6", "M9 16h4"], + stocktake: ["M5 5h14v14H5z", "M8 9h8", "M8 13h5", "M8 17h3"], + close: ["M6 6l12 12", "M18 6L6 18"], + search: ["M10.5 18a7.5 7.5 0 1 0 0-15 7.5 7.5 0 0 0 0 15z", "M16 16l5 5"], + warning: ["M12 4l9 16H3z", "M12 9v5", "M12 17h.01"], + supplier: ["M5 4h14v16H5z", "M8 8h8", "M8 12h8", "M8 16h5"], + material: ["M6 8a3 3 0 1 0 0 6 3 3 0 0 0 0-6z", "M13 6h8v6h-8z", "M13 16h8", "M3 20c0-2.2 1.4-4 3-4s3 1.8 3 4"], + customer: ["M12 5a4 4 0 1 0 0 8 4 4 0 0 0 0-8z", "M5 21c0-3.3 3-6 7-6s7 2.7 7 6", "M17 8h4", "M17 12h4"], + productSpec: ["M5 4h14v16H5z", "M8 8h8", "M8 12h8", "M8 16h4"], + employee: ["M12 5a4 4 0 1 0 0 8 4 4 0 0 0 0-8z", "M5 21c0-3.3 3-6 7-6s7 2.7 7 6"], + purchaseOrder: ["M6 4h10l3 3v13H6z", "M16 4v4h3", "M8 11h5", "M8 15h4", "M14 16l2 2 4-5"], + receipt: ["M4 9l8-4 8 4-8 4z", "M4 13v4l8 4 8-4v-4", "M12 3v8", "M9 8l3 3 3-3"], + quality: ["M5 12l5 5 10-10", "M4 4h16v16H4z"], + workOrder: ["M7 4h10v16H7z", "M9 4V2h6v2", "M10 9h4", "M10 13h3", "M15.5 15.5a2 2 0 1 0 0 4 2 2 0 0 0 0-4z"], + operationReport: ["M5 5h14v14H5z", "M8 9h4", "M8 13h4", "M15 9l3 3-3 3", "M14 17h4"], + permissions: ["M12 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8z", "M5 21c0-3.3 3-6 7-6s7 2.7 7 6", "M17 5h4v4"], + extension: ["M7 4h6v4h4v6h-4v6H7v-6H3V8h4z", "M9 6V2", "M11 6V2", "M9 22v-4", "M11 22v-4"] +}; diff --git a/frontend/src/utils/inventoryOperationOptions.js b/frontend/src/utils/inventoryOperationOptions.js new file mode 100644 index 0000000..3638a1c --- /dev/null +++ b/frontend/src/utils/inventoryOperationOptions.js @@ -0,0 +1,175 @@ +function normalizeWarehouseType(row) { + return String(row?.warehouse_type || "").toUpperCase(); +} + +function numericValue(value) { + const num = Number(value || 0); + return Number.isFinite(num) ? num : 0; +} + +function hasAvailableStock(row, weightOnly) { + if (weightOnly) { + return numericValue(row.weight_available_kg) > 0; + } + return numericValue(row.qty_available) > 0 || numericValue(row.weight_available_kg) > 0; +} + +function mergeMeasureBasis(current, next) { + const normalizedCurrent = String(current || "").toUpperCase(); + const normalizedNext = String(next || "").toUpperCase(); + if (!normalizedCurrent) { + return normalizedNext; + } + if (!normalizedNext || normalizedCurrent === normalizedNext) { + return normalizedCurrent; + } + return "MIXED"; +} + +function isWeightOnlyTab(tab) { + return ["raw", "auxiliary", "scrap"].includes(String(tab || "")); +} + +function normalizeText(value) { + return String(value || "").trim(); +} + +function operationDisplayName(operation) { + const seqNoText = normalizeText(operation?.seq_no); + const operationName = normalizeText(operation?.operation_name); + const stampingMethod = normalizeText(operation?.stamping_method || operation?.stampingMethod); + const fallbackName = operationName && operationName !== seqNoText ? operationName : ""; + return stampingMethod || fallbackName; +} + +export function buildProductOperationOptions(processRoutes = []) { + return processRoutes + .filter((route) => String(route.status || "").toUpperCase() === "ACTIVE") + .flatMap((route) => + (route.operations || []) + .filter((operation) => String(operation.status || "").toUpperCase() === "ACTIVE") + .map((operation) => { + const seqNo = Number(operation.seq_no || 0); + const displayName = operationDisplayName(operation); + const operationLabel = `第${seqNo}道${displayName ? ` ${displayName}` : ""}`; + return { + option_key: `${Number(route.product_item_id)}::${Number(operation.route_operation_id)}`, + item_id: Number(route.product_item_id), + item_code: route.product_code || "", + item_name: route.product_name || "", + version_no: route.version_no || "", + route_id: Number(route.route_id), + source_doc_id: Number(route.route_id), + route_operation_id: Number(operation.route_operation_id), + source_line_id: Number(operation.route_operation_id), + seq_no: seqNo, + operation_name: operation.operation_name || operation.process_name || "", + stamping_method: operation.stamping_method || "", + operation_label: operationLabel, + source_material_summary: `产品:${route.product_name || "-"};已完成工序:${operationLabel}` + }; + }) + ) + .sort((left, right) => { + const codeOrder = String(left.item_code || "").localeCompare(String(right.item_code || ""), "zh-CN", { numeric: true }); + return codeOrder || Number(left.seq_no || 0) - Number(right.seq_no || 0); + }); +} + +export function buildOutboundItemOptions({ balances = [], warehouseId = "", warehouseType = "", activeInventoryTab = "" } = {}) { + const normalizedWarehouseType = String(warehouseType || "").toUpperCase(); + const selectedWarehouseId = Number(warehouseId || 0); + const weightOnly = isWeightOnlyTab(activeInventoryTab) || ["RAW", "AUX", "SCRAP"].includes(normalizedWarehouseType); + const groupByOperation = activeInventoryTab === "semi" || normalizedWarehouseType === "SEMI"; + const optionMap = new Map(); + + balances + .filter((row) => { + if (selectedWarehouseId && Number(row.warehouse_id || 0) !== selectedWarehouseId) { + return false; + } + if (!selectedWarehouseId && normalizedWarehouseType && normalizeWarehouseType(row) !== normalizedWarehouseType) { + return false; + } + return hasAvailableStock(row, weightOnly); + }) + .forEach((row) => { + const itemId = Number(row.item_id || 0); + if (!itemId) { + return; + } + const optionKey = groupByOperation ? `${itemId}::${Number(row.source_line_id || 0)}` : itemId; + const current = optionMap.get(optionKey); + if (!current) { + optionMap.set(optionKey, { + option_key: groupByOperation ? String(row.option_key || optionKey) : String(row.option_key || itemId), + item_id: itemId, + item_code: row.item_code || "", + item_name: row.item_name || "", + version_no: row.version_no || "", + source_line_id: row.source_line_id || null, + route_operation_id: row.source_line_id || row.route_operation_id || null, + route_id: row.source_doc_id || row.route_id || null, + operation_label: row.operation_label || row.source_material_summary || "", + qty_available: numericValue(row.qty_available), + weight_available_kg: numericValue(row.weight_available_kg), + measure_basis: String(row.measure_basis || "").toUpperCase(), + avg_unit_cost: numericValue(row.avg_unit_cost), + warehouse_id: row.warehouse_id, + warehouse_type: row.warehouse_type + }); + return; + } + current.qty_available += numericValue(row.qty_available); + current.weight_available_kg += numericValue(row.weight_available_kg); + current.measure_basis = mergeMeasureBasis(current.measure_basis, row.measure_basis); + const currentWeight = numericValue(current.weight_available_kg); + const rowWeight = numericValue(row.weight_available_kg); + if (rowWeight > 0 && currentWeight > 0) { + current.avg_unit_cost = + (numericValue(current.avg_unit_cost) * (currentWeight - rowWeight) + numericValue(row.avg_unit_cost) * rowWeight) / currentWeight; + } + }); + + return Array.from(optionMap.values()).sort((left, right) => { + const codeOrder = String(left.item_code || "").localeCompare(String(right.item_code || ""), "zh-CN", { numeric: true }); + return ( + codeOrder || + String(left.item_name || "").localeCompare(String(right.item_name || ""), "zh-CN", { numeric: true }) || + Number(left.source_line_id || 0) - Number(right.source_line_id || 0) + ); + }); +} + +export function buildGenericItemOptions({ + direction = "in", + activeInventoryTab = "raw", + activeWarehouseType = "", + warehouseId = "", + balances = [], + materials = [], + products = [], + productOperations = [] +} = {}) { + if (String(direction || "").toLowerCase() === "out") { + return buildOutboundItemOptions({ + balances, + warehouseId, + warehouseType: activeWarehouseType, + activeInventoryTab + }); + } + if (activeInventoryTab === "semi" || String(activeWarehouseType || "").toUpperCase() === "SEMI") { + return productOperations; + } + if (activeInventoryTab === "scrap" || String(activeWarehouseType || "").toUpperCase() === "SCRAP") { + return [...materials, ...products]; + } + if (["raw", "auxiliary"].includes(activeInventoryTab)) { + return materials; + } + if (activeInventoryTab === "return" || String(activeWarehouseType || "").toUpperCase() === "RETURN") { + return products; + } + return products; +} diff --git a/frontend/src/utils/notifications.js b/frontend/src/utils/notifications.js new file mode 100644 index 0000000..727c834 --- /dev/null +++ b/frontend/src/utils/notifications.js @@ -0,0 +1,197 @@ +import { nextTick, onBeforeUnmount, onMounted, reactive } from "vue"; + +const TOAST_DURATION_MS = 4200; +const toastState = reactive({ + messages: [] +}); + +let nextToastId = 1; + +function startToastTimer(message) { + if (message.duration <= 0 || message.remainingMs <= 0) { + removeToast(message.id); + return; + } + + message.startedAt = Date.now(); + message.timerId = window.setTimeout(() => { + removeToast(message.id); + }, message.remainingMs); +} + +function normalizeMessage(message) { + if (message === null || message === undefined) { + return ""; + } + + if (typeof message === "string") { + return message.trim(); + } + + return String(message).trim(); +} + +export function notify(message, type = "info", duration = TOAST_DURATION_MS) { + const text = normalizeMessage(message); + if (!text) { + return null; + } + + const id = nextToastId; + nextToastId += 1; + + const safeDuration = Number.isFinite(duration) && duration > 0 ? duration : TOAST_DURATION_MS; + const toastMessage = { + id, + type, + text, + createdAt: Date.now(), + duration: safeDuration, + remainingMs: safeDuration, + timerId: null, + startedAt: null, + paused: false + }; + + toastState.messages.push(toastMessage); + startToastTimer(toastMessage); + + return id; +} + +export function notifySuccess(message) { + return notify(message, "success"); +} + +export function notifyError(message) { + return notify(message, "error", 5600); +} + +export function notifyInfo(message) { + return notify(message, "info"); +} + +export function removeToast(id) { + const index = toastState.messages.findIndex((message) => message.id === id); + if (index >= 0) { + if (toastState.messages[index].timerId) { + window.clearTimeout(toastState.messages[index].timerId); + } + toastState.messages.splice(index, 1); + } +} + +export function pauseToast(id) { + const message = toastState.messages.find((item) => item.id === id); + if (!message || message.paused) { + return; + } + + if (message.timerId) { + window.clearTimeout(message.timerId); + } + + const elapsedMs = message.startedAt ? Date.now() - message.startedAt : 0; + message.remainingMs = Math.max(0, message.remainingMs - elapsedMs); + message.timerId = null; + message.paused = true; +} + +export function resumeToast(id) { + const message = toastState.messages.find((item) => item.id === id); + if (!message || !message.paused) { + return; + } + + message.paused = false; + startToastTimer(message); +} + +export function useNotifications() { + return { + messages: toastState.messages, + removeToast, + pauseToast, + resumeToast + }; +} + +function getNotificationType(element) { + if (element.classList.contains("error-banner") || element.classList.contains("form-error")) { + return "error"; + } + + return "success"; +} + +function collectMessageElements(root = document) { + const selectors = ".feedback-banner, .form-error"; + const elements = []; + + if (root instanceof Element && root.matches(selectors)) { + elements.push(root); + } + + elements.push(...Array.from(root.querySelectorAll?.(selectors) || [])); + return elements; +} + +export function useDomNotificationBridge() { + const lastTextByElement = new WeakMap(); + let observer = null; + + function pushFromElement(element) { + const text = normalizeMessage(element.textContent); + if (!text) { + return; + } + + if (lastTextByElement.get(element) === text) { + return; + } + + lastTextByElement.set(element, text); + notify(text, getNotificationType(element)); + } + + function scan(root = document) { + collectMessageElements(root).forEach(pushFromElement); + } + + onMounted(() => { + nextTick(() => scan()); + + observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === "characterData") { + const parent = mutation.target.parentElement; + if (parent) { + scan(parent); + } + return; + } + + if (mutation.target instanceof Element) { + scan(mutation.target); + } + + mutation.addedNodes.forEach((node) => { + if (node instanceof Element) { + scan(node); + } + }); + }); + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + characterData: true + }); + }); + + onBeforeUnmount(() => { + observer?.disconnect(); + observer = null; + }); +} diff --git a/frontend/src/utils/pagination.js b/frontend/src/utils/pagination.js new file mode 100644 index 0000000..72c0f42 --- /dev/null +++ b/frontend/src/utils/pagination.js @@ -0,0 +1,40 @@ +import { computed, ref, watch } from "vue"; + +export const DEFAULT_PAGE_SIZE = 10; +export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100]; + +export function usePagination(rowsRef, initialPageSize = DEFAULT_PAGE_SIZE) { + const page = ref(1); + const pageSize = ref(Number(initialPageSize || DEFAULT_PAGE_SIZE)); + const total = computed(() => rowsRef.value.length); + const pageCount = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value))); + const rows = computed(() => { + const start = (page.value - 1) * pageSize.value; + return rowsRef.value.slice(start, start + pageSize.value); + }); + + watch(rowsRef, () => { + page.value = 1; + }); + + watch(pageSize, () => { + page.value = 1; + }); + + watch([total, page, pageSize], () => { + if (page.value > pageCount.value) { + page.value = pageCount.value; + } + if (page.value < 1) { + page.value = 1; + } + }); + + return { + page, + pageSize, + total, + pageCount, + rows + }; +} diff --git a/frontend/src/utils/returnTraceFormatters.js b/frontend/src/utils/returnTraceFormatters.js new file mode 100644 index 0000000..2d725f6 --- /dev/null +++ b/frontend/src/utils/returnTraceFormatters.js @@ -0,0 +1,57 @@ +import { formatQty, formatWeight } from "./formatters.js"; + +function compactText(value, fallback = "-") { + const text = String(value || "").trim(); + return text || fallback; +} + +function sourceMaterialTrace(line) { + const code = compactText(line?.source_material_code, ""); + const name = compactText(line?.source_material_name, ""); + const lotNo = compactText(line?.source_material_lot_no || line?.source_material_sub_batch_no, ""); + const traceParts = [code, name, lotNo].filter(Boolean); + return traceParts.length ? traceParts.join(" · ") : "来源材料"; +} + +function returnableText(line) { + return `${formatQty(line?.returnable_qty)} / ${formatWeight(line?.returnable_weight_kg)}`; +} + +function returnedText(line) { + return `${formatQty(line?.return_qty)} / ${formatWeight(line?.return_weight_kg)}`; +} + +export function formatReturnDeliveryLineLabel(line) { + if (!line) { + return "请选择发货批次"; + } + return [ + compactText(line.customer_name, "客户"), + compactText(line.product_name, "产品"), + sourceMaterialTrace(line), + `可退 ${returnableText(line)}` + ].join(" · "); +} + +export function formatReturnReworkItemLabel(item) { + if (!item) { + return "请选择待返工退货明细"; + } + return [ + compactText(item.product_name, "产品"), + sourceMaterialTrace(item), + `可返工 ${returnedText(item)}` + ].join(" · "); +} + +export function formatReturnTraceSummary(line) { + if (!line) { + return "请选择发货批次"; + } + return [ + `客户:${compactText(line.customer_name, "客户")}`, + `产品:${compactText(line.product_name, "产品")}`, + `原材料库存批次:${sourceMaterialTrace(line)}`, + `可退:${returnableText(line)}` + ].join(";"); +} diff --git a/frontend/src/utils/specialAdjustmentRules.js b/frontend/src/utils/specialAdjustmentRules.js new file mode 100644 index 0000000..cef73de --- /dev/null +++ b/frontend/src/utils/specialAdjustmentRules.js @@ -0,0 +1,66 @@ +export function normalizeWarehouseType(value) { + return String(value || "").trim().toUpperCase(); +} + +function numericValue(value) { + const numberValue = Number(value || 0); + return Number.isFinite(numberValue) ? numberValue : 0; +} + +export function specialAdjustmentDirectionLabel(direction) { + return String(direction || "").toUpperCase() === "OUT" ? "特殊出库" : "特殊入库"; +} + +export function specialAdjustmentMeasureMode(warehouseType, row = {}) { + const normalizedWarehouseType = normalizeWarehouseType(warehouseType); + if (["RAW", "SCRAP"].includes(normalizedWarehouseType)) { + return "WEIGHT"; + } + if (normalizedWarehouseType === "AUX") { + return "QTY"; + } + if (["FINISHED", "RETURN"].includes(normalizedWarehouseType)) { + return "QTY_WITH_WEIGHT"; + } + if (normalizedWarehouseType === "SEMI") { + const qty = numericValue(row.remaining_qty) || numericValue(row.qty_available); + const weight = numericValue(row.remaining_weight_kg) || numericValue(row.weight_available_kg); + if (qty > 0 && weight <= 0) { + return "QTY"; + } + if (weight > 0 && qty <= 0) { + return "WEIGHT"; + } + return "SEMI_SELECT"; + } + return "QTY_WITH_WEIGHT"; +} + +export function specialAdjustmentOperationKeys() { + return { + raw: { + in: "raw-special-in", + out: "raw-special-out" + }, + semi: { + in: "semi-special-in", + out: "semi-special-out" + }, + finished: { + in: "finished-special-in", + out: "finished-special-out" + }, + auxiliary: { + in: "aux-special-in", + out: "aux-special-out" + }, + scrap: { + in: "scrap-special-in", + out: "scrap-special-out" + }, + return: { + in: "return-special-in", + out: "return-special-out" + } + }; +} diff --git a/frontend/src/utils/statusBadges.js b/frontend/src/utils/statusBadges.js new file mode 100644 index 0000000..94246c3 --- /dev/null +++ b/frontend/src/utils/statusBadges.js @@ -0,0 +1,170 @@ +import { getStatusLabel, getStatusTone, normalizeStatusValue } from "./statusToneMap"; + +const STATUS_TONE_CLASS_PREFIX = "status-tone-"; + +function isStatusLikeHeader(label) { + if (!label) { + return false; + } + + return ( + label.includes("状态") || + label.includes("同步") || + label.includes("审核") || + label.includes("检验结果") || + label.includes("质检结果") || + label.includes("处置方式") || + label === "方式" + ); +} + +function clearAutoStatusElement(element) { + const previousAutoTone = element.dataset.autoStatusTone; + if (previousAutoTone) { + element.classList.remove(`${STATUS_TONE_CLASS_PREFIX}${previousAutoTone}`); + delete element.dataset.autoStatusTone; + } + element.classList.remove("status-badge-auto", "status-value-cell"); +} + +function markStatusElement(element, value) { + const tone = getStatusTone(value); + const toneClass = tone ? `${STATUS_TONE_CLASS_PREFIX}${tone}` : ""; + clearAutoStatusElement(element); + element.classList.toggle("status-badge-auto", Boolean(tone)); + element.classList.toggle("status-value-cell", element.tagName === "TD" && Boolean(tone)); + if (tone) { + element.classList.add(toneClass); + element.dataset.autoStatusTone = tone; + const label = getStatusLabel(value); + if (element.textContent !== label) { + element.textContent = label; + } + } +} + +function hasRecognizedStatus(value) { + const rawValue = normalizeStatusValue(value); + if (!rawValue || rawValue === "-") { + return false; + } + const label = getStatusLabel(value); + return getStatusTone(value) !== "info" || normalizeStatusValue(label) !== rawValue; +} + +function wrapPlainStatusCell(cell) { + if (cell.dataset.statusWrapped === "1") { + if (cell.firstElementChild) { + return cell.firstElementChild; + } + delete cell.dataset.statusWrapped; + } + + if (cell.children.length > 0) { + return cell.children.length === 1 ? cell.firstElementChild : cell; + } + + const value = cell.textContent.trim(); + if (!getStatusTone(value)) { + return cell; + } + + const badge = document.createElement("span"); + badge.textContent = getStatusLabel(value); + cell.textContent = ""; + cell.appendChild(badge); + cell.dataset.statusWrapped = "1"; + return badge; +} + +function enhanceTableStatuses(root) { + root.querySelectorAll("table").forEach((table) => { + const headers = Array.from(table.querySelectorAll("thead th")); + const statusColumnIndexes = headers + .map((header, index) => ({ index, label: normalizeStatusValue(header.textContent) })) + .filter((item) => isStatusLikeHeader(item.label)) + .map((item) => item.index); + + if (!statusColumnIndexes.length) { + return; + } + + table.querySelectorAll("tbody tr").forEach((row) => { + const cells = Array.from(row.children).filter((cell) => cell.tagName === "TD"); + statusColumnIndexes.forEach((index) => { + const cell = cells[index]; + if (!cell || cell.querySelector("button, a, input, select, textarea")) { + return; + } + const target = wrapPlainStatusCell(cell); + markStatusElement(target, target.textContent); + }); + }); + }); +} + +function enhanceDetailStatuses(root) { + root.querySelectorAll(".detail-item, .detail-block").forEach((item) => { + const label = normalizeStatusValue(item.querySelector("span")?.textContent || ""); + if (!label.includes("状态")) { + return; + } + const valueElement = item.querySelector("strong") || item; + markStatusElement(valueElement, valueElement.textContent); + }); + + root.querySelectorAll("dl").forEach((list) => { + const terms = Array.from(list.querySelectorAll("dt")); + terms.forEach((term) => { + if (!normalizeStatusValue(term.textContent).includes("状态")) { + return; + } + const valueElement = term.nextElementSibling; + if (valueElement) { + markStatusElement(valueElement, valueElement.textContent); + } + }); + }); + + root.querySelectorAll(".table-chip, .table-badge, .status-chip").forEach((element) => { + if (hasRecognizedStatus(element.textContent)) { + markStatusElement(element, element.textContent); + } else { + clearAutoStatusElement(element); + } + }); +} + +export function enhanceStatusBadges(root = document) { + enhanceTableStatuses(root); + enhanceDetailStatuses(root); +} + +export function setupStatusBadgeEnhancer() { + let frame = 0; + + const scheduleEnhance = () => { + if (frame) { + return; + } + frame = window.requestAnimationFrame(() => { + frame = 0; + enhanceStatusBadges(document); + }); + }; + + const observer = new MutationObserver(scheduleEnhance); + observer.observe(document.body, { + childList: true, + subtree: true, + characterData: true + }); + scheduleEnhance(); + + return () => { + if (frame) { + window.cancelAnimationFrame(frame); + } + observer.disconnect(); + }; +} diff --git a/frontend/src/utils/statusToneMap.js b/frontend/src/utils/statusToneMap.js new file mode 100644 index 0000000..a3c5252 --- /dev/null +++ b/frontend/src/utils/statusToneMap.js @@ -0,0 +1,192 @@ +const EMPTY_STATUS_VALUES = new Set(["", "-"]); + +export const STATUS_LABELS = { + ACTIVE: "启用", + INACTIVE: "停用", + AVAILABLE: "可用", + CREATED: "已创建", + RELEASED: "已下达", + ORDERED: "已下单", + IN_PROGRESS: "进行中", + PARTIAL: "部分完成", + PENDING: "待处理", + PENDING_QC: "待质检", + OPEN: "待处理", + LOCKED: "锁定", + PAUSED: "暂停", + READY_TO_RECEIPT: "待成品入库", + HAS_RETURN: "存在退货", + PASSED: "已通过", + PASS: "通过", + COMPLETED: "已完成", + SETTLED: "已结单", + DONE: "已完成", + CONFIRMED: "已确认", + POSTED: "已过账", + RECEIVED: "已收货", + REJECTED: "已拒收", + REJECT: "拒收", + SCRAP: "报废", + DEPLETED: "已耗尽", + CLOSED: "已关闭", + REWORK: "返工", + SUPERSEDED: "已替代", + OVERRIDDEN: "已覆盖", + FAILED: "失败", + ERROR: "异常", + EXPIRED: "已过期", + APPROVED: "已审核", + AUDIT_FAILED: "审核失败" +}; + +export const STATUS_TONE_GROUPS = { + success: [ + "ACTIVE", + "AVAILABLE", + "PASSED", + "PASS", + "COMPLETED", + "SETTLED", + "DONE", + "CONFIRMED", + "POSTED", + "RECEIVED", + "APPROVED", + "启用", + "正常", + "成功", + "可用", + "已放行", + "放行", + "通过", + "已通过", + "已完成", + "已完工", + "已结单", + "结单", + "已确认", + "已过账", + "已收货", + "已入库", + "已审核", + "审核通过", + "已同步", + "同步完成", + "已匹配", + "匹配成功", + "在生产" + ], + primary: [ + "CREATED", + "RELEASED", + "ORDERED", + "IN_PROGRESS", + "PARTIAL", + "已创建", + "已下达", + "已下单", + "进行中", + "部分完成", + "小程序清单" + ], + warning: [ + "PENDING", + "PENDING_QC", + "OPEN", + "LOCKED", + "PAUSED", + "REWORK", + "READY_TO_RECEIPT", + "待处理", + "待质检", + "待放行", + "未开始", + "锁定", + "锁库", + "锁单", + "暂停", + "返工", + "返工中", + "待成品入库", + "待审核", + "待同步", + "未同步", + "未匹配", + "待匹配", + "未关联" + ], + orange: [ + "HAS_RETURN", + "存在退货", + "快到期", + "即将到期", + "临期" + ], + danger: [ + "INACTIVE", + "REJECTED", + "REJECT", + "SCRAP", + "DEPLETED", + "CLOSED", + "SUPERSEDED", + "OVERRIDDEN", + "FAILED", + "ERROR", + "EXPIRED", + "AUDIT_FAILED", + "停用", + "异常", + "失败", + "报废", + "已报废", + "拒收", + "已拒收", + "已耗尽", + "已关闭", + "已替代", + "已覆盖", + "已过期", + "已驳回", + "驳回", + "审核失败", + "同步失败", + "匹配失败" + ], + neutral: ["多状态", "未知", "未创建账号", "未命名", "无"], + info: ["INFO", "提示", "信息"] +}; + +export function normalizeStatusValue(value) { + return String(value ?? "") + .replace(/[::]/g, "") + .replace(/\s+/g, "") + .trim(); +} + +function normalizeStatusKey(value) { + return normalizeStatusValue(value).toUpperCase(); +} + +export function getStatusLabel(value) { + const normalizedValue = normalizeStatusValue(value); + if (EMPTY_STATUS_VALUES.has(normalizedValue)) { + return normalizedValue || "-"; + } + return STATUS_LABELS[normalizeStatusKey(normalizedValue)] || normalizedValue; +} + +export function getStatusTone(value) { + const normalizedValue = normalizeStatusValue(value); + if (EMPTY_STATUS_VALUES.has(normalizedValue)) { + return ""; + } + + const normalizedKey = normalizeStatusKey(normalizedValue); + for (const [tone, values] of Object.entries(STATUS_TONE_GROUPS)) { + if (values.some((item) => normalizeStatusValue(item) === normalizedValue || normalizeStatusKey(item) === normalizedKey)) { + return tone; + } + } + return "info"; +} diff --git a/frontend/src/utils/stocktakeWorkflow.js b/frontend/src/utils/stocktakeWorkflow.js new file mode 100644 index 0000000..872b49d --- /dev/null +++ b/frontend/src/utils/stocktakeWorkflow.js @@ -0,0 +1,121 @@ +const ACTIVE_STOCKTAKE_STATUSES = new Set(["LOCKED", "EXPORTED", "IMPORTED"]); +const STOCKTAKE_SUMMARY_COUNT_KEYS = [ + "match_count", + "gain_count", + "loss_count", + "new_lot_count", + "missing_count", + "error_count" +]; + +export function normalizeStocktakeStatus(status) { + return String(status || "").trim().toUpperCase(); +} + +export function stocktakeSummaryLineCount(summary = {}) { + return STOCKTAKE_SUMMARY_COUNT_KEYS.reduce((total, key) => total + Number(summary?.[key] || 0), 0); +} + +export function isEmptyStocktakeRead(stocktake) { + const summary = stocktake?.summary; + const hasKnownSummaryCount = + summary && + typeof summary === "object" && + STOCKTAKE_SUMMARY_COUNT_KEYS.some((key) => Object.prototype.hasOwnProperty.call(summary, key)); + return Boolean(hasKnownSummaryCount) && stocktakeSummaryLineCount(summary) === 0; +} + +export function canContinueStocktake(row) { + return ACTIVE_STOCKTAKE_STATUSES.has(normalizeStocktakeStatus(row?.status)); +} + +export function nextStocktakePanel(stocktake) { + const status = normalizeStocktakeStatus(stocktake?.status); + if (!stocktake) return "overview"; + if (status === "LOCKED") return "export"; + if (status === "EXPORTED") return isEmptyStocktakeRead(stocktake) ? "diff" : "import"; + if (status === "IMPORTED") return "diff"; + return "history"; +} + +export function findActiveStocktakeForWarehouse(historyRows = [], warehouseId) { + const id = Number(warehouseId || 0); + if (!id) return null; + return ( + historyRows.find( + (row) => + canContinueStocktake(row) && + Array.isArray(row.warehouses) && + row.warehouses.some((warehouse) => Number(warehouse.warehouse_id) === id) + ) || null + ); +} + +export function stocktakeBelongsToWarehouse(row, warehouseId) { + const id = Number(warehouseId || 0); + if (!id || !Array.isArray(row?.warehouses)) return false; + return row.warehouses.some((warehouse) => Number(warehouse.warehouse_id) === id); +} + +export function filterStocktakesForWarehouse(historyRows = [], warehouseId) { + return historyRows.filter((row) => stocktakeBelongsToWarehouse(row, warehouseId)); +} + +export function findOnlyActiveStocktake(historyRows = []) { + const rows = historyRows.filter((row) => canContinueStocktake(row)); + return rows.length === 1 ? rows[0] : null; +} + +export function hasBlockingDiffErrors(lines = []) { + return lines.some((line) => String(line?.row_status || "").toUpperCase() === "ERROR"); +} + +export function hasStocktakeDiff(line) { + const diffType = normalizeStocktakeStatus(line?.diff_type || "MATCH"); + return diffType !== "MATCH" || String(line?.row_status || "").toUpperCase() === "ERROR"; +} + +export function splitDiffLines(lines = [], filter = "ALL") { + const normalizedFilter = normalizeStocktakeStatus(filter || "ALL"); + return [...lines] + .filter((line) => { + if (normalizedFilter === "ERROR") return String(line?.row_status || "").toUpperCase() === "ERROR"; + if (normalizedFilter === "DIFF") return hasStocktakeDiff(line); + return true; + }) + .sort((left, right) => { + const leftError = String(left?.row_status || "").toUpperCase() === "ERROR" ? 0 : 1; + const rightError = String(right?.row_status || "").toUpperCase() === "ERROR" ? 0 : 1; + if (leftError !== rightError) return leftError - rightError; + const leftMatch = normalizeStocktakeStatus(left?.diff_type) === "MATCH" ? 1 : 0; + const rightMatch = normalizeStocktakeStatus(right?.diff_type) === "MATCH" ? 1 : 0; + if (leftMatch !== rightMatch) return leftMatch - rightMatch; + return Number(left?.line_no || left?.id || 0) - Number(right?.line_no || right?.id || 0); + }); +} + +export function diffTone(line) { + if (String(line?.row_status || "").toUpperCase() === "ERROR") return "error"; + const diffType = normalizeStocktakeStatus(line?.diff_type || "MATCH"); + if (diffType === "GAIN") return "gain"; + if (diffType === "LOSS") return "loss"; + if (diffType === "NEW_LOT") return "new"; + if (diffType === "MISSING") return "missing"; + return "match"; +} + +export function warehouseStocktakeStatus(warehouse, historyRows = []) { + const activeStocktake = findActiveStocktakeForWarehouse(historyRows, warehouse?.id); + const status = normalizeStocktakeStatus(activeStocktake?.status); + return { + locked: Boolean(activeStocktake), + activeStocktake, + status, + label: + { + LOCKED: "盘库中", + EXPORTED: "已导出待导入", + IMPORTED: "已导入待确认" + }[status] || "可盘库" + }; +} diff --git a/frontend/src/utils/tableControls.js b/frontend/src/utils/tableControls.js new file mode 100644 index 0000000..390f29d --- /dev/null +++ b/frontend/src/utils/tableControls.js @@ -0,0 +1,170 @@ +import { computed, ref } from "vue"; + +import { + BOM_USAGE_LABELS, + DISPOSAL_TYPE_LABELS, + INVENTORY_TXN_TYPE_LABELS, + ITEM_TYPE_LABELS, + MAINTENANCE_TYPE_LABELS, + MINIAPP_ROLE_LABELS, + PURCHASE_CALC_MODE_LABELS, + PURCHASE_SOURCE_TYPE_LABELS, + QUALITY_STATUS_LABELS, + SOURCE_DOC_TYPE_LABELS, + STATUS_LABELS, + STOCKTAKE_ROW_STATUS_LABELS, + WAREHOUSE_TYPE_LABELS, + normalizeDictionaryKey +} from "./dictionaries"; + +const SEARCH_LABEL_MAPS = [ + STATUS_LABELS, + QUALITY_STATUS_LABELS, + DISPOSAL_TYPE_LABELS, + INVENTORY_TXN_TYPE_LABELS, + SOURCE_DOC_TYPE_LABELS, + BOM_USAGE_LABELS, + PURCHASE_CALC_MODE_LABELS, + WAREHOUSE_TYPE_LABELS, + ITEM_TYPE_LABELS, + PURCHASE_SOURCE_TYPE_LABELS, + MAINTENANCE_TYPE_LABELS, + MINIAPP_ROLE_LABELS, + STOCKTAKE_ROW_STATUS_LABELS +]; + +function normalizeValue(value) { + if (value === null || value === undefined) { + return ""; + } + return String(value).toLowerCase(); +} + +function getFieldValue(row, field) { + if (typeof field === "function") { + return field(row); + } + return row?.[field]; +} + +function getMappedLabels(value) { + if (typeof value !== "string") { + return []; + } + + const normalizedKey = normalizeDictionaryKey(value); + return SEARCH_LABEL_MAPS.map((map) => map[normalizedKey] || map[value]).filter(Boolean); +} + +function collectSearchValues(value, seen = new WeakSet()) { + if (value === null || value === undefined) { + return []; + } + + if (["string", "number", "boolean", "bigint"].includes(typeof value)) { + return [value, ...getMappedLabels(value)]; + } + + if (value instanceof Date) { + return [value.toISOString()]; + } + + if (typeof value !== "object") { + return [value]; + } + + if (seen.has(value)) { + return []; + } + + seen.add(value); + + if (Array.isArray(value)) { + return value.flatMap((item) => collectSearchValues(item, seen)); + } + + return Object.values(value).flatMap((item) => collectSearchValues(item, seen)); +} + +function buildSearchFields(searchFields, sortOptions) { + const fields = [...searchFields, ...sortOptions.map((option) => option.field || option.key).filter(Boolean)]; + const primitiveKeys = new Set(); + + return fields.filter((field) => { + if (typeof field === "function") { + return true; + } + + const key = String(field || ""); + if (!key || primitiveKeys.has(key)) { + return false; + } + + primitiveKeys.add(key); + return true; + }); +} + +function buildSearchText(row, fields, searchAllRowFields) { + const values = searchAllRowFields || !fields.length + ? collectSearchValues(row) + : fields.flatMap((field) => collectSearchValues(getFieldValue(row, field))); + + return values.map(normalizeValue).join(" "); +} + +function compareValues(left, right, direction) { + const multiplier = direction === "asc" ? 1 : -1; + const leftNumber = Number(left); + const rightNumber = Number(right); + + if (!Number.isNaN(leftNumber) && !Number.isNaN(rightNumber)) { + return (leftNumber - rightNumber) * multiplier; + } + + return normalizeValue(left).localeCompare(normalizeValue(right), "zh-CN", { numeric: true }) * multiplier; +} + +export function useTableControls( + rowsRef, + { searchFields = [], sortOptions = [], defaultSortKey = "", defaultSortDirection = "desc", searchAllRowFields = false } = {} +) { + const search = ref(""); + const sortKey = ref(defaultSortKey); + const sortDirection = ref(defaultSortDirection); + const fields = buildSearchFields(searchFields, sortOptions); + + const rows = computed(() => { + const keywords = normalizeValue(search.value) + .trim() + .split(/\s+/) + .filter(Boolean); + const filteredRows = keywords.length + ? rowsRef.value.filter((row) => { + const searchText = buildSearchText(row, fields, searchAllRowFields); + return keywords.every((keyword) => searchText.includes(keyword)); + }) + : [...rowsRef.value]; + + if (!sortKey.value) { + return filteredRows; + } + + const option = sortOptions.find((item) => item.key === sortKey.value); + if (!option) { + return filteredRows; + } + + return [...filteredRows].sort((left, right) => + compareValues(getFieldValue(left, option.field || option.key), getFieldValue(right, option.field || option.key), sortDirection.value) + ); + }); + + return { + search, + sortKey, + sortDirection, + sortOptions, + rows + }; +} diff --git a/frontend/src/utils/tableViewMode.js b/frontend/src/utils/tableViewMode.js new file mode 100644 index 0000000..21ac1a8 --- /dev/null +++ b/frontend/src/utils/tableViewMode.js @@ -0,0 +1,29 @@ +export const TABLE_VIEW_MODE_STORAGE_KEY = "forgeflow.tableViewMode"; +export const TABLE_VIEW_MODE_DEFAULT_VERSION_KEY = "forgeflow.tableViewModeDefaultVersion"; + +const DEFAULT_TABLE_VIEW_MODE = "wide"; +const CURRENT_DEFAULT_VERSION = "wide-v1"; + +export function normalizeTableViewMode(value) { + return value === "overview" || value === "wide" ? value : DEFAULT_TABLE_VIEW_MODE; +} + +export function readStoredTableMode(storage = typeof window !== "undefined" ? window.localStorage : null) { + if (!storage) { + return DEFAULT_TABLE_VIEW_MODE; + } + + try { + const hasMigratedToWideDefault = storage.getItem(TABLE_VIEW_MODE_DEFAULT_VERSION_KEY) === CURRENT_DEFAULT_VERSION; + + if (!hasMigratedToWideDefault) { + storage.setItem(TABLE_VIEW_MODE_STORAGE_KEY, DEFAULT_TABLE_VIEW_MODE); + storage.setItem(TABLE_VIEW_MODE_DEFAULT_VERSION_KEY, CURRENT_DEFAULT_VERSION); + return DEFAULT_TABLE_VIEW_MODE; + } + + return normalizeTableViewMode(storage.getItem(TABLE_VIEW_MODE_STORAGE_KEY)); + } catch { + return DEFAULT_TABLE_VIEW_MODE; + } +} diff --git a/frontend/src/utils/tableViewMode.test.js b/frontend/src/utils/tableViewMode.test.js new file mode 100644 index 0000000..398d7bb --- /dev/null +++ b/frontend/src/utils/tableViewMode.test.js @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + TABLE_VIEW_MODE_DEFAULT_VERSION_KEY, + TABLE_VIEW_MODE_STORAGE_KEY, + readStoredTableMode +} from "./tableViewMode.js"; + +function createStorage(initialValues = {}) { + const values = new Map(Object.entries(initialValues)); + return { + getItem(key) { + return values.has(key) ? values.get(key) : null; + }, + setItem(key, value) { + values.set(key, String(value)); + } + }; +} + +describe("readStoredTableMode", () => { + it("migrates existing overview preference to wide for the new default", () => { + const storage = createStorage({ + [TABLE_VIEW_MODE_STORAGE_KEY]: "overview" + }); + + assert.equal(readStoredTableMode(storage), "wide"); + assert.equal(storage.getItem(TABLE_VIEW_MODE_STORAGE_KEY), "wide"); + assert.equal(storage.getItem(TABLE_VIEW_MODE_DEFAULT_VERSION_KEY), "wide-v1"); + }); + + it("keeps a user-selected overview after the wide default migration has already run", () => { + const storage = createStorage({ + [TABLE_VIEW_MODE_STORAGE_KEY]: "overview", + [TABLE_VIEW_MODE_DEFAULT_VERSION_KEY]: "wide-v1" + }); + + assert.equal(readStoredTableMode(storage), "overview"); + }); + + it("defaults to wide when no preference exists", () => { + const storage = createStorage({ + [TABLE_VIEW_MODE_DEFAULT_VERSION_KEY]: "wide-v1" + }); + + assert.equal(readStoredTableMode(storage), "wide"); + }); +}); diff --git a/frontend/src/utils/uiAudit.js b/frontend/src/utils/uiAudit.js new file mode 100644 index 0000000..68ded9f --- /dev/null +++ b/frontend/src/utils/uiAudit.js @@ -0,0 +1,51 @@ +function isElementVisible(element) { + if (!(element instanceof Element)) return false; + if (element instanceof HTMLOptionElement) { + const select = element.closest("select"); + return select ? isElementVisible(select) : false; + } + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0" && rect.width > 0 && rect.height > 0; +} + +export function collectUiAuditSnapshot(root = document) { + const normalize = (value) => String(value || "").trim().replace(/\s+/g, " "); + const tables = Array.from(root.querySelectorAll("table")).map((table, index) => { + const rect = table.getBoundingClientRect(); + const wrap = table.closest(".table-wrap, .drawer-data-table-wrap, .finance-sheet-wrap") || table.parentElement; + const wrapRect = wrap?.getBoundingClientRect(); + return { + index, + width: Math.round(rect.width), + wrapWidth: Math.round(wrapRect?.width || rect.width), + hasRawSortText: Array.from(table.querySelectorAll("th")).some((th) => normalize(th.textContent).includes("↕")), + headerCount: table.querySelectorAll("th").length + }; + }); + + const visibleEnglishStatuses = Array.from(root.querySelectorAll("td, option, span, button")) + .filter((element) => isElementVisible(element)) + .map((element) => normalize(element.textContent)) + .filter((text) => /\b(ACTIVE|INACTIVE|PENDING|LOCKED|OPEN|CLOSED|DONE|PASS|REJECT|SETTLED)\b/.test(text)); + + const longHelperBlocks = Array.from(root.querySelectorAll("small, .workflow-tip, .feedback-banner")) + .map((element) => normalize(element.textContent)) + .filter((text) => text.length > 48); + + return { + path: window.location.pathname, + viewport: { width: window.innerWidth, height: window.innerHeight }, + tables, + visibleEnglishStatuses, + longHelperBlocks + }; +} + +export function printUiAuditSnapshot(root = document) { + const snapshot = collectUiAuditSnapshot(root); + console.table(snapshot.tables); + console.log("visibleEnglishStatuses", snapshot.visibleEnglishStatuses); + console.log("longHelperBlocks", snapshot.longHelperBlocks); + return snapshot; +} diff --git a/frontend/src/views/BomManagementView.vue b/frontend/src/views/BomManagementView.vue new file mode 100644 index 0000000..8b7986b --- /dev/null +++ b/frontend/src/views/BomManagementView.vue @@ -0,0 +1,602 @@ + + + diff --git a/frontend/src/views/CompletionReceiptView.vue b/frontend/src/views/CompletionReceiptView.vue new file mode 100644 index 0000000..02ce77b --- /dev/null +++ b/frontend/src/views/CompletionReceiptView.vue @@ -0,0 +1,401 @@ + + + diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue new file mode 100644 index 0000000..2dbd930 --- /dev/null +++ b/frontend/src/views/DashboardView.vue @@ -0,0 +1,1012 @@ + + + diff --git a/frontend/src/views/DataModelView.vue b/frontend/src/views/DataModelView.vue new file mode 100644 index 0000000..031f98b --- /dev/null +++ b/frontend/src/views/DataModelView.vue @@ -0,0 +1,81 @@ + + + diff --git a/frontend/src/views/DeliveryManagementView.vue b/frontend/src/views/DeliveryManagementView.vue new file mode 100644 index 0000000..0acd948 --- /dev/null +++ b/frontend/src/views/DeliveryManagementView.vue @@ -0,0 +1,416 @@ + + + diff --git a/frontend/src/views/DeliveryWorkspaceView.vue b/frontend/src/views/DeliveryWorkspaceView.vue new file mode 100644 index 0000000..f17ff0e --- /dev/null +++ b/frontend/src/views/DeliveryWorkspaceView.vue @@ -0,0 +1,26 @@ + + + diff --git a/frontend/src/views/EmployeeManagementView.vue b/frontend/src/views/EmployeeManagementView.vue new file mode 100644 index 0000000..ed5a930 --- /dev/null +++ b/frontend/src/views/EmployeeManagementView.vue @@ -0,0 +1,353 @@ + + + diff --git a/frontend/src/views/EquipmentManagementView.vue b/frontend/src/views/EquipmentManagementView.vue new file mode 100644 index 0000000..2f506d9 --- /dev/null +++ b/frontend/src/views/EquipmentManagementView.vue @@ -0,0 +1,439 @@ + + + diff --git a/frontend/src/views/FinanceReportView.vue b/frontend/src/views/FinanceReportView.vue new file mode 100644 index 0000000..01a6a75 --- /dev/null +++ b/frontend/src/views/FinanceReportView.vue @@ -0,0 +1,781 @@ + + + diff --git a/frontend/src/views/InventoryLedgerView.test.js b/frontend/src/views/InventoryLedgerView.test.js new file mode 100644 index 0000000..1b8f433 --- /dev/null +++ b/frontend/src/views/InventoryLedgerView.test.js @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const source = readFileSync(join(__dirname, "InventoryLedgerView.vue"), "utf8"); +const mainCss = readFileSync(resolve(__dirname, "../styles/main.css"), "utf8"); + +function functionBody(name) { + const match = source.match(new RegExp(`async function ${name}\\(\\)[\\s\\S]*?\\n\\}`)); + assert.ok(match, `expected ${name} to exist`); + return match[0]; +} + +describe("InventoryLedgerView warehouse action layout", () => { + it("keeps stocktake inside the tool action group instead of a standalone rack", () => { + assert.equal(source.includes("warehouse-stocktake-rack"), false); + assert.equal(source.includes("stocktake-launch-button"), false); + + const toolActionsMatch = source.match(/
      ([\s\S]*?)<\/div>/); + assert.ok(toolActionsMatch, "expected warehouse tool action group to exist"); + assert.match(toolActionsMatch[1], /盘库<\/strong>/); + assert.match(toolActionsMatch[1], /@click="openStocktakeDialog"/); + }); + + it("does not render the separate warehouse header strip above the switchboard", () => { + assert.equal(source.includes("inventory-main-header"), false); + }); + + it("makes the selected warehouse card visibly heavier than inactive cards", () => { + assert.match(mainCss, /\.warehouse-tab-card-active\s*\{[\s\S]*border:\s*2px solid color-mix\(in srgb,\s*currentColor 72%,\s*white 28%\);/); + assert.match(mainCss, /\.warehouse-tab-card-active\s*\{[\s\S]*0 0 0 5px color-mix\(in srgb,\s*currentColor 14%,\s*transparent\)/); + assert.match(mainCss, /\.warehouse-tab-card-active::before\s*\{[\s\S]*content:\s*"当前";/); + }); + + it("shows document archive actions in warehouse ledger rows", () => { + assert.match(source, /DocumentArchiveActions/); + assert.match(source, /warehouse-operation/); + assert.match(source, /previewWarehouseLedgerArchive/); + assert.match(source, /downloadWarehouseLedgerArchive/); + assert.match(source, /regenerateWarehouseLedgerArchive/); + }); + + it("supports exporting warehouse ledger excel with current filters", () => { + assert.match(source, /导出Excel/); + assert.match(source, /exportWarehouseLedgerExcel/); + assert.match(source, /\/inventory\/transaction-ledger\/export/); + assert.match(source, /warehouseLedgerExporting/); + }); + + it("shows document archive actions in inventory detail transaction rows", () => { + assert.match(source, /key:\s*"archive_actions"/); + assert.match(source, / + + + + diff --git a/frontend/src/views/LoginView.test.js b/frontend/src/views/LoginView.test.js new file mode 100644 index 0000000..2b0b683 --- /dev/null +++ b/frontend/src/views/LoginView.test.js @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const source = readFileSync(join(__dirname, "LoginView.vue"), "utf8"); +const mainCss = readFileSync(resolve(__dirname, "../styles/main.css"), "utf8"); + +describe("LoginView industrial technology redesign", () => { + it("renders a separated production line and right-side login console without visual overlap", () => { + assert.match(source, /class="[^"]*login-industrial-stage/); + assert.match(source, /class="[^"]*login-production-layout/); + assert.match(source, /class="login-kinetic-field"/); + assert.match(source, /class="login-production-cockpit"/); + assert.match(source, /class="login-line-area"/); + assert.match(source, /class="login-line-brand"/); + assert.match(source, /class="login-production-line"/); + assert.match(source, /class="login-line-machine"/); + assert.match(source, /class="[^"]*login-forge-beam/); + assert.match(source, /class="login-press-aura"/); + assert.match(source, /class="login-part-runner"/); + assert.match(source, /class="[^"]*login-heat-column/); + assert.match(source, /class="login-conveyor-label"/); + assert.match(source, /class="login-console-link"/); + assert.match(source, /class="login-console-dock"/); + assert.match(source, /class="[^"]*login-dock-rail/); + assert.match(source, /class="login-dock-floor"/); + assert.match(source, /class="login-console-frame"/); + assert.match(source, /class="login-control-console"/); + assert.doesNotMatch(source, /class="login-holo-press"/); + assert.doesNotMatch(source, /class="login-flow-ribbon"/); + assert.doesNotMatch(source, /class="login-flow-chip"/); + assert.doesNotMatch(source, /class="login-photo-bg"/); + assert.doesNotMatch(source, /class="login-command-deck"/); + assert.doesNotMatch(source, /class="login-auth-card"/); + assert.doesNotMatch(source, /class="login-process-node/); + assert.doesNotMatch(source, /class="login-hero-copy"/); + assert.doesNotMatch(source, /ForgeFlow ERP/); + assert.doesNotMatch(source, /五金产线接入中/); + assert.doesNotMatch(source, /库存批次 \/ 生产台账 \/ 成本追溯/); + assert.doesNotMatch(source, /SECURE ACCESS/); + assert.doesNotMatch(source, /以材料批次为线索/); + assert.doesNotMatch(source, /系统账号、角色与菜单权限/); + assert.match(source, /class="login-input-shell"/); + }); + + it("uses premium line icons for the industrial login affordances", () => { + assert.match(source, / { + assert.match(mainCss, /--login-forge-blue:/); + assert.match(mainCss, /\.login-industrial-stage/); + assert.match(mainCss, /\.login-production-layout/); + assert.match(mainCss, /\.login-production-cockpit/); + assert.match(mainCss, /\.login-line-area/); + assert.match(mainCss, /\.login-production-line/); + assert.match(mainCss, /\.login-forge-beam/); + assert.match(mainCss, /\.login-press-aura/); + assert.match(mainCss, /\.login-part-runner/); + assert.match(mainCss, /\.login-heat-column/); + assert.match(mainCss, /\.login-console-link/); + assert.match(mainCss, /\.login-console-dock/); + assert.match(mainCss, /\.login-dock-rail/); + assert.match(mainCss, /\.login-dock-floor/); + assert.match(mainCss, /\.login-console-frame/); + assert.match(mainCss, /\.login-kinetic-field/); + assert.match(mainCss, /\.login-control-console/); + assert.match(mainCss, /\.login-press-hammer/); + assert.doesNotMatch(mainCss, /0 38px 110px rgba\(0, 0, 0, 0\.42\)/); + assert.doesNotMatch(mainCss, /jiaheng-office-bg\.jpg/); + assert.match(mainCss, /@keyframes loginScanSweep/); + assert.match(mainCss, /@keyframes loginMetalTravel/); + assert.match(mainCss, /@keyframes loginPressStamp/); + assert.match(mainCss, /@keyframes loginSteelPulse/); + assert.match(mainCss, /@keyframes loginForgeBeam/); + assert.match(mainCss, /@keyframes loginPartRun/); + assert.match(mainCss, /@keyframes loginHeatRise/); + assert.match(mainCss, /@keyframes loginDockSignal/); + assert.match(mainCss, /@keyframes loginDockBreathe/); + assert.match(mainCss, /@keyframes loginFactoryDepth/); + assert.match(mainCss, /@media \(prefers-reduced-motion: reduce\)[\s\S]*loginScanSweep/); + assert.match(mainCss, /@media \(max-width: 900px\)[\s\S]*\.login-production-cockpit/); + }); +}); diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue new file mode 100644 index 0000000..ec0c18d --- /dev/null +++ b/frontend/src/views/LoginView.vue @@ -0,0 +1,173 @@ + + + diff --git a/frontend/src/views/MasterDataImportView.vue b/frontend/src/views/MasterDataImportView.vue new file mode 100644 index 0000000..de60bd7 --- /dev/null +++ b/frontend/src/views/MasterDataImportView.vue @@ -0,0 +1,185 @@ + + + diff --git a/frontend/src/views/MasterDataWorkspaceView.vue b/frontend/src/views/MasterDataWorkspaceView.vue new file mode 100644 index 0000000..9b5d7dc --- /dev/null +++ b/frontend/src/views/MasterDataWorkspaceView.vue @@ -0,0 +1,62 @@ + + + diff --git a/frontend/src/views/MaterialManagementView.vue b/frontend/src/views/MaterialManagementView.vue new file mode 100644 index 0000000..9602e2c --- /dev/null +++ b/frontend/src/views/MaterialManagementView.vue @@ -0,0 +1,421 @@ + + + diff --git a/frontend/src/views/OperationReportView.vue b/frontend/src/views/OperationReportView.vue new file mode 100644 index 0000000..e2f9a3b --- /dev/null +++ b/frontend/src/views/OperationReportView.vue @@ -0,0 +1,225 @@ + + + diff --git a/frontend/src/views/ProcessCenterView.vue b/frontend/src/views/ProcessCenterView.vue new file mode 100644 index 0000000..190185d --- /dev/null +++ b/frontend/src/views/ProcessCenterView.vue @@ -0,0 +1,164 @@ + + + diff --git a/frontend/src/views/ProcessRouteManagementView.vue b/frontend/src/views/ProcessRouteManagementView.vue new file mode 100644 index 0000000..11ccb60 --- /dev/null +++ b/frontend/src/views/ProcessRouteManagementView.vue @@ -0,0 +1,650 @@ + + + diff --git a/frontend/src/views/ProcurementWorkspaceView.vue b/frontend/src/views/ProcurementWorkspaceView.vue new file mode 100644 index 0000000..343a54e --- /dev/null +++ b/frontend/src/views/ProcurementWorkspaceView.vue @@ -0,0 +1,53 @@ + + + diff --git a/frontend/src/views/ProductManagementView.vue b/frontend/src/views/ProductManagementView.vue new file mode 100644 index 0000000..086755e --- /dev/null +++ b/frontend/src/views/ProductManagementView.vue @@ -0,0 +1,301 @@ + + + diff --git a/frontend/src/views/ProductSpecLiteView.vue b/frontend/src/views/ProductSpecLiteView.vue new file mode 100644 index 0000000..65bca0b --- /dev/null +++ b/frontend/src/views/ProductSpecLiteView.vue @@ -0,0 +1,1100 @@ + + + + + diff --git a/frontend/src/views/ProductionLedgerView.test.js b/frontend/src/views/ProductionLedgerView.test.js new file mode 100644 index 0000000..ddc0834 --- /dev/null +++ b/frontend/src/views/ProductionLedgerView.test.js @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const source = readFileSync(join(__dirname, "ProductionLedgerView.vue"), "utf8"); + +describe("ProductionLedgerView archive actions", () => { + it("uses DocumentArchiveActions for material out and inbound settlement archives", () => { + assert.match(source, /DocumentArchiveActions/); + assert.match(source, /production-material-out/); + assert.match(source, /production-inbound-settlement/); + }); + + it("has preview, download and regenerate archive handlers", () => { + assert.match(source, /previewDocumentArchive/); + assert.match(source, /downloadDocumentArchive/); + assert.match(source, /regenerateDocumentArchive/); + }); +}); diff --git a/frontend/src/views/ProductionLedgerView.vue b/frontend/src/views/ProductionLedgerView.vue new file mode 100644 index 0000000..6b80796 --- /dev/null +++ b/frontend/src/views/ProductionLedgerView.vue @@ -0,0 +1,406 @@ + + + + + diff --git a/frontend/src/views/ProductionWorkspaceView.vue b/frontend/src/views/ProductionWorkspaceView.vue new file mode 100644 index 0000000..f05e6cd --- /dev/null +++ b/frontend/src/views/ProductionWorkspaceView.vue @@ -0,0 +1,35 @@ + + + diff --git a/frontend/src/views/PurchaseOrderView.vue b/frontend/src/views/PurchaseOrderView.vue new file mode 100644 index 0000000..65ff8f7 --- /dev/null +++ b/frontend/src/views/PurchaseOrderView.vue @@ -0,0 +1,2647 @@ + + + + + diff --git a/frontend/src/views/PurchaseReceiptView.vue b/frontend/src/views/PurchaseReceiptView.vue new file mode 100644 index 0000000..47cc1f9 --- /dev/null +++ b/frontend/src/views/PurchaseReceiptView.vue @@ -0,0 +1,962 @@ + + + + + diff --git a/frontend/src/views/QualityInspectionView.test.js b/frontend/src/views/QualityInspectionView.test.js new file mode 100644 index 0000000..655d2cf --- /dev/null +++ b/frontend/src/views/QualityInspectionView.test.js @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const source = readFileSync(join(__dirname, "QualityInspectionView.vue"), "utf8"); + +describe("QualityInspectionView layout and release flow", () => { + it("places the pending/completed switch beside the page title", () => { + assert.match(source, /class="quality-title-row"/); + assert.match(source, /class="inventory-tabbar quality-title-tabbar"/); + }); + + it("does not keep inspection remark as a persistent pending-panel field", () => { + assert.doesNotMatch(source, /class="form-field quality-note"/); + }); + + it("opens a remark confirmation dialog before releasing or rejecting a batch", () => { + assert.match(source, /openInspectionRemarkDialog\(row,\s*'PASS'\)/); + assert.match(source, /openInspectionRemarkDialog\(row,\s*'REJECT'\)/); + assert.match(source, /showInspectionRemarkDialog/); + assert.match(source, /确认放行/); + }); + + it("renders the inspection dialog as a formal quality document", () => { + assert.match(source, / { + assert.match(source, /
+ + + + + + + + + + + +
+ + +
+ + + + + + + + + + diff --git a/frontend/src/views/ReturnManagementView.vue b/frontend/src/views/ReturnManagementView.vue new file mode 100644 index 0000000..fc22fd2 --- /dev/null +++ b/frontend/src/views/ReturnManagementView.vue @@ -0,0 +1,609 @@ + + + diff --git a/frontend/src/views/SalesPlanningView.vue b/frontend/src/views/SalesPlanningView.vue new file mode 100644 index 0000000..4792d60 --- /dev/null +++ b/frontend/src/views/SalesPlanningView.vue @@ -0,0 +1,1599 @@ + + + + + diff --git a/frontend/src/views/ScrapReworkView.vue b/frontend/src/views/ScrapReworkView.vue new file mode 100644 index 0000000..bcafa15 --- /dev/null +++ b/frontend/src/views/ScrapReworkView.vue @@ -0,0 +1,354 @@ + + + diff --git a/frontend/src/views/SupplierManagementView.vue b/frontend/src/views/SupplierManagementView.vue new file mode 100644 index 0000000..55e903f --- /dev/null +++ b/frontend/src/views/SupplierManagementView.vue @@ -0,0 +1,448 @@ + + + diff --git a/frontend/src/views/SupplierMaterialLiteView.vue b/frontend/src/views/SupplierMaterialLiteView.vue new file mode 100644 index 0000000..9276a23 --- /dev/null +++ b/frontend/src/views/SupplierMaterialLiteView.vue @@ -0,0 +1,359 @@ + + + diff --git a/frontend/src/views/SystemExtensionView.vue b/frontend/src/views/SystemExtensionView.vue new file mode 100644 index 0000000..d7a7365 --- /dev/null +++ b/frontend/src/views/SystemExtensionView.vue @@ -0,0 +1,517 @@ + + + diff --git a/frontend/src/views/SystemPermissionView.test.js b/frontend/src/views/SystemPermissionView.test.js new file mode 100644 index 0000000..f5d9a0a --- /dev/null +++ b/frontend/src/views/SystemPermissionView.test.js @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const source = readFileSync(join(__dirname, "SystemPermissionView.vue"), "utf8"); +const mainStyles = readFileSync(join(__dirname, "../styles/main.css"), "utf8"); + +function extractHeaderSource() { + const start = source.indexOf('
", start); + return start >= 0 && end >= 0 ? source.slice(start, end + "
".length) : ""; +} + +describe("SystemPermissionView organization mind map fitting", () => { + it("auto-fits the mind map after initial organization data loads", () => { + assert.match(source, /import\s*\{\s*computed,\s*nextTick,\s*onMounted,\s*reactive,\s*ref,\s*watch\s*\}\s*from\s*"vue"/); + assert.match(source, /async function requestMindMapFitToScreen\(/); + assert.match(source, /onMounted\(async\s*\(\)\s*=>\s*\{\s*await loadAll\(\);\s*await requestMindMapFitToScreen\(\);\s*\}\);/); + }); + + it("auto-fits when users return to mind map mode without showing a toast", () => { + assert.match(source, /watch\(\s*\[\s*permissionMainTab,\s*orgViewMode\s*\]/); + assert.match(source, /requestMindMapFitToScreen\(\);/); + assert.match(source, /requestMindMapFitToScreen\(\{\s*showToast:\s*true,\s*closeMenu:\s*true\s*\}\)/); + }); +}); + +describe("SystemPermissionView page tab placement", () => { + it("places the organization/role tab switch directly beside the page title", () => { + const headerSource = extractHeaderSource(); + assert.ok(headerSource.includes('class="system-permission-title-row"')); + assert.ok(headerSource.includes("

系统权限管理

")); + assert.ok(headerSource.includes('class="system-permission-main-tabs admin-tab-switcher"')); + assert.ok(headerSource.indexOf("

系统权限管理

") < headerSource.indexOf('class="system-permission-main-tabs admin-tab-switcher"')); + assert.equal((source.match(/class="system-permission-main-tabs admin-tab-switcher"/g) || []).length, 1); + }); +}); + +describe("SystemPermissionView maintenance scrolling", () => { + it("enables normal vertical scrolling for role maintenance without changing mind map overflow", () => { + assert.ok(source.includes(`:class="{ 'system-permission-page--maintenance': permissionMainTab === 'maintenance' }"`)); + assert.match(mainStyles, /\.system-permission-page\.system-permission-page--maintenance\s*\{[\s\S]*?overflow-y:\s*auto;/); + assert.match(mainStyles, /\.system-permission-page\.system-permission-page--maintenance\s+\.system-permission-maintenance-card\s*\{[\s\S]*?overflow:\s*visible;/); + }); +}); diff --git a/frontend/src/views/SystemPermissionView.vue b/frontend/src/views/SystemPermissionView.vue new file mode 100644 index 0000000..e75127e --- /dev/null +++ b/frontend/src/views/SystemPermissionView.vue @@ -0,0 +1,1464 @@ + + + + + diff --git a/frontend/src/views/WorkOrderManagementView.vue b/frontend/src/views/WorkOrderManagementView.vue new file mode 100644 index 0000000..9461450 --- /dev/null +++ b/frontend/src/views/WorkOrderManagementView.vue @@ -0,0 +1,303 @@ + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..a374fce --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; + +export default defineConfig({ + plugins: [vue()], + server: { + host: "0.0.0.0", + port: 5173 + } +}); +
归档<\/th>/); + assert.match(source, / +
+
+
+
+

质量检验

+
+

{{ activeTab === "pending" ? "采购到货待质检" : "已判定批次追踪" }}

+
+ + +
+
+

按批次完成采购到货放行或拒收,并保留最近质检结论与备注线索。

+
+
+ {{ activeTab === "pending" ? "仓库待放行" : "质量记录" }} +
+
+ + + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
入库单到货库类型供应商物料库存批次号收货量状态操作
{{ row.receipt_no }}{{ purchaseTargetLabel(row.target_warehouse_type) }}{{ row.supplier_name }}{{ row.material_name }}{{ row.lot_no }}{{ formatInspectionReceived(row) }} + + +
当前没有待检批次
+
+ +
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
入库单到货库类型物料库存批次号合格量质检状态备注归档
{{ row.receipt_no }}{{ purchaseTargetLabel(row.target_warehouse_type) }}{{ row.material_name }}{{ row.lot_no }}{{ formatInspectionAccepted(row) }}{{ [row.inspector_name, row.remark].filter(Boolean).join(" / ") || "-" }} +
+ +
+
暂无已判定记录
+
+ +
+ + +
+
1{{ pendingInspectionRow.material_code || "-" }}{{ pendingInspectionRow.material_name || "-" }}{{ pendingInspectionRow.lot_no || "-" }}{{ formatInspectionReceived(pendingInspectionRow) }}{{ formatInspectionAcceptedPreview(pendingInspectionRow) }}{{ purchaseTargetLabel(pendingInspectionRow.target_warehouse_type) }}