2189 lines
79 KiB
Markdown
2189 lines
79 KiB
Markdown
# Raw Lot Work Order Inbound 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:** Implement原材料库存批次号全库递增、生产出库单批次合并工单、小程序按材料库存批次号报工、以及嘉恒仓库“生产工单入库”统一闭环成品/余料/废料。
|
||
|
||
**Architecture:** Add a small system-config service for the raw lot prefix, keep ERP production work orders as the internal container, and make raw stock lot IDs the external production-facing identifier. Production outbound becomes single-lot and merges into a deterministic daily work order; production work-order inbound becomes one transactional backend API that posts finished goods, returns surplus to the original raw lot, posts scrap, and optionally settles the work order.
|
||
|
||
**Tech Stack:** FastAPI, SQLAlchemy ORM, MySQL/SQLite-compatible tests, Vue 3 + Vite, WeChat miniapp frontend/backend, existing `pytest` and Node script tests.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
Backend ERP files:
|
||
|
||
- Create `backend/sql/add_raw_lot_work_order_inbound_patch.sql`: create generic `sys_system_config` table and seed `RAW_MATERIAL_LOT_PREFIX=YL`.
|
||
- Create `backend/app/services/system_config.py`: normalize config values and read/write `sys_system_config`.
|
||
- Create `backend/app/services/production_work_order_inbound.py`: calculation and transactional posting for production work-order inbound.
|
||
- Modify `backend/app/models/org.py`: add `SystemConfig` ORM model.
|
||
- Modify `backend/app/schemas/database.py`: add config read/update schemas.
|
||
- Modify `backend/app/schemas/operations.py`: add production work-order inbound preview/create/read schemas.
|
||
- Modify `backend/app/api/routes/system_extension.py`: expose raw material lot prefix config endpoints.
|
||
- Modify `backend/app/services/operations.py`: replace material-name lot numbering with raw-warehouse global prefix numbering and add deterministic work-order number helper.
|
||
- Modify `backend/app/api/routes/purchase.py`: keep purchase receipt path on `build_inventory_lot_no`, add test coverage only unless code needs explicit import changes.
|
||
- Modify `backend/app/api/routes/inventory.py`: use new raw lot numbering for raw opening/customer-supplied/special inbound paths and add read helpers used by frontend.
|
||
- Modify `backend/app/api/routes/production.py`: enforce single stock lot production outbound, merge same-day same-lot same-product work orders, resolve miniapp reports by material stock lot no, and expose production work-order inbound endpoints.
|
||
|
||
ERP tests:
|
||
|
||
- Create `backend/tests/test_system_config_raw_lot_prefix.py`.
|
||
- Modify `backend/tests/test_inventory_lot_numbering.py`.
|
||
- Modify `backend/tests/test_customer_supplied_inbound_lot_no.py`.
|
||
- Modify `backend/tests/test_opening_inventory_import.py`.
|
||
- Modify `backend/tests/test_selected_stock_lot_production_issue.py`.
|
||
- Create `backend/tests/test_production_work_order_inbound.py`.
|
||
|
||
ERP frontend files:
|
||
|
||
- Modify `frontend/src/components/StockLotTagSelect.vue`: add `maxSelections` support for single-select production outbound.
|
||
- Modify `frontend/src/views/SystemExtensionView.vue`: add raw lot prefix config card.
|
||
- Modify `frontend/src/views/InventoryLedgerView.vue`: production outbound single-select UI and new “生产工单入库” entry/drawer.
|
||
- Create `frontend/scripts/test-production-work-order-inbound-ui.mjs`.
|
||
- Modify `frontend/scripts/test-special-adjustment-ui.mjs` only if shared warehouse action ordering assertions need updating.
|
||
|
||
Miniapp files:
|
||
|
||
- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reports.py`: return material stock lot numbers instead of `JH0001` work-order short codes.
|
||
- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_work_order_batch_options.py`.
|
||
- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.wxml`.
|
||
- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.js`.
|
||
- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/records/records.wxml`.
|
||
- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard/dashboard.wxml`.
|
||
- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review/review.wxml`.
|
||
- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js`.
|
||
|
||
---
|
||
|
||
### Task 1: Add System Config Storage For Raw Lot Prefix
|
||
|
||
**Files:**
|
||
|
||
- Create: `backend/sql/add_raw_lot_work_order_inbound_patch.sql`
|
||
- Create: `backend/app/services/system_config.py`
|
||
- Modify: `backend/app/models/org.py`
|
||
- Modify: `backend/app/schemas/database.py`
|
||
- Modify: `backend/app/api/routes/system_extension.py`
|
||
- Test: `backend/tests/test_system_config_raw_lot_prefix.py`
|
||
|
||
- [ ] **Step 1: Write failing backend tests for config default, normalization, and update**
|
||
|
||
Add `backend/tests/test_system_config_raw_lot_prefix.py`:
|
||
|
||
```python
|
||
from __future__ import annotations
|
||
|
||
import unittest
|
||
from datetime import datetime
|
||
|
||
from sqlalchemy import BigInteger, create_engine, select
|
||
from sqlalchemy.ext.compiler import compiles
|
||
from sqlalchemy.orm import Session, sessionmaker
|
||
|
||
|
||
@compiles(BigInteger, "sqlite")
|
||
def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str:
|
||
_ = type_, compiler, kw
|
||
return "INTEGER"
|
||
|
||
|
||
import app.models.master_data # noqa: E402,F401
|
||
import app.models.miniapp # noqa: E402,F401
|
||
import app.models.operations # noqa: E402,F401
|
||
import app.models.org # noqa: E402,F401
|
||
import app.models.planning # noqa: E402,F401
|
||
import app.models.sales # noqa: E402,F401
|
||
from app.models.base import Base # noqa: E402
|
||
from app.models.org import SystemConfig # noqa: E402
|
||
from app.services.system_config import ( # noqa: E402
|
||
RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE,
|
||
get_raw_material_lot_prefix,
|
||
normalize_raw_material_lot_prefix,
|
||
upsert_system_config,
|
||
)
|
||
|
||
|
||
class RawLotPrefixSystemConfigTest(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_default_prefix_is_yl_when_config_missing(self) -> None:
|
||
self.assertEqual(get_raw_material_lot_prefix(self.db), "YL")
|
||
|
||
def test_prefix_normalization_keeps_only_uppercase_letters_and_numbers(self) -> None:
|
||
self.assertEqual(normalize_raw_material_lot_prefix(" yl-01 "), "YL01")
|
||
self.assertEqual(normalize_raw_material_lot_prefix(""), "YL")
|
||
self.assertEqual(normalize_raw_material_lot_prefix("材料"), "YL")
|
||
|
||
def test_upsert_raw_lot_prefix_config(self) -> None:
|
||
now = datetime.now()
|
||
upsert_system_config(
|
||
self.db,
|
||
config_code=RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE,
|
||
config_name="原材料库存批次号前缀",
|
||
config_value="rm",
|
||
remark="测试配置",
|
||
updated_by=1,
|
||
now=now,
|
||
)
|
||
self.db.commit()
|
||
|
||
row = self.db.scalar(select(SystemConfig).where(SystemConfig.config_code == RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE))
|
||
self.assertIsNotNone(row)
|
||
self.assertEqual(row.config_value, "RM")
|
||
self.assertEqual(get_raw_material_lot_prefix(self.db), "RM")
|
||
```
|
||
|
||
- [ ] **Step 2: Run failing config tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
|
||
.venv/bin/python -m pytest tests/test_system_config_raw_lot_prefix.py -q
|
||
```
|
||
|
||
Expected: FAIL because `SystemConfig` and `app.services.system_config` do not exist.
|
||
|
||
- [ ] **Step 3: Add SQL patch**
|
||
|
||
Create `backend/sql/add_raw_lot_work_order_inbound_patch.sql`:
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS sys_system_config (
|
||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||
config_code VARCHAR(100) NOT NULL,
|
||
config_name VARCHAR(100) NOT NULL,
|
||
config_value VARCHAR(500) NOT NULL,
|
||
remark VARCHAR(500) NULL,
|
||
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
|
||
created_by BIGINT NULL,
|
||
updated_by BIGINT NULL,
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
UNIQUE KEY uk_sys_system_config_code (config_code),
|
||
KEY idx_sys_system_config_status (status)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||
|
||
INSERT INTO sys_system_config (config_code, config_name, config_value, remark, status)
|
||
SELECT 'RAW_MATERIAL_LOT_PREFIX', '原材料库存批次号前缀', 'YL', '原材料库存批次号默认前缀,例如 YL0001', 'ACTIVE'
|
||
WHERE NOT EXISTS (
|
||
SELECT 1 FROM sys_system_config WHERE config_code = 'RAW_MATERIAL_LOT_PREFIX'
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 4: Add ORM model**
|
||
|
||
Modify `backend/app/models/org.py` after `AiAssistantConfig`:
|
||
|
||
```python
|
||
class SystemConfig(Base):
|
||
__tablename__ = "sys_system_config"
|
||
|
||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||
config_code: Mapped[str] = mapped_column(String(100))
|
||
config_name: Mapped[str] = mapped_column(String(100))
|
||
config_value: Mapped[str] = mapped_column(String(500))
|
||
remark: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||
status: Mapped[str] = mapped_column(String(32), server_default=text("'ACTIVE'"))
|
||
created_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||
updated_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||
created_at: Mapped[object] = mapped_column(DateTime)
|
||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||
```
|
||
|
||
- [ ] **Step 5: Add config service**
|
||
|
||
Create `backend/app/services/system_config.py`:
|
||
|
||
```python
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from datetime import datetime
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.models.org import SystemConfig
|
||
|
||
RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE = "RAW_MATERIAL_LOT_PREFIX"
|
||
RAW_MATERIAL_LOT_PREFIX_DEFAULT = "YL"
|
||
|
||
|
||
def normalize_raw_material_lot_prefix(value: object | None) -> str:
|
||
normalized = "".join(re.findall(r"[A-Za-z0-9]", str(value or "").upper()))
|
||
return normalized or RAW_MATERIAL_LOT_PREFIX_DEFAULT
|
||
|
||
|
||
def get_system_config_value(db: Session, config_code: str, default: str = "") -> str:
|
||
row = db.scalar(
|
||
select(SystemConfig)
|
||
.where(SystemConfig.config_code == config_code, SystemConfig.status == "ACTIVE")
|
||
.limit(1)
|
||
)
|
||
return str(row.config_value if row else default)
|
||
|
||
|
||
def get_raw_material_lot_prefix(db: Session) -> str:
|
||
return normalize_raw_material_lot_prefix(
|
||
get_system_config_value(db, RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE, RAW_MATERIAL_LOT_PREFIX_DEFAULT)
|
||
)
|
||
|
||
|
||
def upsert_system_config(
|
||
db: Session,
|
||
*,
|
||
config_code: str,
|
||
config_name: str,
|
||
config_value: object,
|
||
remark: str | None = None,
|
||
updated_by: int | None = None,
|
||
now: datetime | None = None,
|
||
) -> SystemConfig:
|
||
current_time = now or datetime.now()
|
||
normalized_value = (
|
||
normalize_raw_material_lot_prefix(config_value)
|
||
if config_code == RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE
|
||
else str(config_value or "").strip()
|
||
)
|
||
row = db.scalar(select(SystemConfig).where(SystemConfig.config_code == config_code).limit(1))
|
||
if not row:
|
||
row = SystemConfig(
|
||
config_code=config_code,
|
||
config_name=config_name,
|
||
config_value=normalized_value,
|
||
remark=remark,
|
||
status="ACTIVE",
|
||
created_by=updated_by,
|
||
updated_by=updated_by,
|
||
created_at=current_time,
|
||
updated_at=current_time,
|
||
)
|
||
else:
|
||
row.config_name = config_name
|
||
row.config_value = normalized_value
|
||
row.remark = remark
|
||
row.status = "ACTIVE"
|
||
row.updated_by = updated_by
|
||
row.updated_at = current_time
|
||
db.add(row)
|
||
db.flush()
|
||
return row
|
||
```
|
||
|
||
- [ ] **Step 6: Add schemas and API endpoints**
|
||
|
||
Modify `backend/app/schemas/database.py`:
|
||
|
||
```python
|
||
class SystemConfigUpdate(BaseModel):
|
||
config_value: str = Field(min_length=1, max_length=500)
|
||
remark: str | None = None
|
||
|
||
|
||
class SystemConfigRead(BaseModel):
|
||
config_code: str
|
||
config_name: str
|
||
config_value: str
|
||
remark: str | None = None
|
||
updated_at: datetime | None = None
|
||
```
|
||
|
||
Modify `backend/app/api/routes/system_extension.py` imports:
|
||
|
||
```python
|
||
from app.models.org import AiAssistantConfig, SystemConfig
|
||
from app.schemas.database import SystemConfigRead, SystemConfigUpdate
|
||
from app.services.system_config import RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE, get_raw_material_lot_prefix, upsert_system_config
|
||
```
|
||
|
||
Add endpoints near assistant config endpoints:
|
||
|
||
```python
|
||
def _system_config_read(row: SystemConfig | None, *, config_code: str, config_name: str, default_value: str) -> SystemConfigRead:
|
||
return SystemConfigRead(
|
||
config_code=config_code,
|
||
config_name=row.config_name if row else config_name,
|
||
config_value=row.config_value if row else default_value,
|
||
remark=row.remark if row else None,
|
||
updated_at=row.updated_at if row else None,
|
||
)
|
||
|
||
|
||
@router.get("/raw-material-lot-prefix", response_model=SystemConfigRead)
|
||
def get_raw_material_lot_prefix_config(
|
||
context: AuthContext = Depends(require_system_admin),
|
||
db: Session = Depends(get_db),
|
||
) -> SystemConfigRead:
|
||
_ = context
|
||
row = db.scalar(select(SystemConfig).where(SystemConfig.config_code == RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE).limit(1))
|
||
return _system_config_read(
|
||
row,
|
||
config_code=RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE,
|
||
config_name="原材料库存批次号前缀",
|
||
default_value=get_raw_material_lot_prefix(db),
|
||
)
|
||
|
||
|
||
@router.put("/raw-material-lot-prefix", response_model=SystemConfigRead)
|
||
def save_raw_material_lot_prefix_config(
|
||
payload: SystemConfigUpdate,
|
||
context: AuthContext = Depends(require_system_admin),
|
||
db: Session = Depends(get_db),
|
||
) -> SystemConfigRead:
|
||
row = upsert_system_config(
|
||
db,
|
||
config_code=RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE,
|
||
config_name="原材料库存批次号前缀",
|
||
config_value=payload.config_value,
|
||
remark=payload.remark or "原材料库存批次号默认前缀,例如 YL0001",
|
||
updated_by=context.user.id,
|
||
)
|
||
db.commit()
|
||
db.refresh(row)
|
||
return _system_config_read(
|
||
row,
|
||
config_code=RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE,
|
||
config_name="原材料库存批次号前缀",
|
||
default_value=get_raw_material_lot_prefix(db),
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 7: Run config tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
|
||
.venv/bin/python -m pytest tests/test_system_config_raw_lot_prefix.py -q
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
---
|
||
|
||
### Task 2: Change Raw Material Lot Numbering To Global Prefix Sequence
|
||
|
||
**Files:**
|
||
|
||
- Modify: `backend/app/services/operations.py`
|
||
- Modify: `backend/tests/test_inventory_lot_numbering.py`
|
||
|
||
- [ ] **Step 1: Replace old numbering tests with new global sequence tests**
|
||
|
||
Modify `backend/tests/test_inventory_lot_numbering.py` to assert:
|
||
|
||
```python
|
||
def test_inventory_lot_no_starts_from_global_raw_sequence(self) -> None:
|
||
self.assertEqual(build_inventory_lot_no(self.db, self.material.id), "YL0001")
|
||
|
||
|
||
def test_inventory_lot_no_increments_across_materials(self) -> None:
|
||
other_material = Item(id=2, item_code="原材料00002", item_name="冷轧板", item_type="RAW_MATERIAL", unit_weight_kg=Decimal("1"), status="ACTIVE")
|
||
self.db.add(other_material)
|
||
self.db.add(
|
||
StockLot(
|
||
lot_no="YL0001",
|
||
lot_role="INBOUND_RAW",
|
||
item_id=other_material.id,
|
||
warehouse_id=self.warehouse.id,
|
||
source_doc_type="TEST",
|
||
source_doc_id=1,
|
||
inbound_qty=Decimal("0"),
|
||
inbound_weight_kg=Decimal("100"),
|
||
remaining_qty=Decimal("0"),
|
||
remaining_weight_kg=Decimal("100"),
|
||
locked_qty=Decimal("0"),
|
||
locked_weight_kg=Decimal("0"),
|
||
unit_cost=Decimal("5"),
|
||
quality_status="PASS",
|
||
status="AVAILABLE",
|
||
)
|
||
)
|
||
self.db.commit()
|
||
|
||
self.assertEqual(build_inventory_lot_no(self.db, self.material.id), "YL0002")
|
||
|
||
|
||
def test_inventory_lot_no_expands_after_9999(self) -> None:
|
||
self.db.add(
|
||
StockLot(
|
||
lot_no="YL9999",
|
||
lot_role="INBOUND_RAW",
|
||
item_id=self.material.id,
|
||
warehouse_id=self.warehouse.id,
|
||
source_doc_type="TEST",
|
||
source_doc_id=1,
|
||
inbound_qty=Decimal("0"),
|
||
inbound_weight_kg=Decimal("100"),
|
||
remaining_qty=Decimal("0"),
|
||
remaining_weight_kg=Decimal("100"),
|
||
locked_qty=Decimal("0"),
|
||
locked_weight_kg=Decimal("0"),
|
||
unit_cost=Decimal("5"),
|
||
quality_status="PASS",
|
||
status="AVAILABLE",
|
||
)
|
||
)
|
||
self.db.commit()
|
||
|
||
self.assertEqual(build_inventory_lot_no(self.db, self.material.id), "YL10000")
|
||
```
|
||
|
||
- [ ] **Step 2: Run numbering tests to verify failure**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
|
||
.venv/bin/python -m pytest tests/test_inventory_lot_numbering.py -q
|
||
```
|
||
|
||
Expected: FAIL because `build_inventory_lot_no` still returns `JH_物料名称编码_0001`.
|
||
|
||
- [ ] **Step 3: Implement global raw lot number helper**
|
||
|
||
Modify `backend/app/services/operations.py` imports:
|
||
|
||
```python
|
||
from app.services.system_config import get_raw_material_lot_prefix
|
||
```
|
||
|
||
Replace `_build_inventory_lot_prefix` and `build_inventory_lot_no` with:
|
||
|
||
```python
|
||
def _raw_lot_sequence_width(next_sequence: int) -> int:
|
||
return 4 if next_sequence <= 9999 else len(str(next_sequence))
|
||
|
||
|
||
def build_inventory_lot_no(db: Session, material_item_id: int) -> str:
|
||
_ = material_item_id
|
||
prefix = get_raw_material_lot_prefix(db)
|
||
existing_lot_nos = db.scalars(select(StockLot.lot_no).where(StockLot.lot_no.like(f"{prefix}%"))).all()
|
||
max_sequence = 0
|
||
for lot_no in existing_lot_nos:
|
||
match = re.fullmatch(rf"{re.escape(prefix)}(\d+)", str(lot_no or ""))
|
||
if match:
|
||
max_sequence = max(max_sequence, int(match.group(1)))
|
||
sequence = max_sequence + 1
|
||
candidate = f"{prefix}{sequence:0{_raw_lot_sequence_width(sequence)}d}"
|
||
while db.scalar(select(func.count(StockLot.id)).where(StockLot.lot_no == candidate)):
|
||
sequence += 1
|
||
candidate = f"{prefix}{sequence:0{_raw_lot_sequence_width(sequence)}d}"
|
||
return candidate
|
||
```
|
||
|
||
- [ ] **Step 4: Run numbering tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
|
||
.venv/bin/python -m pytest tests/test_inventory_lot_numbering.py tests/test_system_config_raw_lot_prefix.py -q
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
---
|
||
|
||
### Task 3: Apply New Lot Numbering To Raw Inbound Paths
|
||
|
||
**Files:**
|
||
|
||
- Modify: `backend/app/api/routes/inventory.py`
|
||
- Modify: `backend/app/api/routes/purchase.py`
|
||
- Modify: `backend/app/services/special_inventory_adjustment.py`
|
||
- Modify: `backend/tests/test_customer_supplied_inbound_lot_no.py`
|
||
- Modify: `backend/tests/test_opening_inventory_import.py`
|
||
- Modify: `backend/tests/test_special_warehouse_adjustment.py`
|
||
|
||
- [ ] **Step 1: Write or update tests for raw inbound paths**
|
||
|
||
Update assertions so raw inbound paths generate `YL0001`, `YL0002` and never hand-entered material-name batch numbers.
|
||
|
||
For `backend/tests/test_customer_supplied_inbound_lot_no.py`, assert:
|
||
|
||
```python
|
||
self.assertEqual(created_lot.lot_no, "YL0001")
|
||
self.assertEqual(second_created_lot.lot_no, "YL0002")
|
||
```
|
||
|
||
For `backend/tests/test_opening_inventory_import.py`, assert imported raw opening lots use:
|
||
|
||
```python
|
||
self.assertEqual([lot.lot_no for lot in raw_lots], ["YL0001", "YL0002"])
|
||
```
|
||
|
||
For `backend/tests/test_special_warehouse_adjustment.py`, assert special raw inbound uses:
|
||
|
||
```python
|
||
self.assertRegex(raw_lot.lot_no, r"^YL\d{4,}$")
|
||
```
|
||
|
||
- [ ] **Step 2: Run inbound tests to verify failures**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
|
||
.venv/bin/python -m pytest \
|
||
tests/test_customer_supplied_inbound_lot_no.py \
|
||
tests/test_opening_inventory_import.py \
|
||
tests/test_special_warehouse_adjustment.py \
|
||
-q
|
||
```
|
||
|
||
Expected: FAIL where old raw-specific lot builders still produce `KLRK` or material-name lot numbers.
|
||
|
||
- [ ] **Step 3: Route raw warehouse generic inbound through `build_inventory_lot_no`**
|
||
|
||
Modify `backend/app/api/routes/inventory.py` in the `# build lot_no` block:
|
||
|
||
```python
|
||
if not lot_no:
|
||
if warehouse_type == "RAW" and biz_type in {"OPENING", "CUSTOMER_SUPPLIED"}:
|
||
lot_no = build_inventory_lot_no(db, item.id)
|
||
elif biz_type in ("OPENING",):
|
||
lot_no = _build_opening_lot_no(db, item, warehouse)
|
||
elif biz_type in ("PRODUCTION_SURPLUS", "OUTSOURCING_SURPLUS"):
|
||
tag = "SCSY" if biz_type == "PRODUCTION_SURPLUS" else "WWSY"
|
||
lot_no = _build_surplus_lot_no(db, item, warehouse, tag)
|
||
elif biz_type in ("PRODUCTION_SCRAP_IN", "OUTSOURCING_SCRAP_IN", "REWORK_SCRAP_IN"):
|
||
tag = "SCFL" if biz_type == "PRODUCTION_SCRAP_IN" else "FWFL" if biz_type == "REWORK_SCRAP_IN" else "WWFL"
|
||
lot_no = _build_surplus_lot_no(db, item, warehouse, tag)
|
||
elif biz_type == "WIP_IN":
|
||
lot_no = _build_wip_lot_no(db, item, warehouse)
|
||
elif biz_type == "PRODUCTION_FINISHED":
|
||
lot_no = _build_finished_lot_no(db, item, warehouse)
|
||
elif biz_type == "OUTSOURCING_IN":
|
||
lot_no = _build_surplus_lot_no(db, item, warehouse, "WWJK")
|
||
elif biz_type == "CUSTOMER_SUPPLIED":
|
||
lot_no = _build_customer_supplied_lot_no(db, item)
|
||
else:
|
||
lot_no = _build_opening_lot_no(db, item, warehouse)
|
||
```
|
||
|
||
Confirm `backend/app/api/routes/purchase.py` and `backend/app/services/special_inventory_adjustment.py` call `build_inventory_lot_no(db, item_id)` for raw material lots. If either file has a custom raw lot builder, replace that call with `build_inventory_lot_no`.
|
||
|
||
- [ ] **Step 4: Run inbound tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
|
||
.venv/bin/python -m pytest \
|
||
tests/test_customer_supplied_inbound_lot_no.py \
|
||
tests/test_opening_inventory_import.py \
|
||
tests/test_special_warehouse_adjustment.py \
|
||
tests/test_inventory_lot_numbering.py \
|
||
-q
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
---
|
||
|
||
### Task 4: Enforce Single-Lot Production Outbound And Merge Daily Work Orders
|
||
|
||
**Files:**
|
||
|
||
- Modify: `backend/app/services/operations.py`
|
||
- Modify: `backend/app/api/routes/production.py`
|
||
- Modify: `backend/tests/test_selected_stock_lot_production_issue.py`
|
||
|
||
- [ ] **Step 1: Update production issue tests for new rules**
|
||
|
||
In `backend/tests/test_selected_stock_lot_production_issue.py`, change lot numbers in seed data to `YL0001` and `YL0002`.
|
||
|
||
Replace old yearly sequence tests with:
|
||
|
||
```python
|
||
def test_create_work_order_no_uses_date_lot_and_product_name(self) -> None:
|
||
class FrozenDateTime(datetime):
|
||
@classmethod
|
||
def now(cls, tz=None): # noqa: ANN001
|
||
_ = tz
|
||
return cls(2026, 6, 7, 9, 15, 0)
|
||
|
||
with patch.object(production_routes, "datetime", FrozenDateTime):
|
||
result = create_work_order(
|
||
WorkOrderCreate(
|
||
product_item_id=self.product.id,
|
||
selected_stock_lots=[
|
||
SelectedStockLotIssueCreate(
|
||
source_lot_id=self.lot1.id,
|
||
material_item_id=self.material.id,
|
||
issued_weight_kg=Decimal("10"),
|
||
)
|
||
],
|
||
),
|
||
context=SimpleNamespace(user=SimpleNamespace(id=1)),
|
||
db=self.db,
|
||
)
|
||
|
||
self.assertEqual(result.work_order_no, f"20260607-{self.lot1.lot_no}-{self.product.item_name}")
|
||
```
|
||
|
||
Add merge test:
|
||
|
||
```python
|
||
def test_same_day_same_lot_same_product_merges_into_existing_work_order(self) -> None:
|
||
class FrozenDateTime(datetime):
|
||
@classmethod
|
||
def now(cls, tz=None): # noqa: ANN001
|
||
_ = tz
|
||
return cls(2026, 6, 7, 9, 15, 0)
|
||
|
||
payload = WorkOrderCreate(
|
||
product_item_id=self.product.id,
|
||
selected_stock_lots=[
|
||
SelectedStockLotIssueCreate(source_lot_id=self.lot1.id, material_item_id=self.material.id, issued_weight_kg=Decimal("10"))
|
||
],
|
||
)
|
||
with patch.object(production_routes, "datetime", FrozenDateTime):
|
||
first = create_work_order(payload, context=SimpleNamespace(user=SimpleNamespace(id=1)), db=self.db)
|
||
second = create_work_order(
|
||
WorkOrderCreate(
|
||
product_item_id=self.product.id,
|
||
selected_stock_lots=[
|
||
SelectedStockLotIssueCreate(source_lot_id=self.lot1.id, material_item_id=self.material.id, issued_weight_kg=Decimal("8"))
|
||
],
|
||
),
|
||
context=SimpleNamespace(user=SimpleNamespace(id=1)),
|
||
db=self.db,
|
||
)
|
||
|
||
self.assertEqual(second.work_order_id, first.work_order_id)
|
||
issues = self.db.scalars(select(WorkOrderMaterialIssue).where(WorkOrderMaterialIssue.work_order_id == first.work_order_id)).all()
|
||
self.assertEqual(len(issues), 2)
|
||
material_row = self.db.scalar(select(WorkOrderMaterial).where(WorkOrderMaterial.work_order_id == first.work_order_id))
|
||
self.assertEqual(float(material_row.issued_weight_kg), 18)
|
||
```
|
||
|
||
Add rejection test:
|
||
|
||
```python
|
||
def test_create_work_order_rejects_multiple_stock_lots(self) -> None:
|
||
with self.assertRaises(HTTPException) as exc:
|
||
create_work_order(
|
||
WorkOrderCreate(
|
||
product_item_id=self.product.id,
|
||
selected_stock_lots=[
|
||
SelectedStockLotIssueCreate(source_lot_id=self.lot1.id, material_item_id=self.material.id, issued_weight_kg=Decimal("10")),
|
||
SelectedStockLotIssueCreate(source_lot_id=self.lot2.id, material_item_id=self.material.id, issued_weight_kg=Decimal("5")),
|
||
],
|
||
),
|
||
context=SimpleNamespace(user=SimpleNamespace(id=1)),
|
||
db=self.db,
|
||
)
|
||
|
||
self.assertEqual(exc.exception.status_code, 400)
|
||
self.assertIn("一次生产出库只能选择一个材料库存批次号", exc.exception.detail)
|
||
```
|
||
|
||
Replace the old “requires lots for all BOM materials” expectation with a test that only the selected material is issued and other BOM rows remain unissued.
|
||
|
||
- [ ] **Step 2: Run focused production issue tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
|
||
.venv/bin/python -m pytest tests/test_selected_stock_lot_production_issue.py -q
|
||
```
|
||
|
||
Expected: FAIL on old multi-lot and old work-order number behavior.
|
||
|
||
- [ ] **Step 3: Add deterministic production work-order number helper**
|
||
|
||
Modify `backend/app/services/operations.py`:
|
||
|
||
```python
|
||
def build_raw_lot_product_work_order_no(*, biz_time: datetime, lot_no: str, product: Item) -> str:
|
||
date_part = biz_time.strftime("%Y%m%d")
|
||
product_name = " ".join(str(product.item_name or product.item_code or f"产品{product.id}").split()).strip("- ") or f"产品{product.id}"
|
||
prefix = f"{date_part}-{lot_no}-"
|
||
max_product_name_length = max(80 - len(prefix), 1)
|
||
return f"{prefix}{product_name[:max_product_name_length]}"
|
||
```
|
||
|
||
- [ ] **Step 4: Refactor `create_work_order` to validate one selected lot**
|
||
|
||
In `backend/app/api/routes/production.py`, after building `selected_lots`, add:
|
||
|
||
```python
|
||
if len(selected_lots) != 1:
|
||
raise HTTPException(status_code=400, detail="一次生产出库只能选择一个材料库存批次号")
|
||
```
|
||
|
||
Remove the requirement that every BOM material must have a selected lot. Replace it with:
|
||
|
||
```python
|
||
selected_lot = selected_lot_by_id[int(selected_lots[0]["source_lot_id"])]
|
||
selected_material_item_id = int(selected_lots[0]["material_item_id"] or selected_lot.item_id)
|
||
selected_bom_item = next((item for item in bom_items if int(item.material_item_id) == selected_material_item_id), None)
|
||
if not selected_bom_item:
|
||
raise HTTPException(status_code=400, detail=f"库存批次 {selected_lot.lot_no} 不属于该产品需规默认用料")
|
||
```
|
||
|
||
- [ ] **Step 5: Implement same-day same-lot same-product merge**
|
||
|
||
In `backend/app/api/routes/production.py`, before creating a new `WorkOrder`, compute:
|
||
|
||
```python
|
||
now = datetime.now()
|
||
work_order_no = build_raw_lot_product_work_order_no(biz_time=now, lot_no=selected_lot.lot_no, product=product)
|
||
existing_work_order = db.scalar(select(WorkOrder).where(WorkOrder.work_order_no == work_order_no).limit(1))
|
||
if existing_work_order and str(existing_work_order.status or "").upper() == "SETTLED":
|
||
raise HTTPException(status_code=400, detail="该库存批次今日对应产品的生产工单已结单,不能继续合并生产出库")
|
||
```
|
||
|
||
If `existing_work_order` is not settled, use it instead of adding a new one:
|
||
|
||
```python
|
||
if existing_work_order:
|
||
work_order = existing_work_order
|
||
else:
|
||
work_order = WorkOrder(
|
||
work_order_no=work_order_no,
|
||
work_order_type="NORMAL",
|
||
source_sales_order_item_id=payload.source_sales_order_item_id,
|
||
product_item_id=payload.product_item_id,
|
||
route_id=route.id,
|
||
bom_id=bom.id,
|
||
planned_qty=planned_qty,
|
||
released_qty=planned_qty,
|
||
finished_qty=to_decimal(0),
|
||
scrap_qty=to_decimal(0),
|
||
planned_start_time=payload.planned_start_time or now,
|
||
planned_end_time=payload.planned_end_time,
|
||
actual_start_time=None,
|
||
actual_end_time=None,
|
||
priority_level=payload.priority_level or 3,
|
||
status="RELEASED",
|
||
remark=payload.remark,
|
||
)
|
||
db.add(work_order)
|
||
db.flush()
|
||
create_work_order_materials_and_operations(
|
||
db,
|
||
work_order=work_order,
|
||
bom=bom,
|
||
route=route,
|
||
auto_issue_materials=False,
|
||
operator_user_id=context.user.id,
|
||
issue_weight_override=None,
|
||
)
|
||
```
|
||
|
||
When applying the selected lot issue, update the selected material row cumulatively:
|
||
|
||
```python
|
||
material_row.issued_qty = Decimal("0")
|
||
material_row.issued_weight_kg = to_decimal(material_row.issued_weight_kg) + selected_lots[0]["issued_weight_kg"]
|
||
material_row.required_weight_kg = material_row.issued_weight_kg
|
||
material_row.status = "ISSUED"
|
||
db.add(material_row)
|
||
sync_work_order_reference_qty(db, work_order.id)
|
||
```
|
||
|
||
Ensure `build_raw_lot_product_work_order_no` is imported from `app.services.operations`.
|
||
|
||
- [ ] **Step 6: Run production issue tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
|
||
.venv/bin/python -m pytest tests/test_selected_stock_lot_production_issue.py tests/test_production_work_order_ledger.py -q
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
---
|
||
|
||
### Task 5: Match Miniapp Reports By Material Stock Lot Number
|
||
|
||
**Files:**
|
||
|
||
- Modify: `backend/app/api/routes/production.py`
|
||
- Modify: `backend/tests/test_selected_stock_lot_production_issue.py`
|
||
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reports.py`
|
||
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_work_order_batch_options.py`
|
||
|
||
- [ ] **Step 1: Update ERP miniapp resolver tests**
|
||
|
||
Replace `JH0001` tests with `YL0001` tests:
|
||
|
||
```python
|
||
def test_miniapp_material_lot_no_resolves_unsettled_work_order(self) -> None:
|
||
work_order = self._add_issued_work_order(
|
||
work_order_id=1,
|
||
work_order_no=f"20260607-{self.lot1.lot_no}-{self.product.item_name}",
|
||
product=self.product,
|
||
issue_id=10,
|
||
material_row_id=10,
|
||
)
|
||
self._add_work_order_operation(operation_id=101, work_order_id=work_order.id, operation_name="1序")
|
||
self.db.commit()
|
||
|
||
matched = _find_work_order_for_miniapp_item(
|
||
self.db,
|
||
{
|
||
"raw_material_batch_no": self.lot1.lot_no,
|
||
"product_name": self.product.item_name,
|
||
"project_no": self.product.item_code,
|
||
},
|
||
)
|
||
|
||
self.assertEqual(matched.id, work_order.id)
|
||
```
|
||
|
||
Add settled ignore test:
|
||
|
||
```python
|
||
def test_miniapp_material_lot_no_ignores_settled_work_order(self) -> None:
|
||
self._add_issued_work_order(
|
||
work_order_id=1,
|
||
work_order_no=f"20260607-{self.lot1.lot_no}-{self.product.item_name}",
|
||
product=self.product,
|
||
issue_id=10,
|
||
material_row_id=10,
|
||
).status = "SETTLED"
|
||
self.db.commit()
|
||
|
||
matched = _find_work_order_for_miniapp_item(
|
||
self.db,
|
||
{
|
||
"raw_material_batch_no": self.lot1.lot_no,
|
||
"product_name": self.product.item_name,
|
||
"project_no": self.product.item_code,
|
||
},
|
||
)
|
||
|
||
self.assertIsNone(matched)
|
||
```
|
||
|
||
Keep one compatibility test for old `JH0001` short code if existing historical data still needs syncing.
|
||
|
||
- [ ] **Step 2: Run ERP miniapp resolver tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
|
||
.venv/bin/python -m pytest tests/test_selected_stock_lot_production_issue.py -q
|
||
```
|
||
|
||
Expected: FAIL because resolver still prioritizes `JH0001` short codes and does not filter settled work orders in lot-based candidate selection.
|
||
|
||
- [ ] **Step 3: Update ERP resolver**
|
||
|
||
Modify `_select_miniapp_issue_candidate` in `backend/app/api/routes/production.py` to receive only unsettled work orders:
|
||
|
||
```python
|
||
product_rows = db.execute(
|
||
select(WorkOrder.id, Item.item_name, Item.item_code)
|
||
.join(Item, Item.id == WorkOrder.product_item_id)
|
||
.where(
|
||
WorkOrder.id.in_(latest_by_work_order),
|
||
func.upper(func.coalesce(WorkOrder.status, "")) != "SETTLED",
|
||
)
|
||
).all()
|
||
```
|
||
|
||
Modify `_resolve_work_order_issue_by_inventory_lot_no` to join `WorkOrder` and ignore settled:
|
||
|
||
```python
|
||
candidates = db.scalars(
|
||
select(WorkOrderMaterialIssue)
|
||
.join(StockLot, StockLot.id == WorkOrderMaterialIssue.source_lot_id)
|
||
.join(WorkOrder, WorkOrder.id == WorkOrderMaterialIssue.work_order_id)
|
||
.where(
|
||
StockLot.lot_no == lot_no,
|
||
func.upper(func.coalesce(WorkOrder.status, "")) != "SETTLED",
|
||
)
|
||
.order_by(WorkOrderMaterialIssue.id.desc())
|
||
).all()
|
||
```
|
||
|
||
Modify `_find_work_order_for_miniapp_item` to resolve stock lot numbers before `JH0001` compatibility:
|
||
|
||
```python
|
||
issue, has_issue_candidates = _resolve_work_order_issue_by_inventory_lot_no(db, batch_no, row)
|
||
work_order = db.get(WorkOrder, issue.work_order_id) if issue else None
|
||
if work_order:
|
||
if cache is not None:
|
||
cache["work_order"][cache_key] = work_order
|
||
return work_order
|
||
|
||
if _is_miniapp_work_order_short_code(batch_no):
|
||
work_order = _resolve_work_order_by_miniapp_short_code(db, batch_no, row)
|
||
if cache is not None:
|
||
cache["work_order"][cache_key] = work_order
|
||
return work_order
|
||
```
|
||
|
||
Keep the direct work-order-number fallback only when `has_issue_candidates` is false.
|
||
|
||
- [ ] **Step 4: Update miniapp backend option query**
|
||
|
||
Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reports.py`.
|
||
|
||
Replace `_miniapp_work_order_short_code` usage in `_raw_material_batch_options` with a query that returns source stock lot numbers:
|
||
|
||
```python
|
||
rows = db.execute(
|
||
text(
|
||
"""
|
||
select distinct sl.lot_no
|
||
from pp_work_order wo
|
||
join pp_work_order_operation op on op.work_order_id = wo.id
|
||
join md_item product on product.id = wo.product_item_id
|
||
join pp_work_order_material_issue issue on issue.work_order_id = wo.id
|
||
join wh_stock_lot sl on sl.id = issue.source_lot_id
|
||
where sl.lot_no is not null
|
||
and sl.lot_no <> ''
|
||
and upper(coalesce(wo.status, '')) <> 'SETTLED'
|
||
and (:product_name = '' or product.item_name = :product_name)
|
||
and (:project_no = '' or product.item_code = :project_no)
|
||
and (:process_name = '' or op.operation_name = :process_name)
|
||
order by sl.lot_no asc
|
||
"""
|
||
),
|
||
{
|
||
"product_name": product_name,
|
||
"project_no": project_no,
|
||
"process_name": process_name,
|
||
},
|
||
).all()
|
||
return [str(row[0]) for row in rows if row[0]]
|
||
```
|
||
|
||
If the miniapp backend connects to the ERP schema through a different SQLAlchemy engine name, use the existing `_erp_engine()` or session helper already used by the current `_raw_material_batch_options` function.
|
||
|
||
- [ ] **Step 5: Update miniapp backend tests**
|
||
|
||
Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_work_order_batch_options.py`:
|
||
|
||
```python
|
||
def test_raw_material_batch_options_returns_material_stock_lots_for_product_process(monkeypatch):
|
||
# seed pp_work_order, pp_work_order_operation, md_item, pp_work_order_material_issue, wh_stock_lot
|
||
options = reports._raw_material_batch_options(
|
||
product_name="钢制碗",
|
||
project_no="",
|
||
process_name="1序",
|
||
)
|
||
assert options == ["YL0001", "YL0002"]
|
||
```
|
||
|
||
Also update the settled test to assert settled work orders do not return their lot number.
|
||
|
||
- [ ] **Step 6: Run ERP and miniapp backend tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
|
||
.venv/bin/python -m pytest tests/test_selected_stock_lot_production_issue.py -q
|
||
|
||
cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
|
||
python -m pytest tests/test_work_order_batch_options.py -q
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
---
|
||
|
||
### Task 6: Add Production Work-Order Inbound Backend API
|
||
|
||
**Files:**
|
||
|
||
- Create: `backend/app/services/production_work_order_inbound.py`
|
||
- Modify: `backend/app/schemas/operations.py`
|
||
- Modify: `backend/app/api/routes/production.py`
|
||
- Test: `backend/tests/test_production_work_order_inbound.py`
|
||
|
||
- [ ] **Step 1: Write backend tests for preview, deviation, posting, and settle**
|
||
|
||
Create `backend/tests/test_production_work_order_inbound.py` with the same SQLite setup pattern as `test_production_work_order_ledger.py`.
|
||
|
||
Core tests:
|
||
|
||
```python
|
||
def test_preview_calculates_default_surplus_and_scrap_from_finished_qty(self) -> None:
|
||
preview = preview_production_work_order_inbound(self.db, work_order_id=1, finished_qty=Decimal("198"))
|
||
self.assertEqual(float(preview.theoretical_surplus_weight_kg), 81.2)
|
||
self.assertEqual(float(preview.theoretical_scrap_weight_kg), 23.8)
|
||
|
||
|
||
def test_submit_requires_deviation_remark_when_surplus_diff_exceeds_15_percent(self) -> None:
|
||
with self.assertRaises(HTTPException) as exc:
|
||
create_production_work_order_inbound(
|
||
self.db,
|
||
payload=ProductionWorkOrderInboundCreate(
|
||
work_order_id=1,
|
||
finished_warehouse_id=2,
|
||
scrap_warehouse_id=3,
|
||
finished_qty=198,
|
||
surplus_weight_kg=120,
|
||
scrap_weight_kg=23.8,
|
||
deviation_remark=None,
|
||
settle_work_order=False,
|
||
),
|
||
operator_user_id=1,
|
||
)
|
||
self.assertIn("偏差说明", exc.exception.detail)
|
||
|
||
|
||
def test_submit_posts_finished_surplus_and_scrap_in_one_transaction(self) -> None:
|
||
result = create_production_work_order_inbound(
|
||
self.db,
|
||
payload=ProductionWorkOrderInboundCreate(
|
||
work_order_id=1,
|
||
finished_warehouse_id=2,
|
||
scrap_warehouse_id=3,
|
||
finished_qty=198,
|
||
surplus_weight_kg=80,
|
||
scrap_weight_kg=24,
|
||
deviation_remark=None,
|
||
settle_work_order=False,
|
||
),
|
||
operator_user_id=1,
|
||
)
|
||
self.assertEqual(result.work_order_id, 1)
|
||
self.assertEqual(float(self.db.get(WorkOrder, 1).finished_qty), 198)
|
||
self.assertEqual(float(self.db.get(StockLot, 1).remaining_weight_kg), 380)
|
||
txn_types = [row.txn_type for row in self.db.scalars(select(InventoryTxn).order_by(InventoryTxn.id)).all()]
|
||
self.assertIn("FG_IN", txn_types)
|
||
self.assertIn("PRODUCTION_SURPLUS_IN", txn_types)
|
||
self.assertIn("PRODUCTION_SCRAP_IN", txn_types)
|
||
|
||
|
||
def test_submit_settles_work_order_only_when_requested(self) -> None:
|
||
create_production_work_order_inbound(
|
||
self.db,
|
||
payload=ProductionWorkOrderInboundCreate(
|
||
work_order_id=1,
|
||
finished_warehouse_id=2,
|
||
scrap_warehouse_id=3,
|
||
finished_qty=198,
|
||
surplus_weight_kg=80,
|
||
scrap_weight_kg=24,
|
||
settle_work_order=True,
|
||
settle_confirmed=True,
|
||
deviation_remark=None,
|
||
),
|
||
operator_user_id=1,
|
||
)
|
||
self.assertEqual(self.db.get(WorkOrder, 1).status, "SETTLED")
|
||
```
|
||
|
||
- [ ] **Step 2: Run production inbound tests to verify failure**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
|
||
.venv/bin/python -m pytest tests/test_production_work_order_inbound.py -q
|
||
```
|
||
|
||
Expected: FAIL because schemas and service do not exist.
|
||
|
||
- [ ] **Step 3: Add schemas**
|
||
|
||
Modify `backend/app/schemas/operations.py`:
|
||
|
||
```python
|
||
class ProductionWorkOrderInboundPreviewRead(BaseModel):
|
||
work_order_id: int
|
||
work_order_no: str
|
||
product_item_id: int
|
||
product_name: str
|
||
source_lot_id: int
|
||
source_lot_no: str
|
||
issued_weight_kg: float
|
||
gross_weight_per_piece_kg: float
|
||
net_weight_per_piece_kg: float
|
||
reported_good_qty: float
|
||
reported_scrap_qty: float
|
||
finished_received_qty: float
|
||
surplus_returned_weight_kg: float
|
||
scrap_inbound_weight_kg: float
|
||
theoretical_finished_qty: float
|
||
theoretical_surplus_weight_kg: float
|
||
theoretical_scrap_weight_kg: float
|
||
|
||
|
||
class ProductionWorkOrderInboundCreate(BaseModel):
|
||
work_order_id: int = Field(gt=0)
|
||
finished_warehouse_id: int = Field(gt=0)
|
||
scrap_warehouse_id: int = Field(gt=0)
|
||
finished_qty: float = 0
|
||
surplus_weight_kg: float = 0
|
||
scrap_weight_kg: float = 0
|
||
deviation_remark: str | None = None
|
||
remark: str | None = None
|
||
settle_work_order: bool = False
|
||
settle_confirmed: bool = False
|
||
|
||
|
||
class ProductionWorkOrderInboundRead(BaseModel):
|
||
work_order_id: int
|
||
work_order_no: str
|
||
finished_qty: float
|
||
surplus_weight_kg: float
|
||
scrap_weight_kg: float
|
||
deviation_remark: str | None = None
|
||
settled: bool
|
||
message: str
|
||
```
|
||
|
||
- [ ] **Step 4: Implement calculation helpers**
|
||
|
||
Create `backend/app/services/production_work_order_inbound.py`:
|
||
|
||
```python
|
||
from __future__ import annotations
|
||
|
||
from decimal import Decimal
|
||
|
||
from fastapi import HTTPException
|
||
from sqlalchemy import select
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.models.master_data import BomItem, Item, StockBalance, Warehouse
|
||
from app.models.operations import InventoryTxn, StockLot, WorkOrder, WorkOrderMaterial, WorkOrderMaterialIssue
|
||
from app.schemas.operations import ProductionWorkOrderInboundCreate, ProductionWorkOrderInboundPreviewRead, ProductionWorkOrderInboundRead
|
||
from app.services.operations import clamp_non_negative, create_inventory_txn, get_location_or_default, to_decimal, upsert_stock_balance
|
||
from app.services.production_work_order_ledger import get_work_order_limit_snapshot
|
||
|
||
DEVIATION_RATE = Decimal("0.15")
|
||
|
||
|
||
def _deviation_exceeds_limit(actual: Decimal, theoretical: Decimal) -> bool:
|
||
if theoretical <= 0:
|
||
return actual > 0
|
||
return abs(actual - theoretical) / theoretical > DEVIATION_RATE
|
||
|
||
|
||
def _source_issue(db: Session, work_order_id: int) -> WorkOrderMaterialIssue:
|
||
issue = db.scalar(
|
||
select(WorkOrderMaterialIssue)
|
||
.where(WorkOrderMaterialIssue.work_order_id == work_order_id)
|
||
.order_by(WorkOrderMaterialIssue.id.asc())
|
||
.limit(1)
|
||
)
|
||
if not issue:
|
||
raise HTTPException(status_code=400, detail="该工单没有材料库存批次领用记录,不能进行生产工单入库")
|
||
return issue
|
||
|
||
|
||
def preview_production_work_order_inbound(
|
||
db: Session,
|
||
*,
|
||
work_order_id: int,
|
||
finished_qty: Decimal | None = None,
|
||
) -> ProductionWorkOrderInboundPreviewRead:
|
||
work_order = db.get(WorkOrder, work_order_id)
|
||
if not work_order:
|
||
raise HTTPException(status_code=404, detail="生产工单不存在")
|
||
if str(work_order.status or "").upper() == "SETTLED":
|
||
raise HTTPException(status_code=400, detail="该生产工单已结单,不能继续入库")
|
||
product = db.get(Item, work_order.product_item_id)
|
||
issue = _source_issue(db, work_order_id)
|
||
source_lot = db.get(StockLot, issue.source_lot_id)
|
||
snapshot = get_work_order_limit_snapshot(db, work_order_id)
|
||
bom_item = db.scalar(
|
||
select(BomItem)
|
||
.join(WorkOrderMaterial, WorkOrderMaterial.bom_item_id == BomItem.id)
|
||
.where(WorkOrderMaterial.work_order_id == work_order_id, WorkOrderMaterial.material_item_id == issue.material_item_id)
|
||
.limit(1)
|
||
)
|
||
gross = to_decimal(bom_item.gross_weight_per_piece_kg if bom_item else 0)
|
||
net = to_decimal(bom_item.net_weight_per_piece_kg if bom_item else 0)
|
||
default_finished_qty = to_decimal(finished_qty if finished_qty is not None else snapshot.max_finished_inbound_qty)
|
||
issued_weight = to_decimal(snapshot.issued_weight_kg)
|
||
returned_weight = to_decimal(snapshot.returned_material_weight_kg)
|
||
scrap_inbound_weight = to_decimal(snapshot.scrap_inbound_weight_kg)
|
||
reported_scrap_qty = to_decimal(snapshot.total_operation_scrap_qty)
|
||
theoretical_surplus = clamp_non_negative(issued_weight - returned_weight - default_finished_qty * gross)
|
||
theoretical_scrap = clamp_non_negative(default_finished_qty * clamp_non_negative(gross - net) + reported_scrap_qty * gross - scrap_inbound_weight)
|
||
return ProductionWorkOrderInboundPreviewRead(
|
||
work_order_id=work_order.id,
|
||
work_order_no=work_order.work_order_no,
|
||
product_item_id=work_order.product_item_id,
|
||
product_name=product.item_name if product else "",
|
||
source_lot_id=source_lot.id if source_lot else issue.source_lot_id,
|
||
source_lot_no=source_lot.lot_no if source_lot else issue.material_sub_batch_no,
|
||
issued_weight_kg=float(issued_weight),
|
||
gross_weight_per_piece_kg=float(gross),
|
||
net_weight_per_piece_kg=float(net),
|
||
reported_good_qty=float(snapshot.final_operation_good_qty),
|
||
reported_scrap_qty=float(reported_scrap_qty),
|
||
finished_received_qty=float(snapshot.finished_received_qty),
|
||
surplus_returned_weight_kg=float(returned_weight),
|
||
scrap_inbound_weight_kg=float(scrap_inbound_weight),
|
||
theoretical_finished_qty=float(snapshot.max_finished_inbound_qty),
|
||
theoretical_surplus_weight_kg=float(theoretical_surplus),
|
||
theoretical_scrap_weight_kg=float(theoretical_scrap),
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 5: Implement transactional posting helpers**
|
||
|
||
Continue in `backend/app/services/production_work_order_inbound.py` with helper functions that are called by the final submit function:
|
||
|
||
```python
|
||
def _remark(base: str, payload: ProductionWorkOrderInboundCreate) -> str:
|
||
parts = [base]
|
||
if payload.deviation_remark:
|
||
parts.append(f"入库偏差说明:{payload.deviation_remark}")
|
||
if payload.remark:
|
||
parts.append(payload.remark)
|
||
return ";".join(part for part in parts if part)
|
||
|
||
|
||
def _source_lot(db: Session, preview: ProductionWorkOrderInboundPreviewRead) -> StockLot:
|
||
source_lot = db.get(StockLot, preview.source_lot_id)
|
||
if not source_lot:
|
||
raise HTTPException(status_code=404, detail="来源材料库存批次号不存在")
|
||
return source_lot
|
||
|
||
|
||
def _post_surplus_return(
|
||
db: Session,
|
||
*,
|
||
work_order: WorkOrder,
|
||
preview: ProductionWorkOrderInboundPreviewRead,
|
||
payload: ProductionWorkOrderInboundCreate,
|
||
surplus_weight: Decimal,
|
||
operator_user_id: int | None,
|
||
) -> None:
|
||
source_lot = _source_lot(db, preview)
|
||
source_lot.remaining_weight_kg = to_decimal(source_lot.remaining_weight_kg) + surplus_weight
|
||
if source_lot.status == "DEPLETED" and to_decimal(source_lot.remaining_weight_kg) > 0:
|
||
source_lot.status = "AVAILABLE"
|
||
db.add(source_lot)
|
||
material_row = db.scalar(
|
||
select(WorkOrderMaterial)
|
||
.where(WorkOrderMaterial.work_order_id == work_order.id, WorkOrderMaterial.material_item_id == source_lot.item_id)
|
||
.limit(1)
|
||
)
|
||
if material_row:
|
||
material_row.returned_weight_kg = to_decimal(material_row.returned_weight_kg) + surplus_weight
|
||
db.add(material_row)
|
||
upsert_stock_balance(
|
||
db,
|
||
item_id=source_lot.item_id,
|
||
warehouse_id=source_lot.warehouse_id,
|
||
location_id=source_lot.location_id,
|
||
qty_delta=Decimal("0"),
|
||
weight_delta=surplus_weight,
|
||
available_qty_delta=Decimal("0"),
|
||
available_weight_delta=surplus_weight,
|
||
unit_cost=to_decimal(source_lot.unit_cost, "0.0001"),
|
||
)
|
||
create_inventory_txn(
|
||
db,
|
||
txn_type="PRODUCTION_SURPLUS_IN",
|
||
item_id=source_lot.item_id,
|
||
warehouse_id=source_lot.warehouse_id,
|
||
location_id=source_lot.location_id,
|
||
lot_id=source_lot.id,
|
||
qty_change=Decimal("0"),
|
||
weight_change=surplus_weight,
|
||
unit_cost=to_decimal(source_lot.unit_cost, "0.0001"),
|
||
source_doc_type="WORK_ORDER",
|
||
source_doc_id=work_order.id,
|
||
source_line_id=source_lot.id,
|
||
biz_time=datetime.now(),
|
||
operator_user_id=operator_user_id,
|
||
remark=_remark("生产工单入库:生产余料入库", payload),
|
||
amount_basis="WEIGHT",
|
||
)
|
||
|
||
|
||
def _post_finished_inbound(
|
||
db: Session,
|
||
*,
|
||
work_order: WorkOrder,
|
||
preview: ProductionWorkOrderInboundPreviewRead,
|
||
payload: ProductionWorkOrderInboundCreate,
|
||
finished_qty: Decimal,
|
||
operator_user_id: int | None,
|
||
) -> None:
|
||
product = db.get(Item, work_order.product_item_id)
|
||
if not product:
|
||
raise HTTPException(status_code=404, detail="工单产品不存在")
|
||
warehouse = db.get(Warehouse, payload.finished_warehouse_id)
|
||
if not warehouse or str(warehouse.warehouse_type or "").upper() != "FINISHED":
|
||
raise HTTPException(status_code=400, detail="生产工单入库必须选择成品库")
|
||
location = get_location_or_default(db, payload.finished_warehouse_id, None)
|
||
source_lot = _source_lot(db, preview)
|
||
gross_weight = to_decimal(preview.gross_weight_per_piece_kg)
|
||
receipt_weight = finished_qty * gross_weight
|
||
unit_cost = to_decimal(source_lot.unit_cost, "0.0001") * gross_weight
|
||
lot_no = f"CP-{work_order.work_order_no}-{int(to_decimal(work_order.finished_qty) + finished_qty)}"
|
||
lot = StockLot(
|
||
lot_no=lot_no[:50],
|
||
parent_lot_id=source_lot.id,
|
||
lot_role="FINISHED_FROM_RAW_BATCH",
|
||
material_sub_batch_no=None,
|
||
item_id=product.id,
|
||
warehouse_id=warehouse.id,
|
||
location_id=location.id if location else None,
|
||
source_doc_type="WORK_ORDER_INBOUND",
|
||
source_doc_id=work_order.id,
|
||
source_line_id=None,
|
||
source_material_lot_id=source_lot.id,
|
||
source_material_sub_batch_no=source_lot.lot_no,
|
||
source_material_summary=f"来源库存批次号:{source_lot.lot_no}",
|
||
inbound_qty=finished_qty,
|
||
inbound_weight_kg=receipt_weight,
|
||
remaining_qty=finished_qty,
|
||
remaining_weight_kg=receipt_weight,
|
||
locked_qty=Decimal("0"),
|
||
locked_weight_kg=Decimal("0"),
|
||
unit_cost=unit_cost,
|
||
quality_status="PASS",
|
||
status="AVAILABLE",
|
||
remark=_remark("生产工单入库:成品入库", payload),
|
||
)
|
||
db.add(lot)
|
||
db.flush()
|
||
upsert_stock_balance(
|
||
db,
|
||
item_id=product.id,
|
||
warehouse_id=warehouse.id,
|
||
location_id=location.id if location else None,
|
||
qty_delta=finished_qty,
|
||
weight_delta=receipt_weight,
|
||
available_qty_delta=finished_qty,
|
||
available_weight_delta=receipt_weight,
|
||
unit_cost=unit_cost,
|
||
)
|
||
create_inventory_txn(
|
||
db,
|
||
txn_type="FG_IN",
|
||
item_id=product.id,
|
||
warehouse_id=warehouse.id,
|
||
location_id=location.id if location else None,
|
||
lot_id=lot.id,
|
||
qty_change=finished_qty,
|
||
weight_change=receipt_weight,
|
||
unit_cost=unit_cost,
|
||
source_doc_type="WORK_ORDER",
|
||
source_doc_id=work_order.id,
|
||
source_line_id=None,
|
||
biz_time=datetime.now(),
|
||
operator_user_id=operator_user_id,
|
||
remark=_remark("生产工单入库:成品入库", payload),
|
||
amount_basis="QTY",
|
||
)
|
||
work_order.finished_qty = to_decimal(work_order.finished_qty) + finished_qty
|
||
db.add(work_order)
|
||
|
||
|
||
def _post_scrap_inbound(
|
||
db: Session,
|
||
*,
|
||
work_order: WorkOrder,
|
||
preview: ProductionWorkOrderInboundPreviewRead,
|
||
payload: ProductionWorkOrderInboundCreate,
|
||
scrap_weight: Decimal,
|
||
operator_user_id: int | None,
|
||
) -> None:
|
||
source_lot = _source_lot(db, preview)
|
||
warehouse = db.get(Warehouse, payload.scrap_warehouse_id)
|
||
if not warehouse or str(warehouse.warehouse_type or "").upper() != "SCRAP":
|
||
raise HTTPException(status_code=400, detail="生产工单入库必须选择废料库")
|
||
location = get_location_or_default(db, payload.scrap_warehouse_id, None)
|
||
lot = StockLot(
|
||
lot_no=f"FL-{work_order.work_order_no}"[:50],
|
||
parent_lot_id=source_lot.id,
|
||
lot_role="PRODUCTION_SCRAP",
|
||
material_sub_batch_no=None,
|
||
item_id=source_lot.item_id,
|
||
warehouse_id=warehouse.id,
|
||
location_id=location.id if location else None,
|
||
source_doc_type="WORK_ORDER",
|
||
source_doc_id=work_order.id,
|
||
source_line_id=None,
|
||
source_material_lot_id=source_lot.id,
|
||
source_material_sub_batch_no=source_lot.lot_no,
|
||
source_material_summary=f"来源库存批次号:{source_lot.lot_no}",
|
||
inbound_qty=Decimal("0"),
|
||
inbound_weight_kg=scrap_weight,
|
||
remaining_qty=Decimal("0"),
|
||
remaining_weight_kg=scrap_weight,
|
||
locked_qty=Decimal("0"),
|
||
locked_weight_kg=Decimal("0"),
|
||
unit_cost=to_decimal(source_lot.unit_cost, "0.0001"),
|
||
quality_status="PASS",
|
||
status="AVAILABLE",
|
||
remark=_remark("生产工单入库:生产废料入库", payload),
|
||
)
|
||
db.add(lot)
|
||
db.flush()
|
||
upsert_stock_balance(
|
||
db,
|
||
item_id=source_lot.item_id,
|
||
warehouse_id=warehouse.id,
|
||
location_id=location.id if location else None,
|
||
qty_delta=Decimal("0"),
|
||
weight_delta=scrap_weight,
|
||
available_qty_delta=Decimal("0"),
|
||
available_weight_delta=scrap_weight,
|
||
unit_cost=to_decimal(source_lot.unit_cost, "0.0001"),
|
||
)
|
||
create_inventory_txn(
|
||
db,
|
||
txn_type="PRODUCTION_SCRAP_IN",
|
||
item_id=source_lot.item_id,
|
||
warehouse_id=warehouse.id,
|
||
location_id=location.id if location else None,
|
||
lot_id=lot.id,
|
||
qty_change=Decimal("0"),
|
||
weight_change=scrap_weight,
|
||
unit_cost=to_decimal(source_lot.unit_cost, "0.0001"),
|
||
source_doc_type="WORK_ORDER",
|
||
source_doc_id=work_order.id,
|
||
source_line_id=None,
|
||
biz_time=datetime.now(),
|
||
operator_user_id=operator_user_id,
|
||
remark=_remark("生产工单入库:生产废料入库", payload),
|
||
amount_basis="WEIGHT",
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 6: Implement submit function**
|
||
|
||
Continue in `backend/app/services/production_work_order_inbound.py`:
|
||
|
||
```python
|
||
def create_production_work_order_inbound(
|
||
db: Session,
|
||
*,
|
||
payload: ProductionWorkOrderInboundCreate,
|
||
operator_user_id: int | None,
|
||
) -> ProductionWorkOrderInboundRead:
|
||
work_order = db.get(WorkOrder, payload.work_order_id)
|
||
if not work_order:
|
||
raise HTTPException(status_code=404, detail="生产工单不存在")
|
||
if str(work_order.status or "").upper() == "SETTLED":
|
||
raise HTTPException(status_code=400, detail="该生产工单已结单,不能继续入库")
|
||
finished_qty = to_decimal(payload.finished_qty)
|
||
surplus_weight = to_decimal(payload.surplus_weight_kg)
|
||
scrap_weight = to_decimal(payload.scrap_weight_kg)
|
||
if finished_qty <= 0 and surplus_weight <= 0 and scrap_weight <= 0:
|
||
raise HTTPException(status_code=400, detail="本次至少填写一项入库数据")
|
||
preview = preview_production_work_order_inbound(db, work_order_id=payload.work_order_id, finished_qty=finished_qty)
|
||
if (
|
||
_deviation_exceeds_limit(surplus_weight, to_decimal(preview.theoretical_surplus_weight_kg))
|
||
or _deviation_exceeds_limit(scrap_weight, to_decimal(preview.theoretical_scrap_weight_kg))
|
||
) and not str(payload.deviation_remark or "").strip():
|
||
raise HTTPException(status_code=400, detail="余料或废料入库重量与理论值偏差超过上下15%,请填写偏差说明")
|
||
if payload.settle_work_order and not payload.settle_confirmed:
|
||
raise HTTPException(status_code=400, detail="结单前必须确认本次入库明细及结单影响")
|
||
|
||
if finished_qty > 0:
|
||
_post_finished_inbound(db, work_order=work_order, preview=preview, payload=payload, finished_qty=finished_qty, operator_user_id=operator_user_id)
|
||
if surplus_weight > 0:
|
||
_post_surplus_return(db, work_order=work_order, preview=preview, payload=payload, surplus_weight=surplus_weight, operator_user_id=operator_user_id)
|
||
if scrap_weight > 0:
|
||
_post_scrap_inbound(db, work_order=work_order, preview=preview, payload=payload, scrap_weight=scrap_weight, operator_user_id=operator_user_id)
|
||
|
||
if payload.settle_work_order:
|
||
work_order.status = "SETTLED"
|
||
work_order.actual_end_time = work_order.actual_end_time or datetime.now()
|
||
work_order.updated_at = datetime.now()
|
||
parts = [part.strip() for part in str(work_order.remark or "").replace(";", ";").split(";") if part.strip()]
|
||
parts.append("生产工单入库已结单")
|
||
if payload.deviation_remark:
|
||
parts.append(f"入库偏差说明:{payload.deviation_remark}")
|
||
work_order.remark = ";".join(dict.fromkeys(parts))[-255:]
|
||
db.add(work_order)
|
||
db.commit()
|
||
return ProductionWorkOrderInboundRead(
|
||
work_order_id=work_order.id,
|
||
work_order_no=work_order.work_order_no,
|
||
finished_qty=float(finished_qty),
|
||
surplus_weight_kg=float(surplus_weight),
|
||
scrap_weight_kg=float(scrap_weight),
|
||
deviation_remark=payload.deviation_remark,
|
||
settled=bool(payload.settle_work_order),
|
||
message="生产工单入库已完成",
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 7: Add production routes**
|
||
|
||
Modify `backend/app/api/routes/production.py` imports:
|
||
|
||
```python
|
||
from app.schemas.operations import ProductionWorkOrderInboundCreate, ProductionWorkOrderInboundPreviewRead, ProductionWorkOrderInboundRead
|
||
from app.services.production_work_order_inbound import create_production_work_order_inbound, preview_production_work_order_inbound
|
||
```
|
||
|
||
Add routes:
|
||
|
||
```python
|
||
@router.get("/work-order-inbounds/preview", response_model=ProductionWorkOrderInboundPreviewRead)
|
||
def get_work_order_inbound_preview(
|
||
work_order_id: int = Query(gt=0),
|
||
finished_qty: float | None = Query(default=None, ge=0),
|
||
db: Session = Depends(get_db),
|
||
) -> ProductionWorkOrderInboundPreviewRead:
|
||
return preview_production_work_order_inbound(
|
||
db,
|
||
work_order_id=work_order_id,
|
||
finished_qty=to_decimal(finished_qty) if finished_qty is not None else None,
|
||
)
|
||
|
||
|
||
@router.post("/work-order-inbounds", response_model=ProductionWorkOrderInboundRead)
|
||
def post_work_order_inbound(
|
||
payload: ProductionWorkOrderInboundCreate,
|
||
context: AuthContext = Depends(require_authenticated_user),
|
||
db: Session = Depends(get_db),
|
||
) -> ProductionWorkOrderInboundRead:
|
||
return create_production_work_order_inbound(db, payload=payload, operator_user_id=context.user.id)
|
||
```
|
||
|
||
- [ ] **Step 8: Run backend production inbound tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
|
||
.venv/bin/python -m pytest tests/test_production_work_order_inbound.py tests/test_production_work_order_ledger.py -q
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
---
|
||
|
||
### Task 7: Add ERP Frontend UI For Prefix, Single-Lot Production Out, And Work-Order Inbound
|
||
|
||
**Files:**
|
||
|
||
- Modify: `frontend/src/components/StockLotTagSelect.vue`
|
||
- Modify: `frontend/src/views/SystemExtensionView.vue`
|
||
- Modify: `frontend/src/views/InventoryLedgerView.vue`
|
||
- Create: `frontend/scripts/test-production-work-order-inbound-ui.mjs`
|
||
|
||
- [ ] **Step 1: Add frontend tests for single-select and production inbound text**
|
||
|
||
Create `frontend/scripts/test-production-work-order-inbound-ui.mjs`:
|
||
|
||
```js
|
||
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
|
||
const inventoryView = readFileSync(new URL("../src/views/InventoryLedgerView.vue", import.meta.url), "utf8");
|
||
const stockLotSelect = readFileSync(new URL("../src/components/StockLotTagSelect.vue", import.meta.url), "utf8");
|
||
const systemExtension = readFileSync(new URL("../src/views/SystemExtensionView.vue", import.meta.url), "utf8");
|
||
|
||
assert.match(stockLotSelect, /maxSelections/);
|
||
assert.match(inventoryView, /生产工单入库/);
|
||
assert.match(inventoryView, /一次生产出库只能选择一个材料库存批次号/);
|
||
assert.match(inventoryView, /work-order-inbounds\\/preview/);
|
||
assert.match(inventoryView, /work-order-inbounds/);
|
||
assert.match(inventoryView, /本次入库后结单/);
|
||
assert.match(systemExtension, /原材料库存批次号前缀/);
|
||
assert.match(systemExtension, /raw-material-lot-prefix/);
|
||
|
||
console.log("production work-order inbound UI checks passed");
|
||
```
|
||
|
||
- [ ] **Step 2: Run frontend UI test to verify failure**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
|
||
node scripts/test-production-work-order-inbound-ui.mjs
|
||
```
|
||
|
||
Expected: FAIL before UI changes.
|
||
|
||
- [ ] **Step 3: Add `maxSelections` to `StockLotTagSelect.vue`**
|
||
|
||
Modify props:
|
||
|
||
```js
|
||
maxSelections: {
|
||
type: Number,
|
||
default: 0
|
||
}
|
||
```
|
||
|
||
Modify `canSelect`:
|
||
|
||
```js
|
||
function canSelect(lot) {
|
||
if (!lot || (!props.allowUnavailable && !lot.is_available_for_issue) || isSelected(lot)) {
|
||
return false;
|
||
}
|
||
return !props.maxSelections || selectedLots.value.length < props.maxSelections;
|
||
}
|
||
```
|
||
|
||
Modify `selectLot` so single-select replaces existing selection:
|
||
|
||
```js
|
||
const nextSelectedLots = props.maxSelections === 1 ? [] : selectedLots.value;
|
||
emit("update:modelValue", [
|
||
...nextSelectedLots,
|
||
{
|
||
...lot,
|
||
issued_weight_kg: normalizeSelectedIssuedWeight(lot)
|
||
}
|
||
]);
|
||
```
|
||
|
||
- [ ] **Step 4: Add raw lot prefix config card to System Extension**
|
||
|
||
Modify `frontend/src/views/SystemExtensionView.vue`:
|
||
|
||
Add reactive form:
|
||
|
||
```js
|
||
const rawLotPrefixConfig = ref({});
|
||
const rawLotPrefixForm = reactive({ config_value: "YL", remark: "" });
|
||
```
|
||
|
||
Add loader:
|
||
|
||
```js
|
||
async function loadRawLotPrefixConfig() {
|
||
const result = await fetchResource("/system-extension/raw-material-lot-prefix", {});
|
||
rawLotPrefixConfig.value = result;
|
||
rawLotPrefixForm.config_value = result.config_value || "YL";
|
||
rawLotPrefixForm.remark = result.remark || "";
|
||
}
|
||
```
|
||
|
||
Add saver:
|
||
|
||
```js
|
||
async function saveRawLotPrefixConfig() {
|
||
resetFeedback();
|
||
try {
|
||
const result = await putResource("/system-extension/raw-material-lot-prefix", rawLotPrefixForm);
|
||
rawLotPrefixConfig.value = result;
|
||
rawLotPrefixForm.config_value = result.config_value || "YL";
|
||
rawLotPrefixForm.remark = result.remark || "";
|
||
feedbackMessage.value = "原材料库存批次号前缀已保存";
|
||
} catch (error) {
|
||
errorMessage.value = error.message || "原材料库存批次号前缀保存失败";
|
||
}
|
||
}
|
||
```
|
||
|
||
Call `loadRawLotPrefixConfig()` in `loadAll()`.
|
||
|
||
Add panel:
|
||
|
||
```html
|
||
<section class="panel">
|
||
<div class="panel-header">
|
||
<div>
|
||
<p class="eyebrow">仓库编号配置</p>
|
||
<h3>原材料库存批次号前缀</h3>
|
||
</div>
|
||
<span class="panel-tag">{{ rawLotPrefixConfig.config_value || "YL" }}</span>
|
||
</div>
|
||
<form class="stack-form" @submit.prevent="saveRawLotPrefixConfig">
|
||
<label class="form-field">
|
||
<span>前缀</span>
|
||
<input v-model.trim="rawLotPrefixForm.config_value" type="text" maxlength="20" placeholder="默认 YL" />
|
||
</label>
|
||
<label class="form-field">
|
||
<span>说明</span>
|
||
<input v-model.trim="rawLotPrefixForm.remark" type="text" placeholder="例如:原材料库存批次号默认前缀" />
|
||
</label>
|
||
<button class="primary-button" type="submit">保存前缀</button>
|
||
</form>
|
||
</section>
|
||
```
|
||
|
||
- [ ] **Step 5: Change production outbound UI to single-select**
|
||
|
||
In `InventoryLedgerView.vue`, update production outbound `StockLotTagSelect`:
|
||
|
||
```html
|
||
<StockLotTagSelect
|
||
v-model="selectedProductionStockLots"
|
||
:options="productionStockLotOptions"
|
||
:max-selections="1"
|
||
placeholder="搜索并选择一个材料库存批次号"
|
||
/>
|
||
```
|
||
|
||
Update validation:
|
||
|
||
```js
|
||
function validateSelectedProductionStockLots(lots) {
|
||
if (!lots.length) {
|
||
return "请选择一个材料库存批次号";
|
||
}
|
||
if (lots.length > 1) {
|
||
return "一次生产出库只能选择一个材料库存批次号";
|
||
}
|
||
const lot = lots[0];
|
||
const issuedWeight = Number(lot.issued_weight_kg);
|
||
const remainingWeight = Number(lot.remaining_weight_kg || 0);
|
||
const lotNo = lot.lot_no || "未命名批次";
|
||
if (!Number.isFinite(issuedWeight) || issuedWeight <= 0) {
|
||
return `材料库存批次号 ${lotNo} 的本次出库重量必须大于0`;
|
||
}
|
||
if (issuedWeight > remainingWeight) {
|
||
return `材料库存批次号 ${lotNo} 的本次出库重量不能超过剩余重量 ${formatWeight(remainingWeight)}`;
|
||
}
|
||
return "";
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Add “生产工单入库” button next to warehouse ledger button**
|
||
|
||
Find the warehouse action area near the current “流水” button in `InventoryLedgerView.vue` and add:
|
||
|
||
```html
|
||
<button class="ghost-button ledger-action-button" type="button" @click="openProductionWorkOrderInboundDrawer">
|
||
生产工单入库
|
||
</button>
|
||
```
|
||
|
||
Add state:
|
||
|
||
```js
|
||
const productionWorkOrderInboundDrawerOpen = ref(false);
|
||
const selectedInboundWorkOrderId = ref("");
|
||
const productionWorkOrderInboundPreview = ref(null);
|
||
const productionWorkOrderInboundForm = reactive({
|
||
finished_qty: 0,
|
||
surplus_weight_kg: 0,
|
||
scrap_weight_kg: 0,
|
||
deviation_remark: "",
|
||
remark: "",
|
||
settle_work_order: false
|
||
});
|
||
```
|
||
|
||
Add functions:
|
||
|
||
```js
|
||
function openProductionWorkOrderInboundDrawer() {
|
||
resetFeedback();
|
||
selectedInboundWorkOrderId.value = "";
|
||
productionWorkOrderInboundPreview.value = null;
|
||
Object.assign(productionWorkOrderInboundForm, {
|
||
finished_qty: 0,
|
||
surplus_weight_kg: 0,
|
||
scrap_weight_kg: 0,
|
||
deviation_remark: "",
|
||
remark: "",
|
||
settle_work_order: false
|
||
});
|
||
productionWorkOrderInboundDrawerOpen.value = true;
|
||
}
|
||
|
||
async function loadProductionWorkOrderInboundPreview() {
|
||
if (!selectedInboundWorkOrderId.value) {
|
||
productionWorkOrderInboundPreview.value = null;
|
||
return;
|
||
}
|
||
const query = new URLSearchParams({ work_order_id: String(selectedInboundWorkOrderId.value) });
|
||
if (Number(productionWorkOrderInboundForm.finished_qty) > 0) {
|
||
query.set("finished_qty", String(productionWorkOrderInboundForm.finished_qty));
|
||
}
|
||
const result = await fetchResource(`/production/work-order-inbounds/preview?${query.toString()}`, {});
|
||
productionWorkOrderInboundPreview.value = result;
|
||
if (!Number(productionWorkOrderInboundForm.finished_qty)) {
|
||
productionWorkOrderInboundForm.finished_qty = Number(result.theoretical_finished_qty || 0);
|
||
}
|
||
productionWorkOrderInboundForm.surplus_weight_kg = Number(result.theoretical_surplus_weight_kg || 0);
|
||
productionWorkOrderInboundForm.scrap_weight_kg = Number(result.theoretical_scrap_weight_kg || 0);
|
||
}
|
||
```
|
||
|
||
Add submit:
|
||
|
||
```js
|
||
async function submitProductionWorkOrderInbound() {
|
||
resetFeedback();
|
||
if (!selectedInboundWorkOrderId.value) {
|
||
errorMessage.value = "请选择生产工单";
|
||
return;
|
||
}
|
||
if (productionWorkOrderInboundForm.settle_work_order) {
|
||
const confirmed = window.confirm(
|
||
`请确认本次生产工单入库\n\n工单:${productionWorkOrderInboundPreview.value?.work_order_no || ""}\n成品入库:${productionWorkOrderInboundForm.finished_qty || 0} 件\n余料入库:${productionWorkOrderInboundForm.surplus_weight_kg || 0} kg\n废料入库:${productionWorkOrderInboundForm.scrap_weight_kg || 0} kg\n\n勾选结单后,该工单将变为已结单,小程序不再显示该工单对应的材料库存批次号,后续不能再对该工单做生产工单入库。`
|
||
);
|
||
if (!confirmed) {
|
||
return;
|
||
}
|
||
}
|
||
const result = await postResource("/production/work-order-inbounds", {
|
||
work_order_id: Number(selectedInboundWorkOrderId.value),
|
||
finished_warehouse_id: finishedWarehouseId.value,
|
||
scrap_warehouse_id: scrapWarehouseId.value,
|
||
finished_qty: Number(productionWorkOrderInboundForm.finished_qty || 0),
|
||
surplus_weight_kg: Number(productionWorkOrderInboundForm.surplus_weight_kg || 0),
|
||
scrap_weight_kg: Number(productionWorkOrderInboundForm.scrap_weight_kg || 0),
|
||
deviation_remark: productionWorkOrderInboundForm.deviation_remark || null,
|
||
remark: productionWorkOrderInboundForm.remark || null,
|
||
settle_work_order: Boolean(productionWorkOrderInboundForm.settle_work_order),
|
||
settle_confirmed: Boolean(productionWorkOrderInboundForm.settle_work_order)
|
||
});
|
||
feedbackMessage.value = result.message || "生产工单入库已完成";
|
||
productionWorkOrderInboundDrawerOpen.value = false;
|
||
await loadAll();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Add production work-order inbound drawer**
|
||
|
||
Add a `FormDrawer` in `InventoryLedgerView.vue`:
|
||
|
||
```html
|
||
<FormDrawer
|
||
:open="productionWorkOrderInboundDrawerOpen"
|
||
eyebrow="嘉恒仓库 · 生产工单入库"
|
||
title="生产工单入库"
|
||
tag="工单闭环"
|
||
wide
|
||
@close="productionWorkOrderInboundDrawerOpen = false"
|
||
>
|
||
<form class="stack-form" @submit.prevent="submitProductionWorkOrderInbound">
|
||
<label class="form-field">
|
||
<span>生产工单</span>
|
||
<select v-model.number="selectedInboundWorkOrderId" required @change="loadProductionWorkOrderInboundPreview">
|
||
<option disabled value="">请选择未结单生产工单</option>
|
||
<option v-for="workOrder in openProductionWorkOrders" :key="workOrder.work_order_id" :value="workOrder.work_order_id">
|
||
{{ workOrder.work_order_no }} · {{ workOrder.product_name }} · 领料 {{ formatWeight(workOrder.issued_weight_kg) }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
<div v-if="productionWorkOrderInboundPreview" class="summary-grid compact-summary-grid">
|
||
<article class="summary-card"><span>材料库存批次号</span><strong>{{ productionWorkOrderInboundPreview.source_lot_no }}</strong></article>
|
||
<article class="summary-card"><span>领料重量</span><strong>{{ formatWeight(productionWorkOrderInboundPreview.issued_weight_kg) }}</strong></article>
|
||
<article class="summary-card"><span>单件毛重</span><strong>{{ formatWeight(productionWorkOrderInboundPreview.gross_weight_per_piece_kg) }}</strong></article>
|
||
<article class="summary-card"><span>单件净重</span><strong>{{ formatWeight(productionWorkOrderInboundPreview.net_weight_per_piece_kg) }}</strong></article>
|
||
</div>
|
||
<div class="triple-field">
|
||
<label class="form-field">
|
||
<span>成品入库数量</span>
|
||
<input v-model.number="productionWorkOrderInboundForm.finished_qty" type="number" min="0" step="1" @change="loadProductionWorkOrderInboundPreview" />
|
||
</label>
|
||
<label class="form-field">
|
||
<span>余料入库重量(kg)</span>
|
||
<input v-model.number="productionWorkOrderInboundForm.surplus_weight_kg" type="number" min="0" step="0.001" />
|
||
</label>
|
||
<label class="form-field">
|
||
<span>废料重量(kg)</span>
|
||
<input v-model.number="productionWorkOrderInboundForm.scrap_weight_kg" type="number" min="0" step="0.001" />
|
||
</label>
|
||
</div>
|
||
<label class="form-field">
|
||
<span>偏差说明</span>
|
||
<textarea v-model.trim="productionWorkOrderInboundForm.deviation_remark" rows="3" placeholder="余料或废料与理论值偏差超过上下15%时必须填写"></textarea>
|
||
</label>
|
||
<label class="form-field checkbox-field">
|
||
<input v-model="productionWorkOrderInboundForm.settle_work_order" type="checkbox" />
|
||
<span>本次入库后结单</span>
|
||
</label>
|
||
<label class="form-field">
|
||
<span>备注</span>
|
||
<textarea v-model.trim="productionWorkOrderInboundForm.remark" rows="3"></textarea>
|
||
</label>
|
||
<button class="primary-button" type="submit" :disabled="submittingOperation">保存生产工单入库</button>
|
||
</form>
|
||
</FormDrawer>
|
||
```
|
||
|
||
- [ ] **Step 8: Run frontend tests and build**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
|
||
node scripts/test-production-work-order-inbound-ui.mjs
|
||
node scripts/test-inventory-ledger-options.mjs
|
||
npm run build
|
||
```
|
||
|
||
Expected: scripts PASS and build PASS with only existing Vite chunk-size warning.
|
||
|
||
---
|
||
|
||
### Task 8: Update Miniapp Frontend Wording To “材料库存批次号”
|
||
|
||
**Files:**
|
||
|
||
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.wxml`
|
||
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.js`
|
||
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/records/records.wxml`
|
||
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard/dashboard.wxml`
|
||
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review/review.wxml`
|
||
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js`
|
||
|
||
- [ ] **Step 1: Add a text scan test**
|
||
|
||
Run before edits:
|
||
|
||
```bash
|
||
rg -n "工单号|原材料分批次号|请选择工单号|暂无可选工单号" \
|
||
/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm \
|
||
/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/records \
|
||
/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard \
|
||
/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review \
|
||
/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js
|
||
```
|
||
|
||
Expected: output includes old wording.
|
||
|
||
- [ ] **Step 2: Replace visible wording**
|
||
|
||
In `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.wxml`:
|
||
|
||
```xml
|
||
<text class="label">材料库存批次号</text>
|
||
<view class="picker {{reportItem.rawMaterialBatchError ? 'field-error' : ''}}">{{reportItem.rawMaterialBatchNo || '请选择材料库存批次号'}}</view>
|
||
<view wx:else class="readonly">暂无可选材料库存批次号</view>
|
||
```
|
||
|
||
In `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.js`, replace validation message:
|
||
|
||
```js
|
||
title: `${blockDisplay(block)}请选择材料库存批次号`,
|
||
```
|
||
|
||
In records/dashboard/review WXML files, replace visible `原材料分批次号` with `材料库存批次号`.
|
||
|
||
In `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js`, keep payload field names unchanged but replace correction display label:
|
||
|
||
```js
|
||
correctionPair(corrections, 'raw_material_batch_no', '材料库存批次号', row.raw_material_batch_no || '')
|
||
```
|
||
|
||
- [ ] **Step 3: Verify old wording is gone from user-facing miniapp files**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
rg -n "请选择工单号|暂无可选工单号|原材料分批次号" \
|
||
/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm \
|
||
/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/records \
|
||
/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard \
|
||
/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review \
|
||
/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js
|
||
```
|
||
|
||
Expected: no output.
|
||
|
||
---
|
||
|
||
### Task 9: Add SQL Execution And Deployment Checks
|
||
|
||
**Files:**
|
||
|
||
- Use: `backend/sql/add_raw_lot_work_order_inbound_patch.sql`
|
||
- No code files modified in this task.
|
||
|
||
- [ ] **Step 1: Execute SQL patch against the target test database**
|
||
|
||
Run from ERP backend:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
|
||
.venv/bin/python - <<'PY'
|
||
from pathlib import Path
|
||
import re
|
||
import pymysql
|
||
|
||
env = {}
|
||
for line in Path(".env").read_text(encoding="utf-8").splitlines():
|
||
line = line.strip()
|
||
if not line or line.startswith("#") or "=" not in line:
|
||
continue
|
||
key, value = line.split("=", 1)
|
||
env[key.strip()] = value.strip().strip('"').strip("'")
|
||
|
||
sql_path = Path("sql/add_raw_lot_work_order_inbound_patch.sql")
|
||
statements = [stmt.strip() for stmt in re.split(r";\s*(?:\n|$)", sql_path.read_text(encoding="utf-8")) if stmt.strip()]
|
||
conn = pymysql.connect(
|
||
host=env["MYSQL_HOST"],
|
||
port=int(env["MYSQL_PORT"]),
|
||
user=env["MYSQL_USER"],
|
||
password=env["MYSQL_PASSWORD"],
|
||
database=env["MYSQL_DATABASE"],
|
||
charset="utf8mb4",
|
||
)
|
||
try:
|
||
with conn.cursor() as cur:
|
||
for stmt in statements:
|
||
cur.execute(stmt)
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
print(f"executed {len(statements)} statements")
|
||
PY
|
||
```
|
||
|
||
Expected: `executed 2 statements`.
|
||
|
||
- [ ] **Step 2: Verify config table and seed row**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
|
||
.venv/bin/python - <<'PY'
|
||
from pathlib import Path
|
||
import pymysql
|
||
|
||
env = {}
|
||
for line in Path(".env").read_text(encoding="utf-8").splitlines():
|
||
line = line.strip()
|
||
if not line or line.startswith("#") or "=" not in line:
|
||
continue
|
||
key, value = line.split("=", 1)
|
||
env[key.strip()] = value.strip().strip('"').strip("'")
|
||
|
||
conn = pymysql.connect(
|
||
host=env["MYSQL_HOST"],
|
||
port=int(env["MYSQL_PORT"]),
|
||
user=env["MYSQL_USER"],
|
||
password=env["MYSQL_PASSWORD"],
|
||
database=env["MYSQL_DATABASE"],
|
||
charset="utf8mb4",
|
||
)
|
||
try:
|
||
with conn.cursor() as cur:
|
||
cur.execute("select config_code, config_value from sys_system_config where config_code='RAW_MATERIAL_LOT_PREFIX'")
|
||
print(cur.fetchall())
|
||
finally:
|
||
conn.close()
|
||
PY
|
||
```
|
||
|
||
Expected: output contains `RAW_MATERIAL_LOT_PREFIX` and `YL`.
|
||
|
||
---
|
||
|
||
### Task 10: Full Verification
|
||
|
||
**Files:**
|
||
|
||
- No new files.
|
||
|
||
- [ ] **Step 1: Run backend full tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
|
||
.venv/bin/python -m pytest tests -q
|
||
```
|
||
|
||
Expected: all tests pass.
|
||
|
||
- [ ] **Step 2: Run frontend checks**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
|
||
node scripts/test-production-work-order-inbound-ui.mjs
|
||
node scripts/test-inventory-ledger-options.mjs
|
||
node scripts/test-warehouse-drawer-table-overflow.mjs
|
||
npm run build
|
||
```
|
||
|
||
Expected: script tests pass and build succeeds. Vite chunk-size warning is acceptable.
|
||
|
||
- [ ] **Step 3: Run miniapp backend tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
|
||
python -m pytest tests/test_work_order_batch_options.py tests/test_metrics.py tests/test_review_corrections.py -q
|
||
```
|
||
|
||
Expected: tests pass.
|
||
|
||
- [ ] **Step 4: Manual smoke test in browser**
|
||
|
||
Start services:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
|
||
.venv/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||
```
|
||
|
||
In another terminal:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
|
||
npm run dev -- --host 0.0.0.0 --port 5173
|
||
```
|
||
|
||
Smoke flow:
|
||
|
||
1. Open `http://127.0.0.1:5173`.
|
||
2. Go to 系统拓展 and verify 原材料库存批次号前缀 shows `YL`.
|
||
3. Go to 嘉恒仓库原材料库 and create a raw inbound test lot; verify lot number is `YL0001` or the next available `YL` sequence.
|
||
4. Use 原材料库 · 生产出库, select one material stock lot and one product, submit.
|
||
5. Verify 工单台账 has work order `YYYYMMDD-YLxxxx-产品名称`.
|
||
6. Submit another production outbound for the same day, same lot, same product.
|
||
7. Verify it merges into the same work order and adds another issue line.
|
||
8. Open 嘉恒仓库 · 生产工单入库, select that work order, enter finished qty, verify surplus/scrap defaults are filled.
|
||
9. Change surplus or scrap beyond 15%; verify deviation remark is required.
|
||
10. Check “本次入库后结单”, submit, confirm second prompt, verify work order status becomes 已结单.
|
||
|
||
Expected: no red overlay, no uncaught console error, and warehouse流水 shows成品入库、生产余料入库、生产废料入库 records tied to the work order.
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
Spec coverage:
|
||
|
||
- `YL0001` prefix and `YL10000` expansion: Task 1 and Task 2.
|
||
- System拓展 prefix configuration: Task 1 and Task 7.
|
||
- Single-lot production outbound: Task 4 and Task 7.
|
||
- Same-day same-lot same-product merge: Task 4.
|
||
- Miniapp wording and lot-number selection: Task 5 and Task 8.
|
||
- Production work-order inbound entry and workflow: Task 6 and Task 7.
|
||
- 15% deviation remark: Task 6 and Task 7.
|
||
- No default settle and second confirmation: Task 6 and Task 7.
|
||
- SQL execution and verification: Task 9.
|
||
- Full test and smoke checks: Task 10.
|
||
|
||
Placeholder scan:
|
||
|
||
- No unfinished placeholder markers remain in the plan.
|
||
- Production work-order inbound now lists concrete helper functions and submit calls instead of leaving a comment-only implementation gap.
|
||
|
||
Type consistency:
|
||
|
||
- Backend schemas use `ProductionWorkOrderInboundPreviewRead`, `ProductionWorkOrderInboundCreate`, and `ProductionWorkOrderInboundRead`.
|
||
- Frontend routes use `/production/work-order-inbounds/preview` and `/production/work-order-inbounds`.
|
||
- Miniapp keeps the persisted field `raw_material_batch_no` but changes visible wording to “材料库存批次号”.
|
||
|
||
Git note:
|
||
|
||
- Current ERP directory has no `.git` folder. If implementation runs in this mounted directory, commit steps are replaced by checkpoint summaries after each task. If a git worktree is available at execution time, commit after each completed task with a focused message.
|