ForgeFlow-ERP/docs/superpowers/plans/2026-06-08-smart-operation-report-toggle.md
2026-06-12 16:00:56 +08:00

2045 lines
74 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# Production Batch Ledger And Smart Reporting Toggle 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:** Replace the production execution core from “生产工单” to “生产台账”, where each row is uniquely identified by `材料库存批次号 + 产品`, while keeping the intelligent operation-reporting miniapp as an optional integration controlled by a system switch.
**Architecture:** Add a new production ledger aggregate table keyed by `material_lot_id + product_item_id`, with an internal numeric ID for stable references and a ledger transaction table for every production issue, finished inbound, surplus inbound, scrap inbound, material lock, reopen, and write-off event. New production flows write to this ledger instead of creating new `pp_work_order` rows; old work-order tables remain for historical compatibility and rework flows until a separate cleanup project. The system switch `对接智能报工小程序` controls whether miniapp operation reports drive progress calculations and UI visibility; when disabled, progress is inferred from ERP inbound data and BOM gross/net weights.
**Tech Stack:** FastAPI, SQLAlchemy, Pydantic, MySQL patch SQL, Vue 3, Vite, Node static UI checks, Python `unittest`/`pytest`, miniapp backend FastAPI service under `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint`.
---
## Business Rules Locked In
- Production ledger business key:
```text
材料库存批次号 + 产品
```
- Physical database primary key:
```text
production_ledger_id
```
- Unique database constraint:
```text
material_lot_id + product_item_id
```
- Ledger statuses are Chinese user/business values:
```text
在生产
锁单
```
- “该批材料结单” means this material lot is no longer outside the warehouse for this product. Any remaining outside weight is confirmed by a write-off ledger transaction and the row status becomes `锁单`.
- If the same `材料库存批次号 + 产品` is issued again after locking, the same ledger row reopens to `在生产`, the new issue weight is accumulated, and a `重新开工` transaction is recorded.
- Miniapp dropdown source is active production ledger rows, not `pp_work_order`. The miniapp shows material stock lot numbers for rows where `状态 = 在生产` and product matches the scanned product/process context.
- When the smart reporting switch is enabled, operation reports are linked to production ledger rows and drive operation-detail progress.
- When the smart reporting switch is disabled, `工序报工` UI is hidden and production calculations use ERP data:
```text
理论已用料 = 已入库成品数量 × BOM毛重
理论废料 = 已入库成品数量 × (BOM毛重 - BOM净重)
理论余料 = 累计领料重量 - 理论已用料
```
- Scrap rate is not separately modeled in disabled mode; it is handled through the 15% deviation explanation mechanism.
---
## File Structure
- Create: `backend/sql/add_production_batch_ledger_patch.sql`
- Create `pp_production_batch_ledger` and `pp_production_batch_ledger_txn`.
- Add optional `production_ledger_id` columns to existing ERP operation report tables for compatibility.
- Seed `SMART_OPERATION_REPORT_ENABLED` config as enabled.
- Modify: `backend/app/models/operations.py`
- Add `ProductionBatchLedger` and `ProductionBatchLedgerTxn`.
- Add nullable `production_ledger_id` to `OperationReport`.
- Modify: `backend/app/schemas/operations.py`
- Add read/create schemas for production ledger, ledger transactions, ledger inbound preview, ledger inbound create, and material lock request.
- Modify: `backend/app/services/system_config.py`
- Add `SMART_OPERATION_REPORT_ENABLED` helpers.
- Modify: `backend/app/schemas/database.py`
- Add `SmartOperationReportConfigRead` and `SmartOperationReportConfigUpdate`.
- Modify: `backend/app/api/routes/system_extension.py`
- Add switch GET/PUT endpoints.
- Create: `backend/app/services/production_batch_ledger.py`
- Own all new production ledger aggregation, reopen, lock, progress, and inbound calculations.
- Modify: `backend/app/api/routes/production.py`
- Add new production ledger endpoints.
- Change production issue endpoint to upsert ledger rows instead of creating new `pp_work_order` rows.
- Keep legacy work-order endpoints as read-compatible routes while frontend migrates.
- Bind miniapp report sync to production ledger IDs.
- Modify: `backend/app/api/routes/inventory.py`
- Change production surplus and scrap branch inbound to select and update production ledger.
- Keep rework work-order logic untouched.
- Modify: `backend/app/services/production_work_order_ledger.py`
- Keep only as legacy compatibility, delegating normal production ledger reads to the new service if needed.
- Modify: `backend/app/services/production_work_order_inbound.py`
- Keep legacy compatibility, but normal unified production inbound should call the new production ledger service.
- Create: `backend/tests/test_production_batch_ledger.py`
- Covers issue accumulation, reopen, lock, write-off, enabled/disabled calculations.
- Create: `backend/tests/test_smart_operation_report_config.py`
- Covers system switch defaults and updates.
- Modify: `backend/tests/test_selected_stock_lot_production_issue.py`
- Update expectations from work-order creation to production-ledger upsert.
- Modify: `backend/tests/test_production_work_order_inbound.py`
- Split legacy tests and add new ledger inbound tests, or migrate assertions to production ledger APIs.
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reports.py`
- Change material batch options SQL from `pp_work_order` to `pp_production_batch_ledger`.
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_work_order_batch_options.py`
- Rename test intent to production ledger material batch options.
- Modify: `frontend/src/router/index.js`
- Add `production-ledger` route and redirect old `work-order-ledger` to it.
- Hide direct `operation-reports` when smart reporting switch is disabled.
- Modify: `frontend/src/App.vue`
- Rename menu `工单台账` to `生产台账`.
- Hide `工序报工` when disabled.
- Create: `frontend/src/views/ProductionLedgerView.vue`
- Replace `WorkOrderManagementView.vue` as the main production ledger page.
- Modify: `frontend/src/views/ProductionWorkspaceView.vue`
- Rename and explain the new production ledger concept.
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- Rename `生产工单入库` to `生产台账入库`.
- Branch selectors use production ledger rows.
- Main close action becomes `该批材料结单`.
- Modify: `frontend/src/views/SystemExtensionView.vue`
- Add `对接智能报工小程序` switch.
- Create: `frontend/src/services/systemFeatures.js`
- Cache feature switch config.
- Create: `frontend/scripts/test-production-batch-ledger-ui.mjs`
- Static checks for renamed UI and route migration.
- Create: `frontend/scripts/test-smart-operation-report-toggle.mjs`
- Static checks for switch and hidden miniapp UI.
---
## Task 1: Add Database Patch And ORM Models
**Files:**
- Create: `backend/sql/add_production_batch_ledger_patch.sql`
- Modify: `backend/app/models/operations.py`
- Modify: `backend/app/schemas/operations.py`
- Test: `backend/tests/test_production_batch_ledger.py`
- [ ] **Step 1: Write failing model test**
Create `backend/tests/test_production_batch_ledger.py`:
```python
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 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()
def tearDown(self) -> None:
self.db.close()
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")
product = Item(id=2, item_code="FG-001", item_name="钢制碗", item_type="FINISHED_GOOD", unit_weight_kg=Decimal("0.5"), status="ACTIVE")
warehouse = Warehouse(id=1, warehouse_code="WH-RAW", warehouse_name="原材料库", warehouse_type="RAW", status="ACTIVE")
lot = StockLot(
id=1,
lot_no="YL0001",
lot_role="RAW_MATERIAL",
item_id=1,
warehouse_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",
)
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=datetime(2026, 6, 8, 8, 30),
last_issue_time=datetime(2026, 6, 8, 8, 30),
)
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=datetime(2026, 6, 8, 8, 30),
operator_user_id=1,
remark="生产出库累计到生产台账",
)
)
self.db.commit()
saved = self.db.scalar(select(ProductionBatchLedger).where(ProductionBatchLedger.material_lot_no == "YL0001"))
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, "生产出库")
```
- [ ] **Step 2: Run model test and verify failure**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
python3 -m pytest backend/tests/test_production_batch_ledger.py -q
```
Expected: FAIL because `ProductionBatchLedger` and `ProductionBatchLedgerTxn` do not exist.
- [ ] **Step 3: Add SQL patch**
Create `backend/sql/add_production_batch_ledger_patch.sql`:
```sql
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 '锁单次数',
reopen_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, miniapp_selectable),
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 '重量变化',
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 DEFAULT CURRENT_TIMESTAMP COMMENT '业务时间',
operator_user_id BIGINT NULL COMMENT '操作人ID',
remark VARCHAR(500) NULL COMMENT '备注',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
KEY idx_production_batch_ledger_txn_ledger (production_ledger_id, biz_time),
KEY idx_production_batch_ledger_txn_type (txn_type),
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='生产台账流水';
ALTER TABLE pp_operation_report
ADD COLUMN production_ledger_id BIGINT NULL COMMENT '生产台账ID' AFTER work_order_operation_id;
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'
);
```
Use this duplicate-column-safe form on MySQL if the simple `ALTER TABLE` fails because the column already exists:
```sql
SET @column_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'pp_operation_report'
AND COLUMN_NAME = 'production_ledger_id'
);
SET @ddl := IF(
@column_exists = 0,
'ALTER TABLE pp_operation_report ADD COLUMN production_ledger_id BIGINT NULL COMMENT ''生产台账ID'' AFTER work_order_operation_id',
'SELECT ''pp_operation_report.production_ledger_id already exists'''
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
```
- [ ] **Step 4: Add ORM models**
Modify `backend/app/models/operations.py`:
```python
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"))
reopen_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)
```
Add to `OperationReport`:
```python
production_ledger_id: Mapped[int | None] = mapped_column(ForeignKey("pp_production_batch_ledger.id"), nullable=True)
```
- [ ] **Step 5: Add schemas**
Modify `backend/app/schemas/operations.py`:
```python
class ProductionBatchLedgerTxnRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
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
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
reopen_count: int = 0
remark: str | None = None
txns: list[ProductionBatchLedgerTxnRead] = []
operations: list[WorkOrderLedgerOperationRow] = []
```
Use existing operation-row schema names if they differ; keep response fields readable for frontend.
- [ ] **Step 6: Run model test**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
python3 -m pytest backend/tests/test_production_batch_ledger.py -q
python3 -m py_compile backend/app/models/operations.py backend/app/schemas/operations.py
```
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
git add backend/sql/add_production_batch_ledger_patch.sql backend/app/models/operations.py backend/app/schemas/operations.py backend/tests/test_production_batch_ledger.py
git commit -m "feat: add production batch ledger schema"
```
---
## Task 2: Build Production Ledger Service
**Files:**
- Create: `backend/app/services/production_batch_ledger.py`
- Modify: `backend/tests/test_production_batch_ledger.py`
- [ ] **Step 1: Add failing service tests**
Append to `backend/tests/test_production_batch_ledger.py`:
```python
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], ["生产出库", "结单核销", "该批材料结单", "重新开工", "生产出库"])
```
Add helper inside the test class:
```python
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")
product = Item(id=2, item_code="FG-001", item_name="钢制碗", item_type="FINISHED_GOOD", unit_weight_kg=Decimal("0.5"), status="ACTIVE")
warehouse = Warehouse(id=1, warehouse_code="WH-RAW", warehouse_name="原材料库", warehouse_type="RAW", status="ACTIVE")
lot = StockLot(
id=1,
lot_no="YL0001",
lot_role="RAW_MATERIAL",
item_id=1,
warehouse_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",
)
self.db.add_all([raw_item, product, warehouse, lot])
self.db.flush()
```
- [ ] **Step 2: Run tests and verify failure**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
python3 -m pytest backend/tests/test_production_batch_ledger.py -q
```
Expected: FAIL because `production_batch_ledger.py` service does not exist.
- [ ] **Step 3: Implement service**
Create `backend/app/services/production_batch_ledger.py`:
```python
from __future__ import annotations
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 BomItem, Item
from app.models.operations import ProductionBatchLedger, ProductionBatchLedgerTxn, StockLot
from app.services.operations import to_decimal
INBOUND_TOLERANCE_MULTIPLIER = Decimal("1.15")
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)
return txn
def _get_locked_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:
if issued_weight_kg <= 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_locked_ledger(db, material_lot_id, product_item_id)
if ledger and str(ledger.status or "") == "锁单":
ledger.status = "在生产"
ledger.miniapp_selectable = 1
ledger.reopened_at = now
ledger.reopen_count = int(ledger.reopen_count or 0) + 1
_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_kg)
ledger.outside_weight_kg = _round_weight(to_decimal(ledger.outside_weight_kg) + issued_weight_kg)
ledger.last_issue_time = now
ledger.updated_at = now
db.add(ledger)
db.flush()
_append_txn(
db,
ledger=ledger,
txn_type="生产出库",
weight_delta_kg=issued_weight_kg,
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
ledger.updated_at = now
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
```
- [ ] **Step 4: Run service tests**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
python3 -m pytest backend/tests/test_production_batch_ledger.py -q
python3 -m py_compile backend/app/services/production_batch_ledger.py
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add backend/app/services/production_batch_ledger.py backend/tests/test_production_batch_ledger.py
git commit -m "feat: manage production ledger issue lock and reopen"
```
---
## Task 3: Refactor Production Issue To Upsert Production Ledger
**Files:**
- Modify: `backend/app/api/routes/production.py`
- Modify: `backend/tests/test_selected_stock_lot_production_issue.py`
- Modify: `backend/tests/test_production_batch_ledger.py`
- [ ] **Step 1: Write failing API/service behavior test**
Update `backend/tests/test_selected_stock_lot_production_issue.py` so production issue asserts:
```python
def test_production_issue_creates_or_updates_production_batch_ledger(...):
response = client.post("/api/production/work-orders", json=payload)
assert response.status_code == 200
data = response.json()
assert data["production_ledger_id"] > 0
assert data["material_lot_no"] == "YL0001"
assert data["product_name"] == "钢制碗"
assert data["status"] == "在生产"
assert data["total_issued_weight_kg"] == 200
```
If this test file is not client-based, add a direct route/service test using its existing setup style. Keep endpoint path `/production/work-orders` for one release as compatibility, but make the returned object a production ledger read model.
- [ ] **Step 2: Run test and verify failure**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
python3 -m pytest backend/tests/test_selected_stock_lot_production_issue.py -q
```
Expected: FAIL because route still creates `pp_work_order`.
- [ ] **Step 3: Change production issue route**
Modify `backend/app/api/routes/production.py`:
```python
from app.models.operations import ProductionBatchLedger
from app.schemas.operations import ProductionBatchLedgerRead
from app.services.production_batch_ledger import record_production_issue_to_ledger, production_batch_ledger_to_read
```
Update route decorator:
```python
@router.post("/work-orders", response_model=ProductionBatchLedgerRead)
```
Inside `create_work_order`, after stock lot validation and after `issue_selected_stock_lots` or equivalent inventory deduction, call:
```python
ledger = record_production_issue_to_ledger(
db,
material_lot_id=int(selected_lot.id),
product_item_id=int(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 "原材料库生产出库形成生产台账",
)
db.commit()
return production_batch_ledger_to_read(db, ledger.id)
```
Do not create a new `WorkOrder` for normal production issue. Rework flows still use `WorkOrder`.
- [ ] **Step 4: Preserve legacy endpoint names with new semantics**
Add new endpoint alias:
```python
@router.get("/production-ledger", response_model=list[ProductionBatchLedgerRead])
def list_production_batch_ledger(...):
...
```
Keep old endpoint:
```python
@router.get("/work-order-ledger", response_model=list[ProductionBatchLedgerRead])
def list_work_order_ledger(...):
return list_production_batch_ledger(...)
```
This lets frontend migrate route names without breaking old bookmarks immediately.
- [ ] **Step 5: Run focused tests**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
python3 -m pytest backend/tests/test_selected_stock_lot_production_issue.py backend/tests/test_production_batch_ledger.py -q
```
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add backend/app/api/routes/production.py backend/tests/test_selected_stock_lot_production_issue.py backend/tests/test_production_batch_ledger.py
git commit -m "feat: make production issue update batch ledger"
```
---
## Task 4: Implement Ledger Progress Calculations And Inbound APIs
**Files:**
- Modify: `backend/app/services/production_batch_ledger.py`
- Modify: `backend/app/api/routes/production.py`
- Modify: `backend/app/api/routes/inventory.py`
- Modify: `backend/app/schemas/operations.py`
- Modify: `backend/tests/test_production_batch_ledger.py`
- [ ] **Step 1: Add calculation tests**
Append tests:
```python
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)
```
- [ ] **Step 2: Run tests and verify failure**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
python3 -m pytest backend/tests/test_production_batch_ledger.py -q
```
Expected: FAIL until calculation and inbound helpers exist.
- [ ] **Step 3: Add snapshot and inbound helpers**
In `backend/app/services/production_batch_ledger.py`, add:
```python
@dataclass
class ProductionBatchLedgerSnapshot:
production_ledger_id: int
material_lot_no: str
product_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
```
Implement:
```python
def calculate_production_batch_ledger_snapshot(
db: Session,
production_ledger_id: int,
*,
smart_reporting_enabled: bool,
selected_finished_qty: Decimal | None = None,
) -> ProductionBatchLedgerSnapshot:
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 + (selected_finished_qty or Decimal("0"))
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,
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,
)
```
Add helpers:
```python
def post_finished_inbound_to_ledger(...): ...
def post_surplus_inbound_to_ledger(...): ...
def post_scrap_inbound_to_ledger(...): ...
```
Each helper updates the matching aggregate field, subtracts the correct weight from `outside_weight_kg`, writes a Chinese transaction type, and returns the refreshed ledger.
- [ ] **Step 4: Update APIs**
Add in `backend/app/api/routes/production.py`:
```python
@router.get("/production-ledger-inbounds/preview", response_model=ProductionBatchLedgerInboundPreviewRead)
def preview_production_batch_ledger_inbound(...): ...
@router.post("/production-ledger-inbounds", response_model=ProductionBatchLedgerInboundRead)
def create_production_batch_ledger_inbound(...): ...
```
Payload fields:
```python
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
```
If `lock_material_batch` is true, call `lock_production_batch_ledger` after posting inbound rows.
- [ ] **Step 5: Update branch entries**
In `backend/app/api/routes/inventory.py`, for normal production surplus/scrap only:
- Read `payload.source_doc_type == "PRODUCTION_LEDGER"`.
- Read `payload.source_doc_id` as `production_ledger_id`.
- Call `post_surplus_inbound_to_ledger` or `post_scrap_inbound_to_ledger`.
- If payload requests lock, call `lock_production_batch_ledger`.
- Keep rework paths using `WORK_ORDER`.
- [ ] **Step 6: Run tests**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
python3 -m pytest backend/tests/test_production_batch_ledger.py backend/tests/test_production_work_order_inbound.py backend/tests/test_scrap_warehouse_flow.py -q
```
Expected: PASS after updating legacy tests to the new API where normal production is involved.
- [ ] **Step 7: Commit**
```bash
git add backend/app/services/production_batch_ledger.py backend/app/api/routes/production.py backend/app/api/routes/inventory.py backend/app/schemas/operations.py backend/tests/test_production_batch_ledger.py backend/tests/test_production_work_order_inbound.py backend/tests/test_scrap_warehouse_flow.py
git commit -m "feat: calculate and post production ledger inbounds"
```
---
## Task 5: Add Smart Operation Reporting Switch
**Files:**
- Modify: `backend/app/services/system_config.py`
- Modify: `backend/app/schemas/database.py`
- Modify: `backend/app/api/routes/system_extension.py`
- Create: `backend/tests/test_smart_operation_report_config.py`
- [ ] **Step 1: Write config tests**
Create `backend/tests/test_smart_operation_report_config.py`:
```python
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))
```
- [ ] **Step 2: Run tests and verify failure**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
python3 -m pytest backend/tests/test_smart_operation_report_config.py -q
```
Expected: FAIL until helpers exist.
- [ ] **Step 3: Implement config helpers**
Use Chinese persisted values:
```python
SMART_OPERATION_REPORT_CONFIG_CODE = "SMART_OPERATION_REPORT_ENABLED"
SMART_OPERATION_REPORT_DEFAULT = "开启"
def normalize_enabled_config_value(value: object) -> str:
if isinstance(value, bool):
return "开启" if value else "关闭"
normalized = str(value or "").strip().upper()
if normalized in {"1", "TRUE", "YES", "ON", "ENABLED", "开启", "打开", "启用", "是"}:
return "开启"
if normalized in {"0", "FALSE", "NO", "OFF", "DISABLED", "关闭", "停用", "否"}:
return "关闭"
return SMART_OPERATION_REPORT_DEFAULT
def get_smart_operation_report_enabled(db: Session) -> bool:
value = get_system_config_value(db, SMART_OPERATION_REPORT_CONFIG_CODE, SMART_OPERATION_REPORT_DEFAULT)
return normalize_enabled_config_value(value) == "开启"
def upsert_smart_operation_report_config(...):
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,
)
```
- [ ] **Step 4: Add API schemas and endpoints**
Add to `backend/app/schemas/database.py`:
```python
class SmartOperationReportConfigUpdate(BaseModel):
enabled: bool = True
remark: str | None = Field(default=None, max_length=500)
class SmartOperationReportConfigRead(BaseModel):
config_code: str
config_name: str
enabled: bool
config_value: str
remark: str | None = None
updated_at: datetime | None = None
```
Add endpoints in `backend/app/api/routes/system_extension.py`:
```python
@router.get("/smart-operation-report-config", response_model=SmartOperationReportConfigRead)
def get_smart_operation_report_config(...): ...
@router.put("/smart-operation-report-config", response_model=SmartOperationReportConfigRead)
def save_smart_operation_report_config(...): ...
```
- [ ] **Step 5: Run tests**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
python3 -m pytest backend/tests/test_smart_operation_report_config.py -q
python3 -m py_compile backend/app/services/system_config.py backend/app/schemas/database.py backend/app/api/routes/system_extension.py
```
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add backend/app/services/system_config.py backend/app/schemas/database.py backend/app/api/routes/system_extension.py backend/tests/test_smart_operation_report_config.py
git commit -m "feat: add smart reporting integration switch"
```
---
## Task 6: Refactor Miniapp Batch Options And ERP Report Sync
**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`
- Modify: `backend/app/api/routes/production.py`
- Modify: `backend/tests/test_miniapp_operation_report_date_filter.py`
- Modify: `backend/tests/test_production_batch_ledger.py`
- [ ] **Step 1: Update miniapp backend tests**
In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_work_order_batch_options.py`, replace setup tables from `pp_work_order` to `pp_production_batch_ledger`:
```sql
create table pp_production_batch_ledger (
id integer primary key,
material_lot_id integer,
material_lot_no text,
material_item_id integer,
product_item_id integer,
status text,
miniapp_selectable integer
);
```
Expected assertions:
```python
def test_raw_material_batch_options_returns_active_production_ledger_lot_numbers_for_product_process(monkeypatch):
...
assert options == ["YL0001"]
def test_raw_material_batch_options_excludes_locked_ledgers(monkeypatch):
...
assert options == ["YL0002"]
```
- [ ] **Step 2: Run miniapp backend test and verify failure**
Run:
```bash
cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
python -m pytest tests/test_work_order_batch_options.py -q
```
Expected: FAIL because `_raw_material_batch_options` still queries `pp_work_order`.
- [ ] **Step 3: Change miniapp dropdown SQL**
Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reports.py`:
```sql
select distinct ledger.material_lot_no
from pp_production_batch_ledger ledger
join md_item product on product.id = ledger.product_item_id
join md_process_route route on route.product_item_id = product.id and route.status = 'ACTIVE'
join md_process_route_operation op on op.route_id = route.id
where ledger.material_lot_no is not null
and trim(ledger.material_lot_no) <> ''
and ledger.status = '在生产'
and coalesce(ledger.miniapp_selectable, 1) = 1
and (:process_name = '' or op.operation_name = :process_name)
and (
(:product_name <> '' and :project_no <> '' and product.item_name = :product_name and product.item_code = :project_no)
or (:product_name <> '' and :project_no = '' and product.item_name = :product_name)
or (:product_name = '' and :project_no <> '' and product.item_code = :project_no)
)
order by ledger.material_lot_no asc
```
- [ ] **Step 4: Bind ERP operation sync to production ledger**
In `backend/app/api/routes/production.py`, replace `_find_work_order_for_miniapp_item` for normal production with `_find_production_ledger_for_miniapp_item`.
Rules:
```text
raw_material_batch_no = miniapp submitted material stock lot number
product = matched ERP product from miniapp product name/project no
production ledger = pp_production_batch_ledger where material_lot_no = raw_material_batch_no and product_item_id = product.id and status = 在生产
```
Operation reports should write:
```python
report.production_ledger_id = ledger.id
report.work_order_id = None
report.work_order_operation_id = None
```
Keep `work_order_id` for legacy/rework only.
- [ ] **Step 5: Add ERP sync test**
Add to `backend/tests/test_production_batch_ledger.py`:
```python
def test_miniapp_report_resolution_uses_production_ledger_lot_and_product(self) -> None:
from app.api.routes.production import _find_production_ledger_for_miniapp_item
self._seed_basic_items_bom_and_lot()
ledger = self._seed_ledger(total_issued_weight_kg=Decimal("200"), status="在生产")
row = {
"raw_material_batch_no": "YL0001",
"product_name": "钢制碗",
"project_no": "FG-001",
}
matched = _find_production_ledger_for_miniapp_item(self.db, row, cache=None)
self.assertEqual(matched.id, ledger.id)
```
- [ ] **Step 6: Run miniapp and ERP tests**
Run:
```bash
cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
python -m pytest tests/test_work_order_batch_options.py -q
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
python3 -m pytest backend/tests/test_production_batch_ledger.py backend/tests/test_miniapp_operation_report_date_filter.py -q
```
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git add backend/app/api/routes/production.py backend/tests/test_production_batch_ledger.py backend/tests/test_miniapp_operation_report_date_filter.py
git commit -m "feat: bind miniapp reports to production ledger"
cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
git add app/routers/reports.py tests/test_work_order_batch_options.py
git commit -m "feat: source material batch options from ERP production ledger"
```
---
## Task 7: Frontend Route And Page Migration
**Files:**
- Create: `frontend/src/services/systemFeatures.js`
- Create: `frontend/src/views/ProductionLedgerView.vue`
- Modify: `frontend/src/router/index.js`
- Modify: `frontend/src/App.vue`
- Modify: `frontend/src/views/ProductionWorkspaceView.vue`
- Modify: `frontend/src/views/SystemExtensionView.vue`
- Create: `frontend/scripts/test-production-batch-ledger-ui.mjs`
- Create: `frontend/scripts/test-smart-operation-report-toggle.mjs`
- [ ] **Step 1: Write static UI tests**
Create `frontend/scripts/test-production-batch-ledger-ui.mjs`:
```javascript
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
const root = process.cwd();
const read = (relativePath) => readFileSync(path.join(root, relativePath), "utf8");
const router = read("src/router/index.js");
const app = read("src/App.vue");
const productionLedger = read("src/views/ProductionLedgerView.vue");
const inventory = read("src/views/InventoryLedgerView.vue");
assert.match(router, /ProductionLedgerView/, "router should import ProductionLedgerView");
assert.match(router, /name:\s*"production-ledger"/, "router should expose production-ledger route");
assert.match(router, /path:\s*"\/work-order-ledger"[\s\S]*redirect:\s*\{\s*name:\s*"production-ledger"\s*\}/, "old work-order-ledger route should redirect to production-ledger");
assert.match(app, /生产台账/, "sidebar should show 生产台账");
assert.doesNotMatch(app, /工单台账/, "sidebar should no longer show 工单台账");
assert.match(productionLedger, /材料库存批次号/, "production ledger page should use material stock lot number");
assert.match(productionLedger, /产品/, "production ledger page should show product");
assert.match(productionLedger, /库外未闭环重量/, "production ledger page should show outside weight");
assert.match(productionLedger, /该批材料结单/, "production ledger page should expose material batch lock action");
assert.match(inventory, /生产台账入库/, "inventory unified inbound should be renamed to production ledger inbound");
assert.match(inventory, /该批材料结单/, "inventory close action should use material batch close wording");
console.log("production batch ledger UI static checks passed");
```
Create `frontend/scripts/test-smart-operation-report-toggle.mjs`:
```javascript
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
const root = process.cwd();
const read = (relativePath) => readFileSync(path.join(root, relativePath), "utf8");
const featureService = read("src/services/systemFeatures.js");
const router = read("src/router/index.js");
const app = read("src/App.vue");
const systemExtension = read("src/views/SystemExtensionView.vue");
const productionLedger = read("src/views/ProductionLedgerView.vue");
assert.match(featureService, /smart-operation-report-config/, "feature service should load switch config");
assert.match(router, /operation-reports/, "router should still define operation report route");
assert.match(router, /loadSmartOperationReportConfig/, "router should guard operation-reports by switch");
assert.match(app, /smartOperationReportEnabled/, "App should track switch state");
assert.match(app, /child\.key\s*===\s*"operation-reports"/, "App should hide operation reports child when disabled");
assert.match(systemExtension, /对接智能报工小程序/, "system extension should show switch");
assert.match(productionLedger, /工序明细/, "production ledger page should show operation detail only when enabled");
assert.match(productionLedger, /当前未对接智能报工小程序/, "production ledger page should explain disabled mode");
console.log("smart operation report toggle static checks passed");
```
- [ ] **Step 2: Run static tests and verify failure**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-production-batch-ledger-ui.mjs
node scripts/test-smart-operation-report-toggle.mjs
```
Expected: FAIL until frontend files are migrated.
- [ ] **Step 3: Add feature service**
Create `frontend/src/services/systemFeatures.js`:
```javascript
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", {
enabled: true,
config_value: "开启",
config_name: "对接智能报工小程序",
remark: ""
}).then((config) => {
smartOperationReportConfigCache = {
...config,
enabled: config?.enabled !== false
};
smartOperationReportConfigPromise = null;
return smartOperationReportConfigCache;
});
return smartOperationReportConfigPromise;
}
export function resetSmartOperationReportConfigCache(config = null) {
smartOperationReportConfigCache = config;
smartOperationReportConfigPromise = null;
}
```
- [ ] **Step 4: Create ProductionLedgerView**
Create `frontend/src/views/ProductionLedgerView.vue` using the structure of `WorkOrderManagementView.vue`, but rename concepts:
```vue
<template>
<section class="page-grid">
<section class="panel">
<div class="panel-header">
<div>
<p class="eyebrow">生产台账</p>
<h3>按材料库存批次号和产品追踪仓库外生产流转</h3>
</div>
<span class="panel-tag">材料批次 + 产品</span>
</div>
<div v-if="!smartOperationReportEnabled" class="workflow-tip">
当前未对接智能报工小程序生产进度按成品入库数量和BOM毛重/净重反推。
</div>
<TableControls ... placeholder="搜索材料库存批次号、产品、状态" />
<table class="data-table compact-table">
<thead>
<tr>
<th>材料库存批次号</th>
<th>产品</th>
<th>累计领料重量</th>
<th>库外未闭环重量</th>
<th>已入库成品</th>
<th>已归还余料</th>
<th>已入库废料</th>
<th>状态</th>
</tr>
</thead>
</table>
</section>
</section>
</template>
```
Load rows from:
```javascript
fetchResource("/production/production-ledger?limit=500", [])
```
- [ ] **Step 5: Update router and sidebar**
Modify `frontend/src/router/index.js`:
```javascript
import ProductionLedgerView from "../views/ProductionLedgerView.vue";
import { loadSmartOperationReportConfig } from "../services/systemFeatures";
```
Routes:
```javascript
{
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" }
}
```
Route guard:
```javascript
if (to.name === "operation-reports") {
const config = await loadSmartOperationReportConfig();
if (config.enabled === false) {
return { name: "production-ledger" };
}
}
```
Modify `frontend/src/App.vue` child menu:
```javascript
{
key: "production-ledger",
label: "生产台账",
to: { name: "production-ledger" },
routeNames: ["production-ledger", "work-order-ledger"],
permission: "MENU_WORK_ORDER",
...
}
```
- [ ] **Step 6: Add SystemExtension switch UI**
Add a panel in `frontend/src/views/SystemExtensionView.vue`:
```vue
<section class="panel">
<div class="panel-header">
<div>
<p class="eyebrow">生产模式配置</p>
<h3>对接智能报工小程序</h3>
</div>
<span class="panel-tag">{{ smartOperationReportForm.enabled ? "已对接" : "未对接" }}</span>
</div>
<form class="stack-form compact-extension-form" @submit.prevent="saveSmartOperationReportConfig">
<label class="checkbox-field extension-switch-field">
<input v-model="smartOperationReportForm.enabled" type="checkbox" />
<span>对接智能报工小程序</span>
</label>
<div class="workflow-tip">
{{ smartOperationReportForm.enabled ? "开启后显示工序报工,并按小程序报工数据计算生产进度。" : "关闭后隐藏工序报工生产进度按ERP入库数据和BOM反推。" }}
</div>
<button class="primary-button" type="submit">保存生产模式</button>
</form>
</section>
```
- [ ] **Step 7: Run frontend checks**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-production-batch-ledger-ui.mjs
node scripts/test-smart-operation-report-toggle.mjs
npm run build
```
Expected: PASS.
- [ ] **Step 8: Commit**
```bash
git add frontend/src/services/systemFeatures.js frontend/src/views/ProductionLedgerView.vue frontend/src/router/index.js frontend/src/App.vue frontend/src/views/ProductionWorkspaceView.vue frontend/src/views/SystemExtensionView.vue frontend/scripts/test-production-batch-ledger-ui.mjs frontend/scripts/test-smart-operation-report-toggle.mjs
git commit -m "feat: migrate frontend to production batch ledger"
```
---
## Task 8: Update Inventory Inbound UI To Production Ledger
**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- Modify: `frontend/scripts/test-production-batch-ledger-ui.mjs`
- Modify: `frontend/scripts/test-production-work-order-inbound-ui.mjs`
- [ ] **Step 1: Extend static checks**
Add to `frontend/scripts/test-production-batch-ledger-ui.mjs`:
```javascript
assert.doesNotMatch(inventory, /生产工单入库/, "inventory should not show old production work-order inbound wording");
assert.doesNotMatch(inventory, /工单号/, "production ledger inbound summary should not use 工单号");
assert.match(inventory, /生产台账入库/, "inventory should show production ledger inbound");
assert.match(inventory, /生产台账/, "branch selectors should use production ledger wording");
assert.match(inventory, /该批材料结单/, "close action should lock material batch production ledger");
assert.match(inventory, /库外未闭环重量/, "summary should show outside unclosed weight");
```
- [ ] **Step 2: Run static test and verify failure**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-production-batch-ledger-ui.mjs
```
Expected: FAIL until inventory UI wording and payloads are migrated.
- [ ] **Step 3: Rename unified inbound drawer**
In `frontend/src/views/InventoryLedgerView.vue`, rename:
```text
生产工单入库 -> 生产台账入库
工单号 -> 生产台账
本次入库后结单 -> 本次入库后该批材料结单
保存生产工单入库 -> 保存生产台账入库
```
Preview endpoint:
```javascript
fetchResource(`/production/production-ledger-inbounds/preview?production_ledger_id=${id}&finished_qty=${finishedQty}`, null)
```
Submit endpoint:
```javascript
postResource("/production/production-ledger-inbounds", {
production_ledger_id: Number(selectedProductionLedgerId.value),
finished_qty: finishedQty,
surplus_weight_kg: surplusWeight,
scrap_weight_kg: scrapWeight,
lock_material_batch: Boolean(productionLedgerInboundForm.lock_material_batch),
lock_confirmed: Boolean(productionLedgerInboundForm.lock_material_batch),
deviation_remark: productionLedgerInboundForm.deviation_remark || null,
remark: productionLedgerInboundForm.remark || null
})
```
- [ ] **Step 4: Update branch payloads**
For 原材料库 · 生产余料入库:
```javascript
source_doc_type: "PRODUCTION_LEDGER",
source_doc_id: Number(selectedProductionLedger.value.production_ledger_id)
```
For 废料库 · 生产废料入库:
```javascript
source_doc_type: "PRODUCTION_LEDGER",
source_doc_id: Number(selectedProductionLedger.value.production_ledger_id)
```
Close checkbox label:
```text
该批材料结单
```
Confirmation text:
```text
确认后该材料库存批次号将从小程序待选列表中剔除;系统会把当前库外未闭环重量作为结单核销记录。
```
- [ ] **Step 5: Run frontend checks**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-production-batch-ledger-ui.mjs
node scripts/test-production-work-order-inbound-ui.mjs
npm run build
```
Expected: PASS after updating old test names or replacing the old script with production ledger checks.
- [ ] **Step 6: Commit**
```bash
git add frontend/src/views/InventoryLedgerView.vue frontend/scripts/test-production-batch-ledger-ui.mjs frontend/scripts/test-production-work-order-inbound-ui.mjs
git commit -m "feat: use production ledger for warehouse production inbound"
```
---
## Task 9: Compatibility And Data Migration
**Files:**
- Create: `backend/sql/migrate_work_orders_to_production_batch_ledger.sql`
- Modify: `backend/app/api/routes/production.py`
- Test: manual SQL verification
- [ ] **Step 1: Create migration SQL**
Create `backend/sql/migrate_work_orders_to_production_batch_ledger.sql`:
```sql
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
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),
status = VALUES(status),
miniapp_selectable = VALUES(miniapp_selectable),
remark = CONCAT(COALESCE(pp_production_batch_ledger.remark, ''), ';历史工单迁移已更新');
```
Follow with transaction inserts:
```sql
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,
remark
)
SELECT
ledger.id,
'生产出库',
0,
issue.issued_weight_kg,
ledger.outside_weight_kg,
'历史生产工单',
issue.work_order_id,
issue.id,
issue.issue_time,
'由历史生产工单领料记录迁移'
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 = '生产出库'
);
```
- [ ] **Step 2: Run migration in test DB**
Run:
```bash
mysql -h "$DB_HOST" -P "${DB_PORT:-3306}" -u "$DB_USER" -p"$DB_PASSWORD" --default-character-set=utf8mb4 "$DB_NAME" < /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend/sql/add_production_batch_ledger_patch.sql
mysql -h "$DB_HOST" -P "${DB_PORT:-3306}" -u "$DB_USER" -p"$DB_PASSWORD" --default-character-set=utf8mb4 "$DB_NAME" < /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend/sql/migrate_work_orders_to_production_batch_ledger.sql
```
Expected: both commands exit `0`.
- [ ] **Step 3: Verify migration**
Run:
```sql
SELECT material_lot_no, product_item_id, status, total_issued_weight_kg, outside_weight_kg
FROM pp_production_batch_ledger
ORDER BY id DESC
LIMIT 20;
```
Expected: rows exist for historical normal work-order issue data.
- [ ] **Step 4: Commit**
```bash
git add backend/sql/migrate_work_orders_to_production_batch_ledger.sql
git commit -m "chore: add legacy work order to production ledger migration"
```
---
## Task 10: Full Verification
**Files:**
- Verify all touched backend, frontend, and miniapp backend files.
- [ ] **Step 1: Backend focused tests**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
python3 -m pytest \
backend/tests/test_production_batch_ledger.py \
backend/tests/test_smart_operation_report_config.py \
backend/tests/test_selected_stock_lot_production_issue.py \
backend/tests/test_production_work_order_inbound.py \
backend/tests/test_scrap_warehouse_flow.py \
backend/tests/test_miniapp_operation_report_date_filter.py \
-q
```
Expected: PASS.
- [ ] **Step 2: Frontend static checks and build**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-production-batch-ledger-ui.mjs
node scripts/test-smart-operation-report-toggle.mjs
node scripts/test-operation-report-date-filter.mjs
npm run build
```
Expected: PASS. Vite chunk-size warnings are acceptable.
- [ ] **Step 3: Miniapp backend checks**
Run:
```bash
cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
python -m pytest tests/test_work_order_batch_options.py -q
```
Expected: PASS.
- [ ] **Step 4: Manual smoke checks**
Start ERP frontend/backend locally and verify:
- `生产执行` menu shows `生产台账`, not `工单台账`.
- `生产台账` table rows are keyed by `材料库存批次号 + 产品`.
-原材料库生产出库 twice for the same material lot and product updates one ledger row.
- `该批材料结单` changes status to `锁单` and removes the material lot from miniapp dropdown.
- Issuing the same material lot/product after locking reopens the same ledger row to `在生产`.
- When `对接智能报工小程序` is enabled, `工序报工` is visible and ledger detail shows `工序明细`.
- When the switch is disabled, `工序报工` is hidden and ledger detail hides `工序明细`.
- `生产台账入库` calculates theoretical surplus/scrap immediately after finished quantity input.
- Branch entries for production surplus and production scrap update the same production ledger row.
- [ ] **Step 5: Final status check**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git status --short
```
Expected: only intentional files are modified.
---
## Self-Review
- The plan no longer treats `pp_work_order` as the normal production core.
- The new core is `pp_production_batch_ledger`, uniquely representing `材料库存批次号 + 产品`.
- The old `工单台账` wording is replaced by `生产台账`.
- The old “工单结单” concept is replaced by `该批材料结单`.
- The miniapp dropdown source is changed from work orders to active production ledger rows.
- The smart reporting switch remains part of the plan and controls UI/calculation mode.
- Rework work orders are intentionally left on legacy `pp_work_order` because they are a separate return/rework lifecycle.