2045 lines
74 KiB
Markdown
2045 lines
74 KiB
Markdown
# 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.
|