diff --git a/docs/superpowers/plans/2026-07-25-report-over-limit-snapshot.md b/docs/superpowers/plans/2026-07-25-report-over-limit-snapshot.md new file mode 100644 index 0000000..9fa9db3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-report-over-limit-snapshot.md @@ -0,0 +1,1866 @@ +# 报工超额提交快照 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:** 在报工提交和管理员修正时,对产品工序数量是否超过材料/BOM/前序可用量进行校验,并把校验结果按当时状态固化为快照,供审核、记录、看板和导出展示。 + +**Architecture:** 后端新增“超额快照”字段和 `report_over_limit` 服务,所有判断都落在报工明细层,报工主表只保存汇总标识用于列表快速展示。员工提交、清洗外普通报工、系统自动提交后的管理员补填、管理员审核修改后统一刷新超额快照;后续 ERP 追加生产出库不会重算历史快照。 + +**Tech Stack:** FastAPI, SQLAlchemy 2, MySQL, Pytest, openpyxl, 微信小程序原生 WXML/WXSS/JS。 + +--- + +## Scope + +实现内容: + +- 第一道数字工序按提交时 ERP 批次累计出库重量和产品毛重校验最大可报工数量。 +- 后续数字工序按提交时前一道数字工序累计成品数量校验当前工序累计消耗数量。 +- 当前工序消耗数量 = 成品数量 + 不良数量 + 报废数量。 +- 前序可用数量 = 前序成品数量。 +- 超额状态按提交/管理员修正时固化,不随 ERP 后续补料动态改变。 +- 无法校验与超额分开:无法校验只提醒,不按超额标红。 +- 报工卡片、审核页、记录页、经理看板、经理看板导出展示超额/无法校验信息。 +- 数据库迁移脚本直接执行并用 `information_schema` 或目标查询验证。 + +不实现内容: + +- 不阻止员工提交。 +- 不把 ERP 后续出库反向分配到历史报工。 +- 不校验清洗、杂活、非数字工序。 +- 不新建 ERP 投料页面,第一版直接读取现有 `pp_production_batch_ledger.total_issued_weight_kg`。 + +## Business Rules + +### 工序范围 + +- 仅普通产品数字工序参与校验。 +- `process_name` 需能解析为正整数,例如 `1`、`1序` 均按 `1` 处理。 +- `杂活`、清洗冲压方式、非数字工序直接跳过。 +- 多人工序继续参与,因为它是普通产品工序。 + +### 第一道工序 + +```text +最大可报工数量 = 提交时批次累计生产出库重量 / 产品毛重 +本次消耗数量 = 成品 + 不良 + 报废 +当前累计消耗 = 历史未作废且已提交的同考勤点/同项目/同产品/同批次/同工序消耗 + 本次消耗 + +若 当前累计消耗 > 最大可报工数量,则本明细超额。 +``` + +### 后续工序 + +```text +本次消耗数量 = 成品 + 不良 + 报废 +当前累计消耗 = 历史未作废且已提交的同考勤点/同项目/同产品/同批次/当前工序消耗 + 本次消耗 +前序可用数量 = 历史未作废且已提交的同考勤点/同项目/同产品/同批次/前一道数字工序成品数量 + +若 当前累计消耗 > 前序可用数量,则本明细超额。 +``` + +前一道工序按产品清单中同考勤点、同项目、同产品、同批次无关的数字工序序列查找;如果只有 `1序、3序`,则 `3序` 的前序是 `1序`。 + +### 统计范围 + +参与累计的报工: + +```text +未作废 + 待审核 + 审核通过 +``` + +不参与累计的报工: + +```text +已作废 +已拒绝 +未提交的上班班次 +``` + +当前正在保存的报工需要纳入累计,但不能重复计算自身。实现时以 `exclude_report_id` 排除本报工历史行,再把本次明细加到累计中。 + +### 快照固化 + +- 员工提交时报工明细保存 `over_limit_check_source = "submit"`。 +- 管理员审核页修改关键字段后,保存 `over_limit_check_source = "review"`。 +- ERP 后续补料不触发历史快照重算。 +- 作废/撤销作废不修改该报工自身快照,但未来新报工累计时会排除作废记录。 + +## File Map + +Backend repo root: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint` + +- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/models.py` + - `ProductionReport` 增加 `has_over_limit`、`over_limit_summary`。 + - `ProductionReportItem` 增加明细级快照字段。 +- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/schemas.py` + - `ReportItemOut`、`ReportOut`、`DashboardRow` 增加超额输出字段。 +- Create `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/report_over_limit.py` + - 解析工序、读取 ERP 批次重量、计算累计、刷新快照。 +- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/serializers.py` + - 输出明细和主表超额字段。 +- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reports.py` + - 普通报工提交后刷新超额快照。 +- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reviews.py` + - 管理员审核/修正后刷新超额快照。 +- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/auto_submit.py` + - 系统自动提交生成空报工后不强判超额;管理员补填审核时刷新。 +- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/dashboard.py` + - 经理看板聚合超额字段和原因。 +- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/excel_export.py` + - 导出增加超额相关列。 +- Create `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/scripts/migrate_report_over_limit_snapshot.py` + - MySQL 补字段、索引并验证。 +- Create `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_report_over_limit.py` + - 服务级、提交、审核刷新测试。 +- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_dashboard_allocations.py` + - 经理看板超额字段测试。 + +Frontend repo root: `/Users/souplearn/Gitlab/app/JhHardwareWRS` + +- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js` + - 映射超额字段。 +- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review/review.js` + - 编辑后沿用后端返回的超额字段刷新页面。 +- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review/review.wxml` + - 审核页水印和明细提示。 +- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review/review.wxss` + - 红色超额水印/提示样式。 +- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/records/records.wxml` + - 员工记录页水印和明细提示。 +- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/records/records.wxss` + - 记录页样式。 +- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard/dashboard.wxml` + - 经理看板水印和原因。 +- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard/dashboard.wxss` + - 看板样式。 + +--- + +### Task 1: Add Database Fields And Migration + +**Files:** +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/models.py` +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/scripts/migrate_report_over_limit_snapshot.py` +- Test: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_report_over_limit.py` + +- [ ] **Step 1: Write failing schema test** + +Create `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_report_over_limit.py` with this initial content: + +```python +from datetime import datetime + +from sqlalchemy import BigInteger, create_engine, inspect +from sqlalchemy.ext.compiler import compiles + +from app.database import Base + + +@compiles(BigInteger, "sqlite") +def _compile_big_integer_for_sqlite(type_, compiler, **kw): + return "INTEGER" + + +def test_report_over_limit_columns_exist_on_sqlite_metadata(): + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + inspector = inspect(engine) + + report_columns = {column["name"] for column in inspector.get_columns("production_reports")} + item_columns = {column["name"] for column in inspector.get_columns("production_report_items")} + + assert {"has_over_limit", "over_limit_summary"}.issubset(report_columns) + assert { + "over_limit_status", + "over_limit_type", + "over_limit_reason", + "over_limit_checked_at", + "over_limit_check_source", + "over_limit_batch_no", + "over_limit_product_gross_weight_kg", + "over_limit_issued_weight_kg", + "over_limit_max_reportable_qty", + "over_limit_previous_good_qty", + "over_limit_current_cumulative_qty", + "over_limit_current_report_qty", + }.issubset(item_columns) +``` + +- [ ] **Step 2: Run schema test and verify failure** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m pytest tests/test_report_over_limit.py::test_report_over_limit_columns_exist_on_sqlite_metadata -v +``` + +Expected: FAIL because the new columns do not exist. + +- [ ] **Step 3: Add model fields** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/models.py`, add these fields to `ProductionReport` after `auto_submit_reason`: + +```python + has_over_limit: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + over_limit_summary: Mapped[str | None] = mapped_column(String(500)) +``` + +Add these fields to `ProductionReportItem` after `remark`: + +```python + over_limit_status: Mapped[str] = mapped_column(String(32), nullable=False, default="not_checked") + over_limit_type: Mapped[str | None] = mapped_column(String(64)) + over_limit_reason: Mapped[str | None] = mapped_column(String(500)) + over_limit_checked_at: Mapped[datetime | None] = mapped_column(DateTime) + over_limit_check_source: Mapped[str | None] = mapped_column(String(32)) + over_limit_batch_no: Mapped[str | None] = mapped_column(String(128)) + over_limit_product_gross_weight_kg: Mapped[float | None] = mapped_column(Numeric(12, 4)) + over_limit_issued_weight_kg: Mapped[float | None] = mapped_column(Numeric(18, 6)) + over_limit_max_reportable_qty: Mapped[float | None] = mapped_column(Numeric(12, 2)) + over_limit_previous_good_qty: Mapped[float | None] = mapped_column(Numeric(12, 2)) + over_limit_current_cumulative_qty: Mapped[float | None] = mapped_column(Numeric(12, 2)) + over_limit_current_report_qty: Mapped[float | None] = mapped_column(Numeric(12, 2)) +``` + +Add indexes to `ProductionReport.__table_args__`: + +```python + Index("idx_reports_over_limit", "has_over_limit", "submitted_at"), +``` + +Add indexes to `ProductionReportItem.__table_args__`: + +```python + Index("idx_report_items_over_limit_status", "over_limit_status"), + Index( + "idx_report_items_process_batch", + "attendance_point_name", + "project_no", + "product_name", + "raw_material_batch_no", + "process_name", + ), +``` + +- [ ] **Step 4: Run schema test and verify pass** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m pytest tests/test_report_over_limit.py::test_report_over_limit_columns_exist_on_sqlite_metadata -v +``` + +Expected: PASS. + +- [ ] **Step 5: Create MySQL migration script** + +Create `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/scripts/migrate_report_over_limit_snapshot.py`: + +```python +from pathlib import Path +import sys + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import engine # noqa: E402 + + +REPORT_COLUMNS = { + "has_over_limit": "ADD COLUMN has_over_limit TINYINT(1) NOT NULL DEFAULT 0 AFTER auto_submit_reason", + "over_limit_summary": "ADD COLUMN over_limit_summary VARCHAR(500) NULL AFTER has_over_limit", +} + +ITEM_COLUMNS = { + "over_limit_status": "ADD COLUMN over_limit_status VARCHAR(32) NOT NULL DEFAULT 'not_checked' AFTER remark", + "over_limit_type": "ADD COLUMN over_limit_type VARCHAR(64) NULL AFTER over_limit_status", + "over_limit_reason": "ADD COLUMN over_limit_reason VARCHAR(500) NULL AFTER over_limit_type", + "over_limit_checked_at": "ADD COLUMN over_limit_checked_at DATETIME NULL AFTER over_limit_reason", + "over_limit_check_source": "ADD COLUMN over_limit_check_source VARCHAR(32) NULL AFTER over_limit_checked_at", + "over_limit_batch_no": "ADD COLUMN over_limit_batch_no VARCHAR(128) NULL AFTER over_limit_check_source", + "over_limit_product_gross_weight_kg": "ADD COLUMN over_limit_product_gross_weight_kg DECIMAL(12,4) NULL AFTER over_limit_batch_no", + "over_limit_issued_weight_kg": "ADD COLUMN over_limit_issued_weight_kg DECIMAL(18,6) NULL AFTER over_limit_product_gross_weight_kg", + "over_limit_max_reportable_qty": "ADD COLUMN over_limit_max_reportable_qty DECIMAL(12,2) NULL AFTER over_limit_issued_weight_kg", + "over_limit_previous_good_qty": "ADD COLUMN over_limit_previous_good_qty DECIMAL(12,2) NULL AFTER over_limit_max_reportable_qty", + "over_limit_current_cumulative_qty": "ADD COLUMN over_limit_current_cumulative_qty DECIMAL(12,2) NULL AFTER over_limit_previous_good_qty", + "over_limit_current_report_qty": "ADD COLUMN over_limit_current_report_qty DECIMAL(12,2) NULL AFTER over_limit_current_cumulative_qty", +} + +INDEXES = { + "production_reports": { + "idx_reports_over_limit": "CREATE INDEX idx_reports_over_limit ON production_reports (has_over_limit, submitted_at)", + }, + "production_report_items": { + "idx_report_items_over_limit_status": "CREATE INDEX idx_report_items_over_limit_status ON production_report_items (over_limit_status)", + "idx_report_items_process_batch": ( + "CREATE INDEX idx_report_items_process_batch " + "ON production_report_items (attendance_point_name, project_no, product_name, raw_material_batch_no, process_name)" + ), + }, +} + + +def column_exists(conn, table_name: str, column_name: str) -> bool: + return bool( + conn.execute( + text( + """ + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = :table_name + AND column_name = :column_name + """ + ), + {"table_name": table_name, "column_name": column_name}, + ).scalar_one() + ) + + +def index_exists(conn, table_name: str, index_name: str) -> bool: + return bool( + conn.execute( + text( + """ + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = :table_name + AND index_name = :index_name + """ + ), + {"table_name": table_name, "index_name": index_name}, + ).scalar_one() + ) + + +def ensure_columns(conn) -> None: + for column_name, ddl in REPORT_COLUMNS.items(): + if not column_exists(conn, "production_reports", column_name): + conn.execute(text(f"ALTER TABLE production_reports {ddl}")) + for column_name, ddl in ITEM_COLUMNS.items(): + if not column_exists(conn, "production_report_items", column_name): + conn.execute(text(f"ALTER TABLE production_report_items {ddl}")) + + +def ensure_indexes(conn) -> None: + for table_name, indexes in INDEXES.items(): + for index_name, ddl in indexes.items(): + if not index_exists(conn, table_name, index_name): + conn.execute(text(ddl)) + + +def verify(conn) -> None: + missing = [] + for column_name in REPORT_COLUMNS: + if not column_exists(conn, "production_reports", column_name): + missing.append(f"production_reports.{column_name}") + for column_name in ITEM_COLUMNS: + if not column_exists(conn, "production_report_items", column_name): + missing.append(f"production_report_items.{column_name}") + for table_name, indexes in INDEXES.items(): + for index_name in indexes: + if not index_exists(conn, table_name, index_name): + missing.append(f"{table_name}.{index_name}") + if missing: + raise RuntimeError("report over limit migration missing objects: " + ", ".join(missing)) + + +def main() -> None: + with engine.begin() as conn: + ensure_columns(conn) + ensure_indexes(conn) + verify(conn) + print("report over limit snapshot migrated") + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 6: Compile migration script** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m py_compile scripts/migrate_report_over_limit_snapshot.py +``` + +Expected: exit code 0. + +- [ ] **Step 7: Commit** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +git add app/models.py scripts/migrate_report_over_limit_snapshot.py tests/test_report_over_limit.py +git commit -m "feat: 增加报工超额快照字段" +``` + +Expected: commit succeeds. + +--- + +### Task 2: Implement Over-Limit Calculation Service + +**Files:** +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_report_over_limit.py` +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/report_over_limit.py` + +- [ ] **Step 1: Add service tests for process parsing and current quantity** + +Append to `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_report_over_limit.py`: + +```python +from decimal import Decimal +from types import SimpleNamespace + +from app.services.report_over_limit import ( + OVER_LIMIT_STATUS_EXCEEDED, + OVER_LIMIT_STATUS_NOT_CHECKED, + OVER_LIMIT_STATUS_OK, + OVER_LIMIT_STATUS_UNCHECKABLE, + current_report_qty, + parse_process_order, +) + + +def test_parse_process_order_accepts_numeric_process_names(): + assert parse_process_order("1") == 1 + assert parse_process_order("1序") == 1 + assert parse_process_order(" 02 序 ") == 2 + + +def test_parse_process_order_rejects_special_and_text_process_names(): + assert parse_process_order("杂活") is None + assert parse_process_order("清洗") is None + assert parse_process_order("第一序") is None + + +def test_current_report_qty_uses_good_defect_and_scrap(): + item = SimpleNamespace(good_qty=Decimal("90"), defect_qty=Decimal("5"), scrap_qty=Decimal("5")) + + assert current_report_qty(item) == 100 +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m pytest tests/test_report_over_limit.py::test_parse_process_order_accepts_numeric_process_names tests/test_report_over_limit.py::test_parse_process_order_rejects_special_and_text_process_names tests/test_report_over_limit.py::test_current_report_qty_uses_good_defect_and_scrap -v +``` + +Expected: FAIL because `app.services.report_over_limit` does not exist. + +- [ ] **Step 3: Create basic service helpers** + +Create `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/report_over_limit.py`: + +```python +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP +import re +from typing import Any + +from sqlalchemy import func, select, text +from sqlalchemy.orm import Session + +from app.models import Product, ProductionReport, ProductionReportItem, ReportStatus +from app.services.common import as_float +from app.services.product_rules import is_cleaning_item, is_misc_item + + +OVER_LIMIT_STATUS_NOT_CHECKED = "not_checked" +OVER_LIMIT_STATUS_OK = "ok" +OVER_LIMIT_STATUS_EXCEEDED = "exceeded" +OVER_LIMIT_STATUS_UNCHECKABLE = "uncheckable" + +OVER_LIMIT_TYPE_FIRST_PROCESS_MATERIAL = "first_process_material" +OVER_LIMIT_TYPE_PREVIOUS_PROCESS = "previous_process" +OVER_LIMIT_TYPE_UNCHECKABLE = "uncheckable" + +CHECK_SOURCE_SUBMIT = "submit" +CHECK_SOURCE_REVIEW = "review" + +PROCESS_RE = re.compile(r"^\\s*(\\d+)\\s*(?:序)?\\s*$") +QUANT_2 = Decimal("0.01") +QUANT_4 = Decimal("0.0001") +QUANT_6 = Decimal("0.000001") + + +@dataclass(frozen=True) +class OverLimitResult: + status: str + over_limit_type: str | None + reason: str | None + batch_no: str | None + product_gross_weight_kg: Decimal | None + issued_weight_kg: Decimal | None + max_reportable_qty: Decimal | None + previous_good_qty: Decimal | None + current_cumulative_qty: Decimal | None + current_report_qty: Decimal + + +def decimal_value(value: Any) -> Decimal: + if value is None: + return Decimal("0") + if isinstance(value, Decimal): + return value + try: + return Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError): + return Decimal("0") + + +def round2(value: Any) -> Decimal: + return decimal_value(value).quantize(QUANT_2, rounding=ROUND_HALF_UP) + + +def round4(value: Any) -> Decimal: + return decimal_value(value).quantize(QUANT_4, rounding=ROUND_HALF_UP) + + +def round6(value: Any) -> Decimal: + return decimal_value(value).quantize(QUANT_6, rounding=ROUND_HALF_UP) + + +def parse_process_order(process_name: str | None) -> int | None: + match = PROCESS_RE.match(str(process_name or "")) + if not match: + return None + value = int(match.group(1)) + return value if value > 0 else None + + +def current_report_qty(item: ProductionReportItem) -> Decimal: + return round2( + decimal_value(getattr(item, "good_qty", 0)) + + decimal_value(getattr(item, "defect_qty", 0)) + + decimal_value(getattr(item, "scrap_qty", 0)) + ) +``` + +- [ ] **Step 4: Run tests and verify pass** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m pytest tests/test_report_over_limit.py::test_parse_process_order_accepts_numeric_process_names tests/test_report_over_limit.py::test_parse_process_order_rejects_special_and_text_process_names tests/test_report_over_limit.py::test_current_report_qty_uses_good_defect_and_scrap -v +``` + +Expected: PASS. + +- [ ] **Step 5: Add tests for first-process material limit** + +Append to `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_report_over_limit.py`: + +```python +from sqlalchemy.orm import sessionmaker + +from app.models import Product, ProductionReport, ProductionReportItem, ReportStatus +from app.services.report_over_limit import evaluate_report_item_over_limit + + +def _sqlite_db(): + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + SessionLocal = sessionmaker(bind=engine, future=True) + return SessionLocal() + + +def test_first_process_exceeds_material_limit_with_submit_snapshot(monkeypatch): + db = _sqlite_db() + try: + product = Product( + attendance_point_name="总厂", + project_no="P1", + product_name="产品A", + device_no="", + process_name="1", + product_gross_weight_kg=Decimal("2"), + standard_beat=1, + ) + report = ProductionReport( + id=1, + session_id=1, + attendance_point_name="总厂", + employee_phone="13800000000", + report_date=datetime(2026, 7, 25).date(), + start_at=datetime(2026, 7, 25, 8), + end_at=datetime(2026, 7, 25, 10), + status=ReportStatus.pending, + ) + item = ProductionReportItem( + id=1, + report=report, + attendance_point_name="总厂", + device_no="A", + project_no="P1", + product_name="产品A", + raw_material_batch_no="LOT-1", + process_name="1", + good_qty=60, + defect_qty=0, + scrap_qty=0, + standard_beat=1, + ) + db.add_all([product, report, item]) + db.flush() + monkeypatch.setattr( + "app.services.report_over_limit.read_batch_issued_weight_kg", + lambda db, batch_no, project_no, product_name: Decimal("100"), + ) + + result = evaluate_report_item_over_limit(db, report, item, exclude_report_id=report.id) + + assert result.status == OVER_LIMIT_STATUS_EXCEEDED + assert result.max_reportable_qty == Decimal("50.00") + assert result.current_report_qty == Decimal("60.00") + assert result.current_cumulative_qty == Decimal("60.00") + assert "1序累计报工数量超过本批次材料可支撑数量" in result.reason + finally: + db.close() +``` + +- [ ] **Step 6: Run first-process test and verify failure** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m pytest tests/test_report_over_limit.py::test_first_process_exceeds_material_limit_with_submit_snapshot -v +``` + +Expected: FAIL because `evaluate_report_item_over_limit` is not implemented. + +- [ ] **Step 7: Implement ERP weight lookup and first-process logic** + +Append to `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/report_over_limit.py`: + +```python +def read_batch_issued_weight_kg( + db: Session, + batch_no: str | None, + project_no: str | None, + product_name: str | None, +) -> Decimal | None: + normalized_batch_no = str(batch_no or "").strip() + if not normalized_batch_no: + return None + row = db.execute( + text( + """ + SELECT total_issued_weight_kg + FROM pp_production_batch_ledger + WHERE material_lot_no = :batch_no + AND COALESCE(miniapp_selectable, 1) = 1 + ORDER BY updated_at DESC, id DESC + LIMIT 1 + """ + ), + {"batch_no": normalized_batch_no}, + ).first() + if row is None or row[0] is None: + return None + return round6(row[0]) + + +def _product_for_item(db: Session, report: ProductionReport, item: ProductionReportItem) -> Product | None: + return db.scalar( + select(Product).where( + Product.attendance_point_name == report.attendance_point_name, + Product.project_no == item.project_no, + Product.product_name == item.product_name, + Product.process_name == (item.process_name or ""), + Product.device_no == "", + ) + ) + + +def _all_product_process_orders(db: Session, report: ProductionReport, item: ProductionReportItem) -> list[int]: + rows = db.execute( + select(Product.process_name).where( + Product.attendance_point_name == report.attendance_point_name, + Product.project_no == item.project_no, + Product.product_name == item.product_name, + Product.device_no == "", + ) + ).all() + orders = sorted({order for row in rows if (order := parse_process_order(row[0])) is not None}) + return orders + + +def _previous_process_order(db: Session, report: ProductionReport, item: ProductionReportItem, current_order: int) -> int | None: + previous_orders = [order for order in _all_product_process_orders(db, report, item) if order < current_order] + return previous_orders[-1] if previous_orders else None + + +def _item_scope_filter(report: ProductionReport, item: ProductionReportItem, process_order: int): + process_name_texts = {str(process_order), f"{process_order}序"} + return ( + ProductionReportItem.attendance_point_name == report.attendance_point_name, + ProductionReportItem.project_no == item.project_no, + ProductionReportItem.product_name == item.product_name, + ProductionReportItem.raw_material_batch_no == item.raw_material_batch_no, + ProductionReportItem.process_name.in_(process_name_texts), + ) + + +def _submitted_unvoided_filter(exclude_report_id: int | None): + conditions = ( + ProductionReportItem.report_id == ProductionReport.id, + ProductionReport.is_voided.is_(False), + ProductionReport.status.in_([ReportStatus.pending, ReportStatus.approved]), + ) + if exclude_report_id is not None: + conditions = (*conditions, ProductionReport.id != exclude_report_id) + return conditions + + +def _historical_consumed_qty( + db: Session, + report: ProductionReport, + item: ProductionReportItem, + process_order: int, + exclude_report_id: int | None, +) -> Decimal: + value = db.scalar( + select( + func.coalesce( + func.sum( + ProductionReportItem.good_qty + + ProductionReportItem.defect_qty + + ProductionReportItem.scrap_qty + ), + 0, + ) + ).where( + *_item_scope_filter(report, item, process_order), + *_submitted_unvoided_filter(exclude_report_id), + ) + ) + return round2(value) + + +def _historical_good_qty( + db: Session, + report: ProductionReport, + item: ProductionReportItem, + process_order: int, + exclude_report_id: int | None, +) -> Decimal: + value = db.scalar( + select(func.coalesce(func.sum(ProductionReportItem.good_qty), 0)).where( + *_item_scope_filter(report, item, process_order), + *_submitted_unvoided_filter(exclude_report_id), + ) + ) + return round2(value) + + +def _unchecked(reason: str, item: ProductionReportItem) -> OverLimitResult: + return OverLimitResult( + status=OVER_LIMIT_STATUS_UNCHECKABLE, + over_limit_type=OVER_LIMIT_TYPE_UNCHECKABLE, + reason=reason, + batch_no=getattr(item, "raw_material_batch_no", None), + product_gross_weight_kg=None, + issued_weight_kg=None, + max_reportable_qty=None, + previous_good_qty=None, + current_cumulative_qty=None, + current_report_qty=current_report_qty(item), + ) + + +def evaluate_report_item_over_limit( + db: Session, + report: ProductionReport, + item: ProductionReportItem, + *, + exclude_report_id: int | None, +) -> OverLimitResult: + if is_cleaning_item(item) or is_misc_item(item): + return OverLimitResult( + status=OVER_LIMIT_STATUS_NOT_CHECKED, + over_limit_type=None, + reason=None, + batch_no=item.raw_material_batch_no, + product_gross_weight_kg=None, + issued_weight_kg=None, + max_reportable_qty=None, + previous_good_qty=None, + current_cumulative_qty=None, + current_report_qty=current_report_qty(item), + ) + + current_order = parse_process_order(item.process_name) + if current_order is None: + return _unchecked("非数字工序,无法校验超额", item) + if not str(item.raw_material_batch_no or "").strip(): + return _unchecked("缺少材料库存批次号,无法校验超额", item) + + current_qty = current_report_qty(item) + historical_qty = _historical_consumed_qty(db, report, item, current_order, exclude_report_id) + current_cumulative_qty = round2(historical_qty + current_qty) + previous_order = _previous_process_order(db, report, item, current_order) + + if previous_order is None: + product = _product_for_item(db, report, item) + gross_weight = round4(getattr(product, "product_gross_weight_kg", None)) if product else Decimal("0") + if gross_weight <= 0: + return _unchecked("缺少产品毛重,无法校验第一道工序超额", item) + issued_weight = read_batch_issued_weight_kg(db, item.raw_material_batch_no, item.project_no, item.product_name) + if issued_weight is None: + return _unchecked("缺少批次投入重量,无法校验第一道工序超额", item) + max_qty = round2(issued_weight / gross_weight) + exceeded = current_cumulative_qty > max_qty + return OverLimitResult( + status=OVER_LIMIT_STATUS_EXCEEDED if exceeded else OVER_LIMIT_STATUS_OK, + over_limit_type=OVER_LIMIT_TYPE_FIRST_PROCESS_MATERIAL if exceeded else None, + reason=( + f"{current_order}序累计报工数量超过本批次材料可支撑数量" + if exceeded + else None + ), + batch_no=item.raw_material_batch_no, + product_gross_weight_kg=gross_weight, + issued_weight_kg=issued_weight, + max_reportable_qty=max_qty, + previous_good_qty=None, + current_cumulative_qty=current_cumulative_qty, + current_report_qty=current_qty, + ) + + previous_good_qty = _historical_good_qty(db, report, item, previous_order, exclude_report_id) + exceeded = current_cumulative_qty > previous_good_qty + return OverLimitResult( + status=OVER_LIMIT_STATUS_EXCEEDED if exceeded else OVER_LIMIT_STATUS_OK, + over_limit_type=OVER_LIMIT_TYPE_PREVIOUS_PROCESS if exceeded else None, + reason=( + f"{current_order}序累计报工数量超过{previous_order}序累计成品数量" + if exceeded + else None + ), + batch_no=item.raw_material_batch_no, + product_gross_weight_kg=None, + issued_weight_kg=None, + max_reportable_qty=None, + previous_good_qty=previous_good_qty, + current_cumulative_qty=current_cumulative_qty, + current_report_qty=current_qty, + ) +``` + +- [ ] **Step 8: Run first-process test and verify pass** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m pytest tests/test_report_over_limit.py::test_first_process_exceeds_material_limit_with_submit_snapshot -v +``` + +Expected: PASS. + +- [ ] **Step 9: Add tests for previous-process rule** + +Append to `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_report_over_limit.py`: + +```python +def test_later_process_exceeds_previous_good_quantity(): + db = _sqlite_db() + try: + db.add_all( + [ + Product(attendance_point_name="总厂", project_no="P1", product_name="产品A", device_no="", process_name="1", standard_beat=1), + Product(attendance_point_name="总厂", project_no="P1", product_name="产品A", device_no="", process_name="3", standard_beat=1), + ] + ) + previous_report = ProductionReport( + id=1, + session_id=1, + attendance_point_name="总厂", + employee_phone="13800000000", + report_date=datetime(2026, 7, 25).date(), + start_at=datetime(2026, 7, 25, 8), + end_at=datetime(2026, 7, 25, 10), + status=ReportStatus.approved, + ) + previous_item = ProductionReportItem( + id=1, + report=previous_report, + attendance_point_name="总厂", + device_no="A", + project_no="P1", + product_name="产品A", + raw_material_batch_no="LOT-1", + process_name="1", + good_qty=100, + defect_qty=10, + scrap_qty=5, + standard_beat=1, + ) + report = ProductionReport( + id=2, + session_id=2, + attendance_point_name="总厂", + employee_phone="13800000001", + report_date=datetime(2026, 7, 25).date(), + start_at=datetime(2026, 7, 25, 11), + end_at=datetime(2026, 7, 25, 12), + status=ReportStatus.pending, + ) + item = ProductionReportItem( + id=2, + report=report, + attendance_point_name="总厂", + device_no="B", + project_no="P1", + product_name="产品A", + raw_material_batch_no="LOT-1", + process_name="3", + good_qty=98, + defect_qty=3, + scrap_qty=2, + standard_beat=1, + ) + db.add_all([previous_report, previous_item, report, item]) + db.flush() + + result = evaluate_report_item_over_limit(db, report, item, exclude_report_id=report.id) + + assert result.status == OVER_LIMIT_STATUS_EXCEEDED + assert result.previous_good_qty == Decimal("100.00") + assert result.current_report_qty == Decimal("103.00") + assert result.current_cumulative_qty == Decimal("103.00") + assert "3序累计报工数量超过1序累计成品数量" in result.reason + finally: + db.close() +``` + +- [ ] **Step 10: Run previous-process test** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m pytest tests/test_report_over_limit.py::test_later_process_exceeds_previous_good_quantity -v +``` + +Expected: PASS. + +- [ ] **Step 11: Commit** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +git add app/services/report_over_limit.py tests/test_report_over_limit.py +git commit -m "feat: 增加报工超额校验服务" +``` + +Expected: commit succeeds. + +--- + +### Task 3: Refresh Snapshots On Submit And Review + +**Files:** +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/report_over_limit.py` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reports.py` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reviews.py` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_report_over_limit.py` + +- [ ] **Step 1: Add refresh snapshot test** + +Append to `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_report_over_limit.py`: + +```python +from app.services.report_over_limit import refresh_report_over_limit_snapshot + + +def test_refresh_report_over_limit_snapshot_writes_item_and_report_summary(monkeypatch): + db = _sqlite_db() + try: + product = Product( + attendance_point_name="总厂", + project_no="P1", + product_name="产品A", + device_no="", + process_name="1", + product_gross_weight_kg=Decimal("2"), + standard_beat=1, + ) + report = ProductionReport( + id=1, + session_id=1, + attendance_point_name="总厂", + employee_phone="13800000000", + report_date=datetime(2026, 7, 25).date(), + start_at=datetime(2026, 7, 25, 8), + end_at=datetime(2026, 7, 25, 10), + status=ReportStatus.pending, + ) + item = ProductionReportItem( + id=1, + report=report, + attendance_point_name="总厂", + device_no="A", + project_no="P1", + product_name="产品A", + raw_material_batch_no="LOT-1", + process_name="1", + good_qty=60, + defect_qty=0, + scrap_qty=0, + standard_beat=1, + ) + db.add_all([product, report, item]) + db.flush() + monkeypatch.setattr( + "app.services.report_over_limit.read_batch_issued_weight_kg", + lambda db, batch_no, project_no, product_name: Decimal("100"), + ) + + refresh_report_over_limit_snapshot(db, report, source="submit") + + assert report.has_over_limit is True + assert report.over_limit_summary == "1项超额" + assert item.over_limit_status == OVER_LIMIT_STATUS_EXCEEDED + assert item.over_limit_check_source == "submit" + assert item.over_limit_batch_no == "LOT-1" + assert item.over_limit_max_reportable_qty == Decimal("50.00") + finally: + db.close() +``` + +- [ ] **Step 2: Run refresh test and verify failure** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m pytest tests/test_report_over_limit.py::test_refresh_report_over_limit_snapshot_writes_item_and_report_summary -v +``` + +Expected: FAIL because `refresh_report_over_limit_snapshot` is not implemented. + +- [ ] **Step 3: Implement refresh function** + +Append to `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/report_over_limit.py`: + +```python +def _apply_result_to_item(item: ProductionReportItem, result: OverLimitResult, *, source: str, checked_at: datetime) -> None: + item.over_limit_status = result.status + item.over_limit_type = result.over_limit_type + item.over_limit_reason = result.reason + item.over_limit_checked_at = checked_at + item.over_limit_check_source = source + item.over_limit_batch_no = result.batch_no + item.over_limit_product_gross_weight_kg = result.product_gross_weight_kg + item.over_limit_issued_weight_kg = result.issued_weight_kg + item.over_limit_max_reportable_qty = result.max_reportable_qty + item.over_limit_previous_good_qty = result.previous_good_qty + item.over_limit_current_cumulative_qty = result.current_cumulative_qty + item.over_limit_current_report_qty = result.current_report_qty + + +def refresh_report_over_limit_snapshot( + db: Session, + report: ProductionReport, + *, + source: str, + checked_at: datetime | None = None, +) -> None: + timestamp = checked_at or datetime.now() + exceeded_count = 0 + uncheckable_count = 0 + for item in list(getattr(report, "items", []) or []): + result = evaluate_report_item_over_limit(db, report, item, exclude_report_id=getattr(report, "id", None)) + _apply_result_to_item(item, result, source=source, checked_at=timestamp) + if result.status == OVER_LIMIT_STATUS_EXCEEDED: + exceeded_count += 1 + elif result.status == OVER_LIMIT_STATUS_UNCHECKABLE: + uncheckable_count += 1 + + report.has_over_limit = exceeded_count > 0 + if exceeded_count > 0 and uncheckable_count > 0: + report.over_limit_summary = f"{exceeded_count}项超额,{uncheckable_count}项无法校验" + elif exceeded_count > 0: + report.over_limit_summary = f"{exceeded_count}项超额" + elif uncheckable_count > 0: + report.over_limit_summary = f"{uncheckable_count}项无法校验" + else: + report.over_limit_summary = None + db.flush() +``` + +- [ ] **Step 4: Run refresh test and verify pass** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m pytest tests/test_report_over_limit.py::test_refresh_report_over_limit_snapshot_writes_item_and_report_summary -v +``` + +Expected: PASS. + +- [ ] **Step 5: Wire submit path** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reports.py`, add import: + +```python +from app.services.report_over_limit import refresh_report_over_limit_snapshot +``` + +After existing `refresh_report_allocations(db, report, schedule=schedule, commit=False)` in normal submit, add: + +```python + refresh_report_over_limit_snapshot(db, report, source="submit", checked_at=submitted_at) +``` + +Do not add this to `/cleaning` submit because cleaning is excluded. + +- [ ] **Step 6: Wire review path** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reviews.py`, add import: + +```python +from app.services.report_over_limit import refresh_report_over_limit_snapshot +``` + +After existing `refresh_report_allocations(db, report, schedule=schedule, commit=False)` in approve endpoint, add: + +```python + refresh_report_over_limit_snapshot(db, report, source="review", checked_at=now()) +``` + +- [ ] **Step 7: Run report over-limit tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m pytest tests/test_report_over_limit.py -v +``` + +Expected: all tests pass. + +- [ ] **Step 8: Commit** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +git add app/services/report_over_limit.py app/routers/reports.py app/routers/reviews.py tests/test_report_over_limit.py +git commit -m "feat: 报工提交和审核刷新超额快照" +``` + +Expected: commit succeeds. + +--- + +### Task 4: Expose Snapshot Fields Through API + +**Files:** +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/schemas.py` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/serializers.py` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_report_over_limit.py` + +- [ ] **Step 1: Add serializer test** + +Append to `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_report_over_limit.py`: + +```python +from app.services.serializers import report_out + + +def test_report_out_exposes_over_limit_snapshot_fields(): + report = ProductionReport( + id=1, + session_id=1, + attendance_point_name="总厂", + employee_phone="13800000000", + report_date=datetime(2026, 7, 25).date(), + start_at=datetime(2026, 7, 25, 8), + end_at=datetime(2026, 7, 25, 10), + status=ReportStatus.pending, + has_over_limit=True, + over_limit_summary="1项超额", + ) + item = ProductionReportItem( + id=1, + report=report, + attendance_point_name="总厂", + device_no="A", + project_no="P1", + product_name="产品A", + raw_material_batch_no="LOT-1", + process_name="1", + good_qty=60, + defect_qty=0, + scrap_qty=0, + standard_beat=1, + over_limit_status=OVER_LIMIT_STATUS_EXCEEDED, + over_limit_type="first_process_material", + over_limit_reason="1序累计报工数量超过本批次材料可支撑数量", + over_limit_checked_at=datetime(2026, 7, 25, 10), + over_limit_check_source="submit", + over_limit_batch_no="LOT-1", + over_limit_product_gross_weight_kg=Decimal("2.0000"), + over_limit_issued_weight_kg=Decimal("100.000000"), + over_limit_max_reportable_qty=Decimal("50.00"), + over_limit_current_cumulative_qty=Decimal("60.00"), + over_limit_current_report_qty=Decimal("60.00"), + ) + report.items = [item] + report.allocations = [] + report.audit_logs = [] + + output = report_out(report) + + assert output.has_over_limit is True + assert output.over_limit_summary == "1项超额" + assert output.items[0].over_limit_status == OVER_LIMIT_STATUS_EXCEEDED + assert output.items[0].over_limit_reason == "1序累计报工数量超过本批次材料可支撑数量" + assert output.items[0].over_limit_max_reportable_qty == 50 +``` + +- [ ] **Step 2: Run serializer test and verify failure** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m pytest tests/test_report_over_limit.py::test_report_out_exposes_over_limit_snapshot_fields -v +``` + +Expected: FAIL because schema fields are missing. + +- [ ] **Step 3: Add schema fields** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/schemas.py`, add to `ReportItemOut`: + +```python + over_limit_status: str = "not_checked" + over_limit_type: str | None = None + over_limit_reason: str | None = None + over_limit_checked_at: datetime | None = None + over_limit_check_source: str | None = None + over_limit_batch_no: str | None = None + over_limit_product_gross_weight_kg: float | None = None + over_limit_issued_weight_kg: float | None = None + over_limit_max_reportable_qty: float | None = None + over_limit_previous_good_qty: float | None = None + over_limit_current_cumulative_qty: float | None = None + over_limit_current_report_qty: float | None = None +``` + +Add to `ReportOut`: + +```python + has_over_limit: bool = False + over_limit_summary: str | None = None +``` + +- [ ] **Step 4: Map serializer fields** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/serializers.py`, add these keyword arguments in `report_item_out`: + +```python + over_limit_status=item.over_limit_status or "not_checked", + over_limit_type=item.over_limit_type, + over_limit_reason=item.over_limit_reason, + over_limit_checked_at=item.over_limit_checked_at, + over_limit_check_source=item.over_limit_check_source, + over_limit_batch_no=item.over_limit_batch_no, + over_limit_product_gross_weight_kg=optional_float(item.over_limit_product_gross_weight_kg), + over_limit_issued_weight_kg=optional_float(item.over_limit_issued_weight_kg), + over_limit_max_reportable_qty=optional_float(item.over_limit_max_reportable_qty), + over_limit_previous_good_qty=optional_float(item.over_limit_previous_good_qty), + over_limit_current_cumulative_qty=optional_float(item.over_limit_current_cumulative_qty), + over_limit_current_report_qty=optional_float(item.over_limit_current_report_qty), +``` + +Add these keyword arguments in `report_out`: + +```python + has_over_limit=bool(report.has_over_limit), + over_limit_summary=report.over_limit_summary, +``` + +- [ ] **Step 5: Run serializer test** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m pytest tests/test_report_over_limit.py::test_report_out_exposes_over_limit_snapshot_fields -v +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +git add app/schemas.py app/services/serializers.py tests/test_report_over_limit.py +git commit -m "feat: 输出报工超额快照字段" +``` + +Expected: commit succeeds. + +--- + +### Task 5: Add Dashboard And Export Fields + +**Files:** +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/schemas.py` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/dashboard.py` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/excel_export.py` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_dashboard_allocations.py` + +- [ ] **Step 1: Add dashboard test** + +Append to `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_dashboard_allocations.py`: + +```python +def test_dashboard_rows_include_over_limit_summary_from_reports(): + report = SimpleNamespace( + id=1, + attendance_point_name="总厂", + employee_phone="13800000000", + report_date=date(2026, 7, 25), + has_over_limit=True, + over_limit_summary="1项超额", + review_remark=None, + employee=SimpleNamespace(name="张三", is_temporary=False), + items=[], + ) + item = SimpleNamespace( + id=1, + report=report, + report_id=1, + attendance_point_name="总厂", + device_no="A", + project_no="P1", + product_name="产品A", + material_code="M1", + material_name="材料A", + raw_material_batch_no="LOT-1", + process_name="1", + stamping_method="冲压", + operator_count=1, + process_unit_price_yuan=1, + standard_beat=1, + good_qty=60, + defect_qty=0, + scrap_qty=0, + over_limit_status="exceeded", + over_limit_reason="1序累计报工数量超过本批次材料可支撑数量", + ) + report.items = [item] + allocation = SimpleNamespace( + report=report, + item=item, + report_id=1, + allocation_date=date(2026, 7, 25), + attendance_point_name="总厂", + employee_phone="13800000000", + good_qty=60, + defect_qty=0, + scrap_qty=0, + effective_minutes=60, + day_minutes=60, + overtime_minutes=0, + night_minutes=0, + reference_wage=60, + changeover_count=0, + ) + + rows = _dashboard_rows_from_allocations([allocation]) + + assert rows[0].has_over_limit is True + assert rows[0].over_limit_summary == "1项超额" + assert rows[0].over_limit_reasons == "1序累计报工数量超过本批次材料可支撑数量" +``` + +- [ ] **Step 2: Run dashboard test and verify failure** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m pytest tests/test_dashboard_allocations.py::test_dashboard_rows_include_over_limit_summary_from_reports -v +``` + +Expected: FAIL because dashboard schema does not expose fields. + +- [ ] **Step 3: Add dashboard schema fields** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/schemas.py`, add to `DashboardRow`: + +```python + has_over_limit: bool = False + over_limit_summary: str | None = None + over_limit_reasons: str | None = None +``` + +- [ ] **Step 4: Aggregate dashboard fields** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/dashboard.py`, initialize group fields: + +```python + "has_over_limit": False, + "over_limit_summaries": [], + "over_limit_summary_keys": set(), + "over_limit_reasons": [], + "over_limit_reason_keys": set(), +``` + +Inside allocation loop, after `report = getattr(allocation, "report", None)` and `item = getattr(allocation, "item", None)`, add: + +```python + if getattr(report, "has_over_limit", False): + group["has_over_limit"] = True + summary = str(getattr(report, "over_limit_summary", "") or "").strip() + if summary and summary not in group["over_limit_summary_keys"]: + group["over_limit_summary_keys"].add(summary) + group["over_limit_summaries"].append(summary) + reason = str(getattr(item, "over_limit_reason", "") or "").strip() + if reason and reason not in group["over_limit_reason_keys"]: + group["over_limit_reason_keys"].add(reason) + group["over_limit_reasons"].append(reason) +``` + +When constructing `DashboardRow`, add: + +```python + has_over_limit=group["has_over_limit"], + over_limit_summary=";".join(group["over_limit_summaries"]) or None, + over_limit_reasons=";".join(group["over_limit_reasons"]) or None, +``` + +- [ ] **Step 5: Add export columns** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/dashboard.py`, extend `DASHBOARD_EXPORT_HEADERS` or equivalent export header mapping with: + +```python + "has_over_limit": "是否超额", + "over_limit_summary": "超额摘要", + "over_limit_reasons": "超额原因", +``` + +Map boolean as: + +```python +"是" if row.has_over_limit else "否" +``` + +- [ ] **Step 6: Run dashboard/export tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m pytest tests/test_dashboard_allocations.py -v +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +git add app/schemas.py app/routers/dashboard.py app/services/excel_export.py tests/test_dashboard_allocations.py +git commit -m "feat: 经理看板展示报工超额信息" +``` + +Expected: commit succeeds. + +--- + +### Task 6: Frontend Mapping And Display + +**Files:** +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review/review.wxml` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review/review.wxss` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/records/records.wxml` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/records/records.wxss` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard/dashboard.wxml` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard/dashboard.wxss` + +- [ ] **Step 1: Map API fields** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js`, in report item mapping, add: + +```javascript + overLimitStatus: row.over_limit_status || 'not_checked', + overLimitType: row.over_limit_type || '', + overLimitReason: row.over_limit_reason || '', + overLimitCheckedAt: row.over_limit_checked_at || '', + overLimitCheckSource: row.over_limit_check_source || '', + overLimitBatchNo: row.over_limit_batch_no || '', + overLimitProductGrossWeightKg: row.over_limit_product_gross_weight_kg ?? '', + overLimitIssuedWeightKg: row.over_limit_issued_weight_kg ?? '', + overLimitMaxReportableQty: row.over_limit_max_reportable_qty ?? '', + overLimitPreviousGoodQty: row.over_limit_previous_good_qty ?? '', + overLimitCurrentCumulativeQty: row.over_limit_current_cumulative_qty ?? '', + overLimitCurrentReportQty: row.over_limit_current_report_qty ?? '', +``` + +In report mapping, add: + +```javascript + hasOverLimit: Boolean(row.has_over_limit), + overLimitSummary: row.over_limit_summary || '', +``` + +In dashboard row mapping, add: + +```javascript + hasOverLimit: Boolean(row.has_over_limit), + overLimitSummary: row.over_limit_summary || '', + overLimitReasons: row.over_limit_reasons || '', +``` + +- [ ] **Step 2: Add review page display** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review/review.wxml`, near existing card watermarks, add: + +```xml + 超额 +``` + +Inside each detail card metadata area, add: + +```xml + + {{detail.overLimitStatus === 'exceeded' ? '超额' : '无法校验'}} + {{detail.overLimitReason || '-'}} + 本次消耗 {{detail.overLimitCurrentReportQty || '-'}} + 累计消耗 {{detail.overLimitCurrentCumulativeQty || '-'}} + 最大可报 {{detail.overLimitMaxReportableQty}} + 前序可用 {{detail.overLimitPreviousGoodQty}} + +``` + +- [ ] **Step 3: Add shared page styles** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review/review.wxss`, add: + +```css +.over-limit-watermark { + position: absolute; + right: 24rpx; + top: 24rpx; + transform: rotate(12deg); + color: rgba(220, 38, 38, 0.85); + border: 2rpx solid rgba(220, 38, 38, 0.55); + border-radius: 8rpx; + padding: 6rpx 14rpx; + font-size: 24rpx; + font-weight: 700; +} + +.over-limit-panel { + margin-top: 16rpx; + padding: 14rpx 16rpx; + border-radius: 8rpx; + display: flex; + flex-direction: column; + gap: 6rpx; +} + +.over-limit-panel.danger { + background: #fef2f2; + border: 1rpx solid #fecaca; +} + +.over-limit-panel.warn { + background: #fffbeb; + border: 1rpx solid #fde68a; +} + +.over-limit-title { + font-size: 24rpx; + font-weight: 700; + color: #b91c1c; +} + +.over-limit-text { + font-size: 22rpx; + color: #7f1d1d; +} +``` + +Add equivalent styles to `records.wxss` and `dashboard.wxss`, or reuse existing page-local class names with same CSS. + +- [ ] **Step 4: Add records page display** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/records/records.wxml`, add: + +```xml + 超额 +``` + +Inside detail card: + +```xml + + {{detail.overLimitStatus === 'exceeded' ? '超额' : '无法校验'}} + {{detail.overLimitReason || '-'}} + +``` + +- [ ] **Step 5: Add dashboard page display** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard/dashboard.wxml`, add: + +```xml + 超额 +``` + +Near metrics or remark area: + +```xml + + {{item.overLimitSummary || '超额'}} + {{item.overLimitReasons}} + +``` + +- [ ] **Step 6: Run frontend syntax check** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS +node --check utils/api.js +``` + +Expected: exit code 0. + +- [ ] **Step 7: Commit** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS +git add utils/api.js pages/review/review.wxml pages/review/review.wxss pages/records/records.wxml pages/records/records.wxss pages/dashboard/dashboard.wxml pages/dashboard/dashboard.wxss +git commit -m "feat: 小程序展示报工超额快照" +``` + +Expected: commit succeeds. Do not stage `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/config.js` if it has unrelated local edits. + +--- + +### Task 7: Execute Database Migration And Verify + +**Files:** +- Execute: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/scripts/migrate_report_over_limit_snapshot.py` + +- [ ] **Step 1: Run migration** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python scripts/migrate_report_over_limit_snapshot.py +``` + +Expected: + +```text +report over limit snapshot migrated +``` + +- [ ] **Step 2: Verify MySQL columns and indexes** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python - <<'PY' +from sqlalchemy import text +from app.database import engine + +checks = { + "production_reports.has_over_limit": """ + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'production_reports' + AND column_name = 'has_over_limit' + """, + "production_reports.over_limit_summary": """ + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'production_reports' + AND column_name = 'over_limit_summary' + """, + "production_report_items.over_limit_status": """ + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'production_report_items' + AND column_name = 'over_limit_status' + """, + "idx_reports_over_limit": """ + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'production_reports' + AND index_name = 'idx_reports_over_limit' + """, + "idx_report_items_process_batch": """ + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'production_report_items' + AND index_name = 'idx_report_items_process_batch' + """, +} + +with engine.connect() as conn: + for name, sql in checks.items(): + value = conn.execute(text(sql)).scalar_one() + print(f"{name}: {value}") + if value < 1: + raise SystemExit(f"missing {name}") +PY +``` + +Expected: every printed value is at least `1`, command exits 0. + +- [ ] **Step 3: Commit migration execution note only if a file changed** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +git status --short +``` + +Expected: no file changes from executing the script. Do not create a commit for pure database execution. + +--- + +### Task 8: Full Verification + +**Files:** +- Verify backend and frontend repos. + +- [ ] **Step 1: Run backend targeted tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m pytest tests/test_report_over_limit.py tests/test_dashboard_allocations.py -v +``` + +Expected: all pass. + +- [ ] **Step 2: Run backend full suite** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m pytest -v +``` + +Expected: all pass. + +- [ ] **Step 3: Compile changed backend modules** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m py_compile \ + app/services/report_over_limit.py \ + scripts/migrate_report_over_limit_snapshot.py +``` + +Expected: exit code 0. + +- [ ] **Step 4: Run backend whitespace check** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +git diff --check +``` + +Expected: no output, exit code 0. + +- [ ] **Step 5: Run frontend syntax check** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS +node --check utils/api.js +``` + +Expected: exit code 0. + +- [ ] **Step 6: Check git status in both repos** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +git status --short --branch + +cd /Users/souplearn/Gitlab/app/JhHardwareWRS +git status --short --branch +``` + +Expected: + +- Backend contains only intended commits or is clean. +- Frontend contains intended commit; unrelated `utils/config.js` must not be staged or committed. + +- [ ] **Step 7: Final report** + +Report to the user: + +```text +已完成: +- 提交时固化超额快照 +- 第一道工序按批次投入重量/产品毛重校验 +- 后续工序按前序成品数量校验 +- 审核、记录、经理看板、导出展示超额信息 +- 已执行数据库脚本并验证字段/索引 + +验证: +- 后端 targeted tests: 通过 +- 后端 full pytest: 通过 +- 前端 node --check utils/api.js: 通过 +``` + +## Self-Review + +- Spec coverage: 覆盖提交时快照固化、第一道工序材料上限、后续工序前序上限、数量口径、页面展示、导出、数据库迁移和验证。 +- Placeholder scan: 已扫描占位词和空泛步骤,未发现需要补全的内容。 +- Type consistency: 后端字段统一使用 snake_case;前端映射统一使用 camelCase;状态值统一为 `not_checked`、`ok`、`exceeded`、`uncheckable`。 +- Scope check: 该计划是单一闭环功能,可独立测试和发布;不包含 ERP 投料页面和动态历史重算。