1667 lines
62 KiB
Markdown
1667 lines
62 KiB
Markdown
# Production Warehouse Document Archive Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Add formal PDF archive support for the production warehouse chain: production material issue, production ledger inbound, finished production inbound, production surplus inbound, and production scrap inbound.
|
||
|
||
**Architecture:** Reuse the existing `document_archives` table, PDF renderer, and `/document-archives` API. Add two document types, `生产领料出库单` keyed by `ProductionBatchLedger.id` and `生产入库结算单` keyed by the main `InventoryTxn.id`; expose archive status through production ledger rows and production ledger transaction rows.
|
||
|
||
**Tech Stack:** FastAPI, SQLAlchemy, Pydantic, ReportLab PDF generation, Vue 3, existing document form/archive components.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
## Scope Mapping
|
||
|
||
- 原材料库 · 生产出库: implemented by `生产领料出库单`, generated after raw material production outbound updates `ProductionBatchLedger`.
|
||
- 生产台账入口 · 生产工单入库: implemented by `生产入库结算单`, generated after `/production/production-ledger-inbounds` saves.
|
||
- 成品库 · 生产入库: implemented by `生产入库结算单`, generated from the branch production finished inbound main `InventoryTxn`.
|
||
- 原材料库 · 生产余料入库: implemented by `生产入库结算单`, generated from the branch production surplus inbound main `InventoryTxn`.
|
||
- 废料库 · 生产废料入库: implemented by `生产入库结算单`, generated from the branch production scrap inbound main `InventoryTxn`.
|
||
|
||
### Backend
|
||
|
||
- Modify: `backend/app/services/document_archives.py`
|
||
- Add production warehouse document constants.
|
||
- Add production material issue context collector.
|
||
- Add production inbound settlement context collector.
|
||
- Add helper to group settlement inventory transactions from a main transaction.
|
||
- Add document type dispatch.
|
||
- Modify: `backend/app/api/routes/document_archives.py`
|
||
- Add route keys `production-material-out` and `production-inbound-settlement`.
|
||
- Update Chinese validation error text.
|
||
- Modify: `backend/app/schemas/operations.py`
|
||
- Add archive fields to `ProductionBatchLedgerRead`.
|
||
- Add archive fields to `ProductionBatchLedgerTxnRead`.
|
||
- Add archive fields to `ProductionBatchLedgerInboundRead`.
|
||
- Add archive fields to `ProductionWorkOrderInboundRead`.
|
||
- Add optional archive fields to `StockLotRead` so warehouse branch entry responses can carry the archive result.
|
||
- Modify: `backend/app/api/routes/production.py`
|
||
- Generate `生产入库结算单` after `/production/production-ledger-inbounds` saves.
|
||
- Generate `生产入库结算单` after `/production/work-order-inbounds` saves through the existing service response.
|
||
- Add latest archive joins to `/production/production-ledger`.
|
||
- Modify: `backend/app/services/production_work_order_inbound.py`
|
||
- Generate `生产入库结算单` after old work-order inbound saves.
|
||
- Return archive status fields without rolling back saved business data.
|
||
- Modify: `backend/app/api/routes/inventory.py`
|
||
- Generate `生产领料出库单` after raw material production outbound creates or updates a production ledger.
|
||
- Generate `生产入库结算单` after branch production inbound creates a main inventory transaction.
|
||
- Return archive status fields in existing response models.
|
||
- Test: `backend/tests/test_production_warehouse_document_archive.py`
|
||
- New focused backend tests for production warehouse archive collection and generation.
|
||
- Modify: `backend/tests/test_document_archive_routes.py`
|
||
- Add route key normalization assertions for production warehouse document types.
|
||
|
||
### Frontend
|
||
|
||
- Modify: `frontend/src/views/ProductionLedgerView.vue`
|
||
- Import and render `DocumentArchiveActions`.
|
||
- Show production material issue archive actions in the production ledger detail header.
|
||
- Show production inbound settlement archive actions for production ledger transaction rows that came from inventory transactions.
|
||
- Add preview/download/regenerate handlers using the two new route keys.
|
||
- Modify: `frontend/src/views/InventoryLedgerView.vue`
|
||
- Update production ledger inbound success feedback to include archive status.
|
||
- Update generic production inbound/outbound success feedback to include archive status where the backend returns it.
|
||
- Add archive action helpers only if needed by the visible warehouse flow; do not duplicate `DocumentArchiveActions` if production ledger already covers the detail action.
|
||
- Test: `frontend/src/views/ProductionLedgerView.test.js`
|
||
- New static tests for archive actions and route keys.
|
||
- Modify: `frontend/src/views/InventoryLedgerView.test.js` if present; otherwise no new inventory static test is required.
|
||
|
||
---
|
||
|
||
### Task 1: Backend Tests For Production Archive Types And Contexts
|
||
|
||
**Files:**
|
||
- Create: `backend/tests/test_production_warehouse_document_archive.py`
|
||
- Modify: `backend/tests/test_document_archive_routes.py`
|
||
|
||
- [ ] **Step 1: Create the focused backend test file**
|
||
|
||
Create `backend/tests/test_production_warehouse_document_archive.py` with this complete structure. The seed intentionally uses only the tables needed by document archive context collection.
|
||
|
||
```python
|
||
from __future__ import annotations
|
||
|
||
import unittest
|
||
from datetime import datetime
|
||
from decimal import Decimal
|
||
from tempfile import TemporaryDirectory
|
||
from unittest.mock import patch
|
||
|
||
from sqlalchemy import BigInteger, create_engine, select
|
||
from sqlalchemy.ext.compiler import compiles
|
||
from sqlalchemy.orm import Session, sessionmaker
|
||
|
||
|
||
@compiles(BigInteger, "sqlite")
|
||
def _compile_big_integer_for_sqlite(type_, compiler, **kw) -> str:
|
||
_ = type_, compiler, kw
|
||
return "INTEGER"
|
||
|
||
|
||
import app.models.document_archive # noqa: E402,F401
|
||
import app.models.master_data # noqa: E402,F401
|
||
import app.models.miniapp # noqa: E402,F401
|
||
import app.models.operations # noqa: E402,F401
|
||
import app.models.org # noqa: E402,F401
|
||
import app.models.planning # noqa: E402,F401
|
||
import app.models.sales # noqa: E402,F401
|
||
from app.models.base import Base # noqa: E402
|
||
from app.models.document_archive import DocumentArchive # noqa: E402
|
||
from app.models.master_data import Item, Warehouse # noqa: E402
|
||
from app.models.operations import InventoryTxn, ProductionBatchLedger, ProductionBatchLedgerTxn, StockLot # noqa: E402
|
||
from app.services.document_archives import ( # noqa: E402
|
||
ARCHIVE_STATUS_READY,
|
||
DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
|
||
DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
|
||
collect_production_inbound_settlement_archive_context,
|
||
collect_production_material_out_archive_context,
|
||
generate_document_archive,
|
||
)
|
||
|
||
|
||
class ProductionWarehouseDocumentArchiveTest(unittest.TestCase):
|
||
def setUp(self) -> None:
|
||
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
|
||
Base.metadata.create_all(engine)
|
||
self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||
self.db: Session = self.SessionLocal()
|
||
self.now = datetime(2026, 6, 11, 10, 30, 0)
|
||
self._seed_data()
|
||
|
||
def tearDown(self) -> None:
|
||
self.db.close()
|
||
|
||
def _seed_data(self) -> None:
|
||
self.raw_warehouse = Warehouse(
|
||
id=1,
|
||
warehouse_code="WH-RAW",
|
||
warehouse_name="原材料库",
|
||
warehouse_type="RAW",
|
||
status="ACTIVE",
|
||
created_at=self.now,
|
||
updated_at=self.now,
|
||
)
|
||
self.finished_warehouse = Warehouse(
|
||
id=2,
|
||
warehouse_code="WH-FIN",
|
||
warehouse_name="成品库",
|
||
warehouse_type="FINISHED",
|
||
status="ACTIVE",
|
||
created_at=self.now,
|
||
updated_at=self.now,
|
||
)
|
||
self.scrap_warehouse = Warehouse(
|
||
id=3,
|
||
warehouse_code="WH-SCRAP",
|
||
warehouse_name="废料库",
|
||
warehouse_type="SCRAP",
|
||
status="ACTIVE",
|
||
created_at=self.now,
|
||
updated_at=self.now,
|
||
)
|
||
self.material = Item(
|
||
id=10,
|
||
item_code="RM-00010",
|
||
item_name="测试冷轧板",
|
||
item_type="RAW_MATERIAL",
|
||
specification="1.2mm",
|
||
material_grade="SPCC",
|
||
unit_weight_kg=Decimal("0"),
|
||
status="ACTIVE",
|
||
created_at=self.now,
|
||
updated_at=self.now,
|
||
)
|
||
self.product = Item(
|
||
id=20,
|
||
item_code="FG-00020",
|
||
item_name="测试钢制碗",
|
||
item_type="FINISHED",
|
||
specification="φ180",
|
||
material_grade=None,
|
||
unit_weight_kg=Decimal("0.5"),
|
||
status="ACTIVE",
|
||
created_at=self.now,
|
||
updated_at=self.now,
|
||
)
|
||
self.material_lot = StockLot(
|
||
id=100,
|
||
lot_no="YL0001",
|
||
parent_lot_id=None,
|
||
lot_role="INBOUND_RAW",
|
||
material_sub_batch_no=None,
|
||
item_id=self.material.id,
|
||
warehouse_id=self.raw_warehouse.id,
|
||
location_id=None,
|
||
source_doc_type="PURCHASE_RECEIPT",
|
||
source_doc_id=900,
|
||
source_line_id=901,
|
||
source_material_lot_id=None,
|
||
source_material_sub_batch_no=None,
|
||
source_material_summary=None,
|
||
inbound_qty=0,
|
||
inbound_weight_kg=500,
|
||
remaining_qty=0,
|
||
remaining_weight_kg=300,
|
||
locked_qty=0,
|
||
locked_weight_kg=0,
|
||
unit_cost=Decimal("6.6"),
|
||
production_date=None,
|
||
expire_date=None,
|
||
quality_status="PASS",
|
||
status="AVAILABLE",
|
||
remark="测试材料批次",
|
||
created_at=self.now,
|
||
updated_at=self.now,
|
||
)
|
||
self.ledger = ProductionBatchLedger(
|
||
id=200,
|
||
material_lot_id=self.material_lot.id,
|
||
material_lot_no=self.material_lot.lot_no,
|
||
material_item_id=self.material.id,
|
||
product_item_id=self.product.id,
|
||
status="在生产",
|
||
miniapp_selectable=1,
|
||
total_issued_weight_kg=Decimal("200"),
|
||
outside_weight_kg=Decimal("80"),
|
||
finished_inbound_qty=Decimal("198"),
|
||
surplus_inbound_weight_kg=Decimal("60"),
|
||
scrap_inbound_weight_kg=Decimal("2"),
|
||
writeoff_weight_kg=Decimal("0"),
|
||
first_issue_time=self.now,
|
||
last_issue_time=self.now,
|
||
locked_at=None,
|
||
reopened_at=None,
|
||
lock_count=0,
|
||
remark="生产台账测试",
|
||
created_at=self.now,
|
||
updated_at=self.now,
|
||
)
|
||
self.issue_ledger_txn = ProductionBatchLedgerTxn(
|
||
id=300,
|
||
production_ledger_id=self.ledger.id,
|
||
txn_type="生产出库",
|
||
qty_delta=0,
|
||
weight_delta_kg=Decimal("200"),
|
||
outside_weight_after_kg=Decimal("200"),
|
||
source_doc_type="库存流水",
|
||
source_doc_id=400,
|
||
source_line_id=self.material_lot.id,
|
||
biz_time=self.now,
|
||
operator_user_id=1,
|
||
remark="原材料库生产出库形成生产台账",
|
||
created_at=self.now,
|
||
updated_at=self.now,
|
||
)
|
||
self.issue_inventory_txn = InventoryTxn(
|
||
id=400,
|
||
txn_no="TXN-ISSUE-001",
|
||
txn_type="PRODUCTION_OUT",
|
||
item_id=self.material.id,
|
||
warehouse_id=self.raw_warehouse.id,
|
||
location_id=None,
|
||
lot_id=self.material_lot.id,
|
||
qty_change=0,
|
||
weight_change_kg=Decimal("-200"),
|
||
unit_cost=Decimal("6.6"),
|
||
amount=Decimal("1320"),
|
||
source_doc_type="生产台账",
|
||
source_doc_id=self.ledger.id,
|
||
source_line_id=self.material_lot.id,
|
||
biz_time=self.now,
|
||
operator_user_id=1,
|
||
remark="生产出库",
|
||
created_at=self.now,
|
||
updated_at=self.now,
|
||
)
|
||
self.finished_lot = StockLot(
|
||
id=101,
|
||
lot_no="FGL20260611001",
|
||
parent_lot_id=self.material_lot.id,
|
||
lot_role="FINISHED_FROM_RAW_BATCH",
|
||
material_sub_batch_no=None,
|
||
item_id=self.product.id,
|
||
warehouse_id=self.finished_warehouse.id,
|
||
location_id=None,
|
||
source_doc_type="PRODUCTION_LEDGER_IN",
|
||
source_doc_id=self.ledger.id,
|
||
source_line_id=None,
|
||
source_material_lot_id=self.material_lot.id,
|
||
source_material_sub_batch_no=self.material_lot.lot_no,
|
||
source_material_summary=f"{self.material.item_name}:{self.material_lot.lot_no}",
|
||
inbound_qty=198,
|
||
inbound_weight_kg=99,
|
||
remaining_qty=198,
|
||
remaining_weight_kg=99,
|
||
locked_qty=0,
|
||
locked_weight_kg=0,
|
||
unit_cost=Decimal("3.3"),
|
||
production_date=self.now.date(),
|
||
expire_date=None,
|
||
quality_status="PASS",
|
||
status="AVAILABLE",
|
||
remark="生产台账入库;成品入库;来源库存批次已记录",
|
||
created_at=self.now,
|
||
updated_at=self.now,
|
||
)
|
||
self.finished_txn = InventoryTxn(
|
||
id=401,
|
||
txn_no="TXN-FG-001",
|
||
txn_type="FG_IN",
|
||
item_id=self.product.id,
|
||
warehouse_id=self.finished_warehouse.id,
|
||
location_id=None,
|
||
lot_id=self.finished_lot.id,
|
||
qty_change=198,
|
||
weight_change_kg=99,
|
||
unit_cost=Decimal("3.3"),
|
||
amount=Decimal("653.4"),
|
||
source_doc_type="生产台账",
|
||
source_doc_id=self.ledger.id,
|
||
source_line_id=self.finished_lot.id,
|
||
biz_time=self.now,
|
||
operator_user_id=1,
|
||
remark="生产台账入库;成品入库;偏差说明:称重复核通过",
|
||
created_at=self.now,
|
||
updated_at=self.now,
|
||
)
|
||
self.surplus_txn = InventoryTxn(
|
||
id=402,
|
||
txn_no="TXN-SURPLUS-001",
|
||
txn_type="PRODUCTION_SURPLUS_IN",
|
||
item_id=self.material.id,
|
||
warehouse_id=self.raw_warehouse.id,
|
||
location_id=None,
|
||
lot_id=self.material_lot.id,
|
||
qty_change=0,
|
||
weight_change_kg=60,
|
||
unit_cost=Decimal("6.6"),
|
||
amount=Decimal("396"),
|
||
source_doc_type="生产台账",
|
||
source_doc_id=self.ledger.id,
|
||
source_line_id=self.material_lot.id,
|
||
biz_time=self.now,
|
||
operator_user_id=1,
|
||
remark="生产台账入库;生产余料入库;偏差说明:称重复核通过",
|
||
created_at=self.now,
|
||
updated_at=self.now,
|
||
)
|
||
self.db.add_all(
|
||
[
|
||
self.raw_warehouse,
|
||
self.finished_warehouse,
|
||
self.scrap_warehouse,
|
||
self.material,
|
||
self.product,
|
||
self.material_lot,
|
||
self.ledger,
|
||
self.issue_ledger_txn,
|
||
self.issue_inventory_txn,
|
||
self.finished_lot,
|
||
self.finished_txn,
|
||
self.surplus_txn,
|
||
]
|
||
)
|
||
self.db.commit()
|
||
|
||
def test_collect_material_out_context_uses_production_ledger(self) -> None:
|
||
context = collect_production_material_out_archive_context(self.db, self.ledger.id)
|
||
|
||
self.assertEqual(context.document_type, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT)
|
||
self.assertEqual(context.business_id, self.ledger.id)
|
||
self.assertEqual(context.document_no, "生产领料-YL0001-测试钢制碗")
|
||
self.assertEqual(context.partner_label, "生产产品")
|
||
self.assertEqual(context.partner_name, "测试钢制碗")
|
||
self.assertEqual(context.address, "原材料库")
|
||
self.assertIn("材料库存批次号:YL0001", context.remark)
|
||
self.assertEqual(len(context.lines), 1)
|
||
self.assertEqual(context.lines[0].item_name, "测试冷轧板")
|
||
self.assertEqual(Decimal(str(context.lines[0].quantity)), Decimal("200"))
|
||
self.assertEqual(Decimal(str(context.lines[0].line_amount)), Decimal("1320.00"))
|
||
|
||
def test_collect_inbound_settlement_context_groups_same_save_action(self) -> None:
|
||
context = collect_production_inbound_settlement_archive_context(self.db, self.finished_txn.id)
|
||
|
||
self.assertEqual(context.document_type, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT)
|
||
self.assertEqual(context.business_id, self.finished_txn.id)
|
||
self.assertEqual(context.document_no, "生产入库结算-TXN-FG-001")
|
||
self.assertEqual(context.partner_label, "生产产品")
|
||
self.assertEqual(context.partner_name, "测试钢制碗")
|
||
self.assertIn("材料库存批次号:YL0001", context.remark)
|
||
self.assertEqual(len(context.lines), 2)
|
||
self.assertEqual([line.remark.split(";")[0] for line in context.lines], ["入库类型:成品入库", "入库类型:生产余料入库"])
|
||
|
||
def test_generate_production_material_out_archive_pdf(self) -> None:
|
||
with TemporaryDirectory() as tmp_dir, patch("app.services.document_archives.default_archive_root", return_value=tmp_dir):
|
||
result = generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, self.ledger.id, created_by=1)
|
||
|
||
self.assertEqual(result.status, ARCHIVE_STATUS_READY)
|
||
saved = self.db.scalar(select(DocumentArchive).where(DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT))
|
||
self.assertIsNotNone(saved)
|
||
self.assertEqual(saved.business_id, self.ledger.id)
|
||
self.assertEqual(saved.archive_version, 1)
|
||
|
||
def test_generate_production_inbound_settlement_archive_pdf(self) -> None:
|
||
with TemporaryDirectory() as tmp_dir, patch("app.services.document_archives.default_archive_root", return_value=tmp_dir):
|
||
result = generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, self.finished_txn.id, created_by=1)
|
||
|
||
self.assertEqual(result.status, ARCHIVE_STATUS_READY)
|
||
saved = self.db.scalar(select(DocumentArchive).where(DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT))
|
||
self.assertIsNotNone(saved)
|
||
self.assertEqual(saved.business_id, self.finished_txn.id)
|
||
self.assertEqual(saved.archive_version, 1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|
||
```
|
||
|
||
- [ ] **Step 2: Run the new test file and verify it fails for missing symbols**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
PYTHONPATH=backend backend/.venv/bin/pytest backend/tests/test_production_warehouse_document_archive.py -q
|
||
```
|
||
|
||
Expected: FAIL with import errors for `DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT`, `DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT`, `collect_production_inbound_settlement_archive_context`, and `collect_production_material_out_archive_context`.
|
||
|
||
- [ ] **Step 3: Extend document archive route key tests**
|
||
|
||
Modify `backend/tests/test_document_archive_routes.py::DocumentArchiveRouteTest.test_normalize_document_type_key_supports_route_keys` to include:
|
||
|
||
```python
|
||
self.assertEqual(normalize_document_type_key("production-material-out"), "生产领料出库单")
|
||
self.assertEqual(normalize_document_type_key("production-inbound-settlement"), "生产入库结算单")
|
||
```
|
||
|
||
- [ ] **Step 4: Run route tests and verify they fail**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
PYTHONPATH=backend backend/.venv/bin/pytest backend/tests/test_document_archive_routes.py::DocumentArchiveRouteTest::test_normalize_document_type_key_supports_route_keys -q
|
||
```
|
||
|
||
Expected: FAIL because the new document type keys are not mapped yet.
|
||
|
||
- [ ] **Step 5: Commit tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
git add backend/tests/test_production_warehouse_document_archive.py backend/tests/test_document_archive_routes.py
|
||
git commit -m "test: cover production warehouse document archives"
|
||
```
|
||
|
||
Expected: commit succeeds in a git worktree. If the workspace reports `fatal: not a git repository`, record that commit was skipped because this project copy has no `.git` directory.
|
||
|
||
---
|
||
|
||
### Task 2: Add Production Document Archive Contexts And Route Keys
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/services/document_archives.py`
|
||
- Modify: `backend/app/api/routes/document_archives.py`
|
||
- Test: `backend/tests/test_production_warehouse_document_archive.py`
|
||
- Test: `backend/tests/test_document_archive_routes.py`
|
||
|
||
- [ ] **Step 1: Add imports and constants in `document_archives.py`**
|
||
|
||
Modify imports:
|
||
|
||
```python
|
||
from app.models.operations import (
|
||
InventoryTxn,
|
||
ProductionBatchLedger,
|
||
ProductionBatchLedgerTxn,
|
||
PurchaseOrder,
|
||
PurchaseOrderItem,
|
||
PurchaseReceipt,
|
||
PurchaseReceiptItem,
|
||
StockLot,
|
||
Supplier,
|
||
)
|
||
```
|
||
|
||
Add constants below `DOCUMENT_TYPE_QUALITY_INSPECTION`:
|
||
|
||
```python
|
||
DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT = "生产领料出库单"
|
||
DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT = "生产入库结算单"
|
||
```
|
||
|
||
- [ ] **Step 2: Add production helper functions**
|
||
|
||
Add these helper functions below `collect_quality_inspection_archive_context`.
|
||
|
||
```python
|
||
PRODUCTION_INBOUND_TXN_LABELS = {
|
||
"FG_IN": "成品入库",
|
||
"PRODUCTION_SURPLUS_IN": "生产余料入库",
|
||
"PRODUCTION_SCRAP_IN": "生产废料入库",
|
||
}
|
||
|
||
|
||
def _production_document_no_part(value: str | None) -> str:
|
||
text = str(value or "").strip()
|
||
return text or "-"
|
||
|
||
|
||
def _production_amount(quantity_or_weight: Decimal | float | int | None, unit_cost: Decimal | float | int | None) -> Decimal:
|
||
return Decimal(str(quantity_or_weight or 0)) * Decimal(str(unit_cost or 0))
|
||
|
||
|
||
def _production_ledger_row(db: Session, production_ledger_id: int):
|
||
material_item = aliased(Item)
|
||
product_item = aliased(Item)
|
||
row = db.execute(
|
||
select(
|
||
ProductionBatchLedger,
|
||
StockLot,
|
||
material_item,
|
||
product_item,
|
||
Warehouse,
|
||
)
|
||
.join(StockLot, StockLot.id == ProductionBatchLedger.material_lot_id)
|
||
.join(material_item, material_item.id == ProductionBatchLedger.material_item_id)
|
||
.join(product_item, product_item.id == ProductionBatchLedger.product_item_id)
|
||
.join(Warehouse, Warehouse.id == StockLot.warehouse_id)
|
||
.where(ProductionBatchLedger.id == production_ledger_id)
|
||
).one_or_none()
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="生产台账不存在")
|
||
return row
|
||
```
|
||
|
||
Also add `aliased` to the SQLAlchemy import line:
|
||
|
||
```python
|
||
from sqlalchemy.orm import Session, aliased
|
||
```
|
||
|
||
- [ ] **Step 3: Add `collect_production_material_out_archive_context`**
|
||
|
||
Add:
|
||
|
||
```python
|
||
def collect_production_material_out_archive_context(db: Session, production_ledger_id: int) -> ArchiveContext:
|
||
ledger, material_lot, material, product, warehouse = _production_ledger_row(db, production_ledger_id)
|
||
issue_txns = db.scalars(
|
||
select(ProductionBatchLedgerTxn)
|
||
.where(
|
||
ProductionBatchLedgerTxn.production_ledger_id == production_ledger_id,
|
||
ProductionBatchLedgerTxn.txn_type == "生产出库",
|
||
)
|
||
.order_by(ProductionBatchLedgerTxn.biz_time.asc(), ProductionBatchLedgerTxn.id.asc())
|
||
).all()
|
||
issued_weight = Decimal(str(ledger.total_issued_weight_kg or 0))
|
||
unit_cost = Decimal(str(material_lot.unit_cost or 0))
|
||
amount = issued_weight * unit_cost
|
||
latest_issue_time = ledger.last_issue_time or ledger.updated_at
|
||
issue_count = len(issue_txns)
|
||
line_remark_parts = [
|
||
f"库存批次号:{ledger.material_lot_no}",
|
||
f"累计生产出库次数:{issue_count}",
|
||
f"库外未闭环重量:{decimal_text(ledger.outside_weight_kg, 3)}kg",
|
||
]
|
||
if ledger.remark:
|
||
line_remark_parts.append(str(ledger.remark))
|
||
|
||
return ArchiveContext(
|
||
document_type=DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
|
||
business_id=ledger.id,
|
||
document_no=f"生产领料-{_production_document_no_part(ledger.material_lot_no)}-{_production_document_no_part(product.item_name)}",
|
||
title="生产领料出库单归档",
|
||
partner_label="生产产品",
|
||
partner_name=product.item_name,
|
||
document_date=latest_issue_time,
|
||
due_date_label="生产台账",
|
||
due_date=ledger.id,
|
||
status=archive_status_text(ledger.status),
|
||
tax_rate=Decimal("0"),
|
||
total_amount=amount,
|
||
contact_name=None,
|
||
contact_phone=None,
|
||
address=warehouse.warehouse_name,
|
||
remark=f"生产台账号:{ledger.id};材料库存批次号:{ledger.material_lot_no};产品编码:{product.item_code}",
|
||
lines=[
|
||
ArchiveLine(
|
||
line_no=1,
|
||
item_code=material.item_code,
|
||
item_name=material.item_name,
|
||
specification=material.specification,
|
||
quantity=issued_weight,
|
||
delivered_or_received_quantity=ledger.outside_weight_kg,
|
||
unit_price=unit_cost,
|
||
line_amount=amount,
|
||
promised_or_expected_date=latest_issue_time,
|
||
remark=";".join(line_remark_parts),
|
||
)
|
||
],
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 4: Add settlement grouping and `collect_production_inbound_settlement_archive_context`**
|
||
|
||
Add:
|
||
|
||
```python
|
||
def _production_inbound_related_txns(db: Session, main_txn: InventoryTxn) -> list[InventoryTxn]:
|
||
source_doc_type = str(main_txn.source_doc_type or "")
|
||
source_doc_id = int(main_txn.source_doc_id or 0)
|
||
if source_doc_id <= 0:
|
||
return [main_txn]
|
||
rows = db.scalars(
|
||
select(InventoryTxn)
|
||
.where(
|
||
InventoryTxn.source_doc_type == source_doc_type,
|
||
InventoryTxn.source_doc_id == source_doc_id,
|
||
InventoryTxn.biz_time == main_txn.biz_time,
|
||
InventoryTxn.txn_type.in_(tuple(PRODUCTION_INBOUND_TXN_LABELS.keys())),
|
||
)
|
||
.order_by(
|
||
case(
|
||
(InventoryTxn.txn_type == "FG_IN", 1),
|
||
(InventoryTxn.txn_type == "PRODUCTION_SURPLUS_IN", 2),
|
||
(InventoryTxn.txn_type == "PRODUCTION_SCRAP_IN", 3),
|
||
else_=9,
|
||
),
|
||
InventoryTxn.id.asc(),
|
||
)
|
||
).all()
|
||
return rows or [main_txn]
|
||
|
||
|
||
def _production_ledger_from_inbound_txn(db: Session, txn: InventoryTxn) -> ProductionBatchLedger | None:
|
||
if str(txn.source_doc_type or "") == "生产台账" and txn.source_doc_id:
|
||
return db.get(ProductionBatchLedger, int(txn.source_doc_id))
|
||
linked = db.scalar(
|
||
select(ProductionBatchLedger)
|
||
.join(StockLot, StockLot.id == ProductionBatchLedger.material_lot_id)
|
||
.where(
|
||
or_(
|
||
StockLot.id == txn.lot_id,
|
||
StockLot.id == db.scalar(select(StockLot.source_material_lot_id).where(StockLot.id == txn.lot_id)),
|
||
)
|
||
)
|
||
.order_by(ProductionBatchLedger.updated_at.desc(), ProductionBatchLedger.id.desc())
|
||
.limit(1)
|
||
)
|
||
return linked
|
||
|
||
|
||
def collect_production_inbound_settlement_archive_context(db: Session, inventory_txn_id: int) -> ArchiveContext:
|
||
main_txn = db.get(InventoryTxn, inventory_txn_id)
|
||
if main_txn is None:
|
||
raise HTTPException(status_code=404, detail="生产入库库存流水不存在")
|
||
if main_txn.txn_type not in PRODUCTION_INBOUND_TXN_LABELS:
|
||
raise HTTPException(status_code=400, detail="该库存流水不是生产入库结算流水")
|
||
|
||
txns = _production_inbound_related_txns(db, main_txn)
|
||
ledger = _production_ledger_from_inbound_txn(db, main_txn)
|
||
if ledger:
|
||
ledger_row = _production_ledger_row(db, ledger.id)
|
||
ledger, material_lot, material, product, _warehouse = ledger_row
|
||
partner_name = product.item_name
|
||
remark_prefix = f"生产台账号:{ledger.id};材料库存批次号:{ledger.material_lot_no};产品编码:{product.item_code}"
|
||
status_text = archive_status_text(ledger.status)
|
||
else:
|
||
material_lot = db.get(StockLot, main_txn.lot_id) if main_txn.lot_id else None
|
||
item = db.get(Item, main_txn.item_id)
|
||
material = item
|
||
product = item
|
||
partner_name = item.item_name if item else "生产入库"
|
||
remark_prefix = f"来源:{main_txn.source_doc_type} #{main_txn.source_doc_id}"
|
||
status_text = "已入库"
|
||
|
||
lines: list[ArchiveLine] = []
|
||
total_amount = Decimal("0")
|
||
for index, txn in enumerate(txns, start=1):
|
||
item = db.get(Item, txn.item_id)
|
||
lot = db.get(StockLot, txn.lot_id) if txn.lot_id else None
|
||
warehouse = db.get(Warehouse, txn.warehouse_id)
|
||
txn_label = PRODUCTION_INBOUND_TXN_LABELS.get(txn.txn_type, archive_status_text(txn.txn_type))
|
||
qty_or_weight = txn.qty_change if txn.txn_type == "FG_IN" else txn.weight_change_kg
|
||
amount = Decimal(str(txn.amount or 0))
|
||
total_amount += amount
|
||
remark_parts = [
|
||
f"入库类型:{txn_label}",
|
||
f"仓库:{warehouse.warehouse_name if warehouse else '-'}",
|
||
f"库存批次号:{lot.lot_no if lot else '-'}",
|
||
]
|
||
if lot and lot.source_material_lot_id:
|
||
source_lot = db.get(StockLot, lot.source_material_lot_id)
|
||
if source_lot:
|
||
remark_parts.append(f"原材料库存批次号:{source_lot.lot_no}")
|
||
elif ledger:
|
||
remark_parts.append(f"原材料库存批次号:{ledger.material_lot_no}")
|
||
if txn.remark:
|
||
remark_parts.append(str(txn.remark))
|
||
lines.append(
|
||
ArchiveLine(
|
||
line_no=index,
|
||
item_code=item.item_code if item else "-",
|
||
item_name=item.item_name if item else "-",
|
||
specification=item.specification if item else None,
|
||
quantity=qty_or_weight,
|
||
delivered_or_received_quantity=txn.weight_change_kg,
|
||
unit_price=txn.unit_cost,
|
||
line_amount=amount,
|
||
promised_or_expected_date=txn.biz_time,
|
||
remark=";".join(remark_parts),
|
||
)
|
||
)
|
||
|
||
return ArchiveContext(
|
||
document_type=DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
|
||
business_id=main_txn.id,
|
||
document_no=f"生产入库结算-{main_txn.txn_no}",
|
||
title="生产入库结算单归档",
|
||
partner_label="生产产品",
|
||
partner_name=partner_name,
|
||
document_date=main_txn.biz_time,
|
||
due_date_label="主库存流水",
|
||
due_date=main_txn.txn_no,
|
||
status=status_text,
|
||
tax_rate=Decimal("0"),
|
||
total_amount=total_amount,
|
||
contact_name=None,
|
||
contact_phone=None,
|
||
address=material_lot.lot_no if material_lot else "",
|
||
remark=remark_prefix,
|
||
lines=lines,
|
||
)
|
||
```
|
||
|
||
Also add `case` and `or_` to the SQLAlchemy import line:
|
||
|
||
```python
|
||
from sqlalchemy import case, func, or_, select
|
||
```
|
||
|
||
- [ ] **Step 5: Register new collectors**
|
||
|
||
Modify `collect_archive_context`:
|
||
|
||
```python
|
||
if document_type == DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT:
|
||
return collect_production_material_out_archive_context(db, business_id)
|
||
if document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT:
|
||
return collect_production_inbound_settlement_archive_context(db, business_id)
|
||
```
|
||
|
||
- [ ] **Step 6: Add document route keys**
|
||
|
||
Modify `backend/app/api/routes/document_archives.py` imports:
|
||
|
||
```python
|
||
DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
|
||
DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
|
||
```
|
||
|
||
Add to `DOCUMENT_TYPE_KEY_MAP`:
|
||
|
||
```python
|
||
"production-material-out": DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
|
||
"production-inbound-settlement": DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
|
||
DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT: DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
|
||
DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT: DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
|
||
```
|
||
|
||
Update the validation error:
|
||
|
||
```python
|
||
raise HTTPException(status_code=400, detail="单据类型只能选择销售订单、采购订单、到货入库单、质量校验单、生产领料出库单或生产入库结算单")
|
||
```
|
||
|
||
- [ ] **Step 7: Run archive tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
PYTHONPATH=backend backend/.venv/bin/pytest backend/tests/test_production_warehouse_document_archive.py backend/tests/test_document_archive_routes.py::DocumentArchiveRouteTest::test_normalize_document_type_key_supports_route_keys -q
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 8: Run existing document archive tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
PYTHONPATH=backend backend/.venv/bin/pytest backend/tests/test_document_archive_service.py backend/tests/test_quality_inspection_document_archive.py backend/tests/test_document_archive_routes.py -q
|
||
```
|
||
|
||
Expected: PASS. This confirms sales, purchase, receipt, and quality archive paths still work.
|
||
|
||
- [ ] **Step 9: Commit backend archive service changes**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
git add backend/app/services/document_archives.py backend/app/api/routes/document_archives.py backend/tests/test_production_warehouse_document_archive.py backend/tests/test_document_archive_routes.py
|
||
git commit -m "feat: add production warehouse document archive types"
|
||
```
|
||
|
||
Expected: commit succeeds in a git worktree. If no `.git` exists, record the skipped commit.
|
||
|
||
---
|
||
|
||
### Task 3: Generate Archives From Production Save Points
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/schemas/operations.py`
|
||
- Modify: `backend/app/api/routes/production.py`
|
||
- Modify: `backend/app/services/production_work_order_inbound.py`
|
||
- Modify: `backend/app/api/routes/inventory.py`
|
||
- Test: `backend/tests/test_production_warehouse_document_archive.py`
|
||
|
||
- [ ] **Step 1: Add archive fields to response schemas**
|
||
|
||
In `backend/app/schemas/operations.py`, add these fields to `StockLotRead`, `ProductionWorkOrderInboundRead`, and `ProductionBatchLedgerInboundRead`:
|
||
|
||
```python
|
||
archive_status: str | None = None
|
||
archive_business_id: int | None = None
|
||
archive_document_type: str | None = None
|
||
archive_error_message: str | None = None
|
||
```
|
||
|
||
Add these fields to `ProductionBatchLedgerTxnRead`:
|
||
|
||
```python
|
||
archive_status: str | None = None
|
||
archive_business_id: int | None = None
|
||
archive_document_type: str | None = None
|
||
archive_error_message: str | None = None
|
||
```
|
||
|
||
Add these fields to `ProductionBatchLedgerRead`:
|
||
|
||
```python
|
||
material_out_archive_status: str | None = None
|
||
material_out_archive_business_id: int | None = None
|
||
material_out_archive_document_type: str | None = None
|
||
material_out_archive_error_message: str | None = None
|
||
```
|
||
|
||
- [ ] **Step 2: Add a small archive-result helper in `production.py`**
|
||
|
||
In `backend/app/api/routes/production.py`, import:
|
||
|
||
```python
|
||
from app.schemas.document_archives import DocumentArchiveGenerateResult
|
||
from app.services.document_archives import (
|
||
DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
|
||
generate_document_archive,
|
||
)
|
||
```
|
||
|
||
Add helper near route helpers:
|
||
|
||
```python
|
||
def _archive_fields_from_result(result: DocumentArchiveGenerateResult | None, *, document_type: str, business_id: int | None) -> dict[str, object]:
|
||
if result is None:
|
||
return {
|
||
"archive_status": "未生成",
|
||
"archive_business_id": business_id,
|
||
"archive_document_type": document_type,
|
||
"archive_error_message": None,
|
||
}
|
||
return {
|
||
"archive_status": result.status,
|
||
"archive_business_id": business_id,
|
||
"archive_document_type": document_type,
|
||
"archive_error_message": result.error_message,
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Generate settlement archive after `/production/production-ledger-inbounds`**
|
||
|
||
In `create_production_batch_ledger_inbound`, after `db.commit()` and before returning, determine the main transaction ID:
|
||
|
||
```python
|
||
main_txn_id = finished_txn_id or surplus_txn_id or scrap_txn_id
|
||
archive_result = None
|
||
if main_txn_id:
|
||
try:
|
||
archive_result = generate_document_archive(
|
||
db,
|
||
DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
|
||
int(main_txn_id),
|
||
created_by=context.user.id,
|
||
)
|
||
except Exception as exc:
|
||
archive_result = DocumentArchiveGenerateResult(
|
||
document_type=DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
|
||
business_id=int(main_txn_id),
|
||
document_no=f"库存流水#{main_txn_id}",
|
||
archive_version=0,
|
||
status="归档失败",
|
||
file_name="",
|
||
file_path="",
|
||
error_message=str(exc),
|
||
)
|
||
```
|
||
|
||
In the returned `ProductionBatchLedgerInboundRead`, include:
|
||
|
||
```python
|
||
**_archive_fields_from_result(
|
||
archive_result,
|
||
document_type=DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
|
||
business_id=int(main_txn_id) if main_txn_id else None,
|
||
)
|
||
```
|
||
|
||
To keep the current constructor style easy to review, replace the direct constructor return for `ProductionBatchLedgerInboundRead` with this explicit response object:
|
||
|
||
```python
|
||
response = ProductionBatchLedgerInboundRead(
|
||
production_ledger_id=ledger.id,
|
||
material_lot_no=ledger.material_lot_no,
|
||
finished_qty=float(finished_qty),
|
||
surplus_weight_kg=float(surplus_weight),
|
||
scrap_weight_kg=float(scrap_weight),
|
||
finished_lot_id=finished_lot_id,
|
||
surplus_lot_id=surplus_lot_id,
|
||
scrap_lot_id=scrap_lot_id,
|
||
finished_txn_id=finished_txn_id,
|
||
surplus_txn_id=surplus_txn_id,
|
||
scrap_txn_id=scrap_txn_id,
|
||
locked=bool(payload.lock_material_batch),
|
||
status=ledger.status,
|
||
remark=payload.remark,
|
||
)
|
||
for key, value in _archive_fields_from_result(
|
||
archive_result,
|
||
document_type=DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
|
||
business_id=int(main_txn_id) if main_txn_id else None,
|
||
).items():
|
||
setattr(response, key, value)
|
||
return response
|
||
```
|
||
|
||
- [ ] **Step 4: Generate settlement archive after old `/production/work-order-inbounds`**
|
||
|
||
In `backend/app/services/production_work_order_inbound.py`, import:
|
||
|
||
```python
|
||
from app.schemas.document_archives import DocumentArchiveGenerateResult
|
||
from app.services.document_archives import DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, generate_document_archive
|
||
```
|
||
|
||
After `db.commit()`, before returning, add:
|
||
|
||
```python
|
||
main_txn_id = finished_txn_id or surplus_txn_id or scrap_txn_id
|
||
archive_status = "未生成"
|
||
archive_error_message = None
|
||
if main_txn_id:
|
||
try:
|
||
archive_result = generate_document_archive(
|
||
db,
|
||
DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
|
||
int(main_txn_id),
|
||
created_by=operator_user_id,
|
||
)
|
||
archive_status = archive_result.status
|
||
archive_error_message = archive_result.error_message
|
||
except Exception as exc:
|
||
archive_status = "归档失败"
|
||
archive_error_message = str(exc)
|
||
```
|
||
|
||
Include these fields in `ProductionWorkOrderInboundRead`:
|
||
|
||
```python
|
||
archive_status=archive_status,
|
||
archive_business_id=int(main_txn_id) if main_txn_id else None,
|
||
archive_document_type=DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT if main_txn_id else None,
|
||
archive_error_message=archive_error_message,
|
||
```
|
||
|
||
- [ ] **Step 5: Generate material issue archive after raw material production outbound**
|
||
|
||
In `backend/app/api/routes/inventory.py`, import:
|
||
|
||
```python
|
||
from app.schemas.document_archives import DocumentArchiveGenerateResult
|
||
from app.services.document_archives import (
|
||
DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
|
||
DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
|
||
generate_document_archive,
|
||
)
|
||
```
|
||
|
||
In `create_warehouse_outbound`, track production ledger ID for production outbound. After the branch that posts production issue to ledger, store:
|
||
|
||
```python
|
||
production_material_out_archive_ledger_id = None
|
||
```
|
||
|
||
When `biz_type == "PRODUCTION_OUT"` creates or updates a `ProductionBatchLedger`, set:
|
||
|
||
```python
|
||
production_material_out_archive_ledger_id = ledger.id
|
||
```
|
||
|
||
After the existing `db.commit()`, before returning `StockLotRead`, generate:
|
||
|
||
```python
|
||
archive_status = None
|
||
archive_business_id = None
|
||
archive_document_type = None
|
||
archive_error_message = None
|
||
if production_material_out_archive_ledger_id:
|
||
archive_business_id = int(production_material_out_archive_ledger_id)
|
||
archive_document_type = DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT
|
||
try:
|
||
archive_result = generate_document_archive(
|
||
db,
|
||
DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
|
||
archive_business_id,
|
||
created_by=context.user.id,
|
||
)
|
||
archive_status = archive_result.status
|
||
archive_error_message = archive_result.error_message
|
||
except Exception as exc:
|
||
archive_status = "归档失败"
|
||
archive_error_message = str(exc)
|
||
```
|
||
|
||
When returning the `StockLotRead`, assign:
|
||
|
||
```python
|
||
response = StockLotRead.model_validate(dict(row))
|
||
response.archive_status = archive_status
|
||
response.archive_business_id = archive_business_id
|
||
response.archive_document_type = archive_document_type
|
||
response.archive_error_message = archive_error_message
|
||
return response
|
||
```
|
||
|
||
- [ ] **Step 6: Generate settlement archive after branch production inbound**
|
||
|
||
In `create_warehouse_inbound`, track the main inventory transaction ID for these `biz_type` values:
|
||
|
||
```python
|
||
PRODUCTION_ARCHIVE_INBOUND_BIZ_TYPES = {"PRODUCTION_FINISHED", "PRODUCTION_SURPLUS", "PRODUCTION_SCRAP_IN"}
|
||
```
|
||
|
||
For the source-lot return branch, after the existing assignment to `return_txn` from `create_inventory_txn`, set:
|
||
|
||
```python
|
||
production_inbound_archive_main_txn_id = return_txn.id if biz_type in PRODUCTION_ARCHIVE_INBOUND_BIZ_TYPES else None
|
||
```
|
||
|
||
For the non-return branches, capture the `InventoryTxn` created for finished production inbound and production scrap inbound. Use the same priority rule:
|
||
|
||
```python
|
||
production_inbound_archive_main_txn_id = finished_txn.id or surplus_txn.id or scrap_txn.id
|
||
```
|
||
|
||
After `db.commit()`, generate `DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT` with `production_inbound_archive_main_txn_id`, catch exceptions, and set the same four archive fields on `StockLotRead`.
|
||
|
||
- [ ] **Step 7: Add integration tests for archive generation not rolling back business data**
|
||
|
||
Append to `backend/tests/test_production_warehouse_document_archive.py`:
|
||
|
||
```python
|
||
def test_archive_generation_failure_keeps_existing_business_rows(self) -> None:
|
||
with patch("app.services.document_archives.render_pdf_archive", side_effect=RuntimeError("PDF服务异常")):
|
||
result = generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, self.finished_txn.id, created_by=1)
|
||
|
||
self.assertEqual(result.status, "归档失败")
|
||
self.assertIsNotNone(self.db.get(InventoryTxn, self.finished_txn.id))
|
||
saved = self.db.scalar(select(DocumentArchive).where(DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT))
|
||
self.assertIsNotNone(saved)
|
||
self.assertEqual(saved.status, "归档失败")
|
||
```
|
||
|
||
- [ ] **Step 8: Run focused backend tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
PYTHONPATH=backend backend/.venv/bin/pytest backend/tests/test_production_warehouse_document_archive.py backend/tests/test_production_batch_ledger.py backend/tests/test_production_work_order_inbound.py -q
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 9: Run production inventory regression tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
PYTHONPATH=backend backend/.venv/bin/pytest backend/tests/test_selected_stock_lot_production_issue.py backend/tests/test_production_work_order_ledger.py backend/tests/test_scrap_warehouse_flow.py -q
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 10: Commit production save-point archive generation**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
git add backend/app/schemas/operations.py backend/app/api/routes/production.py backend/app/services/production_work_order_inbound.py backend/app/api/routes/inventory.py backend/tests/test_production_warehouse_document_archive.py
|
||
git commit -m "feat: archive production warehouse save points"
|
||
```
|
||
|
||
Expected: commit succeeds in a git worktree. If no `.git` exists, record the skipped commit.
|
||
|
||
---
|
||
|
||
### Task 4: Expose Archive Status In Production Ledger API
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/api/routes/production.py`
|
||
- Test: `backend/tests/test_production_warehouse_document_archive.py`
|
||
|
||
- [ ] **Step 1: Import archive model and status constants**
|
||
|
||
In `backend/app/api/routes/production.py`, import:
|
||
|
||
```python
|
||
from app.models.document_archive import DocumentArchive
|
||
from app.services.document_archives import ARCHIVE_STATUS_MISSING, FILE_FORMAT_PDF
|
||
```
|
||
|
||
Keep the production document type imports from Task 3.
|
||
|
||
- [ ] **Step 2: Add helper to get latest archive rows by business IDs**
|
||
|
||
Add near route helpers:
|
||
|
||
```python
|
||
def _latest_archive_map(db: Session, document_type: str, business_ids: list[int]) -> dict[int, DocumentArchive]:
|
||
if not business_ids:
|
||
return {}
|
||
latest_version_subquery = (
|
||
select(
|
||
DocumentArchive.business_id.label("business_id"),
|
||
func.max(DocumentArchive.archive_version).label("archive_version"),
|
||
)
|
||
.where(
|
||
DocumentArchive.document_type == document_type,
|
||
DocumentArchive.file_format == FILE_FORMAT_PDF,
|
||
DocumentArchive.business_id.in_(business_ids),
|
||
)
|
||
.group_by(DocumentArchive.business_id)
|
||
.subquery()
|
||
)
|
||
latest_id_subquery = (
|
||
select(
|
||
DocumentArchive.business_id.label("business_id"),
|
||
func.max(DocumentArchive.id).label("archive_id"),
|
||
)
|
||
.join(
|
||
latest_version_subquery,
|
||
(latest_version_subquery.c.business_id == DocumentArchive.business_id)
|
||
& (latest_version_subquery.c.archive_version == DocumentArchive.archive_version),
|
||
)
|
||
.where(
|
||
DocumentArchive.document_type == document_type,
|
||
DocumentArchive.file_format == FILE_FORMAT_PDF,
|
||
)
|
||
.group_by(DocumentArchive.business_id)
|
||
.subquery()
|
||
)
|
||
rows = db.scalars(
|
||
select(DocumentArchive).join(latest_id_subquery, latest_id_subquery.c.archive_id == DocumentArchive.id)
|
||
).all()
|
||
return {int(row.business_id): row for row in rows}
|
||
```
|
||
|
||
- [ ] **Step 3: Populate production ledger archive fields**
|
||
|
||
Inside `list_production_batch_ledger`, after `ledger_ids` is built:
|
||
|
||
```python
|
||
material_out_archives = _latest_archive_map(db, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, ledger_ids)
|
||
```
|
||
|
||
After `txn_rows` is loaded, collect settlement business IDs:
|
||
|
||
```python
|
||
settlement_business_ids = [
|
||
int(txn.source_doc_id)
|
||
for txn in txn_rows
|
||
if txn.source_doc_type == "库存流水" and txn.source_doc_id and txn.txn_type in {"成品入库", "生产余料入库", "生产废料入库"}
|
||
]
|
||
settlement_archives = _latest_archive_map(db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, settlement_business_ids)
|
||
```
|
||
|
||
If current `ProductionBatchLedgerTxn.txn_type` values for inbound rows are exactly `成品入库`, `生产余料入库`, `生产废料入库`, keep the set above. If the code uses `post_finished_inbound_to_ledger`, `post_surplus_inbound_to_ledger`, and `post_scrap_inbound_to_ledger` with these names, do not change them.
|
||
|
||
- [ ] **Step 4: Extend `_production_ledger_txn_read`**
|
||
|
||
Modify `_production_ledger_txn_read(txn)` to compute archive fields:
|
||
|
||
```python
|
||
def _production_ledger_txn_read(txn: ProductionBatchLedgerTxn, archive: DocumentArchive | None = None) -> dict:
|
||
archive_business_id = int(txn.source_doc_id) if txn.source_doc_type == "库存流水" and txn.source_doc_id else None
|
||
return {
|
||
"production_ledger_txn_id": txn.id,
|
||
"production_ledger_id": txn.production_ledger_id,
|
||
"txn_type": txn.txn_type,
|
||
"qty_delta": float(to_decimal(txn.qty_delta)),
|
||
"weight_delta_kg": float(to_decimal(txn.weight_delta_kg)),
|
||
"outside_weight_after_kg": float(to_decimal(txn.outside_weight_after_kg)),
|
||
"source_doc_type": txn.source_doc_type,
|
||
"source_doc_id": txn.source_doc_id,
|
||
"source_line_id": txn.source_line_id,
|
||
"biz_time": txn.biz_time,
|
||
"operator_user_id": txn.operator_user_id,
|
||
"remark": txn.remark,
|
||
"archive_status": archive.status if archive else (ARCHIVE_STATUS_MISSING if archive_business_id else None),
|
||
"archive_business_id": archive_business_id,
|
||
"archive_document_type": DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT if archive_business_id else None,
|
||
"archive_error_message": archive.error_message if archive else None,
|
||
}
|
||
```
|
||
|
||
Update all call sites to pass the archive:
|
||
|
||
```python
|
||
txns=[
|
||
_production_ledger_txn_read(
|
||
txn,
|
||
settlement_archives.get(int(txn.source_doc_id)) if txn.source_doc_type == "库存流水" and txn.source_doc_id else None,
|
||
)
|
||
for txn in txns_by_ledger_id.get(ledger.id, [])
|
||
]
|
||
```
|
||
|
||
- [ ] **Step 5: Extend `ProductionBatchLedgerRead` construction**
|
||
|
||
When appending `ProductionBatchLedgerRead`, get:
|
||
|
||
```python
|
||
material_archive = material_out_archives.get(int(ledger.id))
|
||
```
|
||
|
||
Add fields:
|
||
|
||
```python
|
||
material_out_archive_status=material_archive.status if material_archive else ARCHIVE_STATUS_MISSING,
|
||
material_out_archive_business_id=ledger.id,
|
||
material_out_archive_document_type=DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
|
||
material_out_archive_error_message=material_archive.error_message if material_archive else None,
|
||
```
|
||
|
||
- [ ] **Step 6: Add a production ledger API test**
|
||
|
||
Add to `backend/tests/test_production_warehouse_document_archive.py` a direct helper-level assertion if full FastAPI setup is too heavy:
|
||
|
||
```python
|
||
def test_latest_archive_rows_can_be_joined_by_business_id(self) -> None:
|
||
with TemporaryDirectory() as tmp_dir, patch("app.services.document_archives.default_archive_root", return_value=tmp_dir):
|
||
generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, self.ledger.id, created_by=1)
|
||
generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, self.finished_txn.id, created_by=1)
|
||
|
||
material_archive = self.db.scalar(select(DocumentArchive).where(DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT))
|
||
settlement_archive = self.db.scalar(select(DocumentArchive).where(DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT))
|
||
self.assertEqual(material_archive.business_id, self.ledger.id)
|
||
self.assertEqual(settlement_archive.business_id, self.finished_txn.id)
|
||
```
|
||
|
||
The execution agent should also run the full production API tests after implementation.
|
||
|
||
- [ ] **Step 7: Run production API and archive tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
PYTHONPATH=backend backend/.venv/bin/pytest backend/tests/test_production_warehouse_document_archive.py backend/tests/test_production_batch_ledger.py -q
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 8: Commit production ledger archive status API**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
git add backend/app/api/routes/production.py backend/app/schemas/operations.py backend/tests/test_production_warehouse_document_archive.py
|
||
git commit -m "feat: expose production archive status"
|
||
```
|
||
|
||
Expected: commit succeeds in a git worktree. If no `.git` exists, record the skipped commit.
|
||
|
||
---
|
||
|
||
### Task 5: Frontend Archive Actions For Production Ledger And Warehouse Feedback
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/views/ProductionLedgerView.vue`
|
||
- Modify: `frontend/src/views/InventoryLedgerView.vue`
|
||
- Create: `frontend/src/views/ProductionLedgerView.test.js`
|
||
|
||
- [ ] **Step 1: Add static frontend test for production ledger archive UI**
|
||
|
||
Create `frontend/src/views/ProductionLedgerView.test.js`:
|
||
|
||
```javascript
|
||
import assert from "node:assert/strict";
|
||
import { describe, it } from "node:test";
|
||
import { readFileSync } from "node:fs";
|
||
import { fileURLToPath } from "node:url";
|
||
import { dirname, join } from "node:path";
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
const source = readFileSync(join(__dirname, "ProductionLedgerView.vue"), "utf8");
|
||
|
||
describe("ProductionLedgerView archive actions", () => {
|
||
it("uses DocumentArchiveActions for material out and inbound settlement archives", () => {
|
||
assert.match(source, /DocumentArchiveActions/);
|
||
assert.match(source, /production-material-out/);
|
||
assert.match(source, /production-inbound-settlement/);
|
||
});
|
||
|
||
it("has preview, download and regenerate archive handlers", () => {
|
||
assert.match(source, /previewDocumentArchive/);
|
||
assert.match(source, /downloadDocumentArchive/);
|
||
assert.match(source, /regenerateDocumentArchive/);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run the frontend test and verify it fails**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
node --test frontend/src/views/ProductionLedgerView.test.js
|
||
```
|
||
|
||
Expected: FAIL because the archive component and route keys are not yet used in `ProductionLedgerView.vue`.
|
||
|
||
- [ ] **Step 3: Import archive component and API helpers**
|
||
|
||
In `frontend/src/views/ProductionLedgerView.vue`, add:
|
||
|
||
```javascript
|
||
import DocumentArchiveActions from "../components/documentForms/DocumentArchiveActions.vue";
|
||
import { downloadResource, fetchResource, openResource, postResource } from "../services/api";
|
||
```
|
||
|
||
Replace the current import:
|
||
|
||
```javascript
|
||
import { fetchResource, postResource } from "../services/api";
|
||
```
|
||
|
||
with the combined import above.
|
||
|
||
- [ ] **Step 4: Render material issue archive actions in the detail header**
|
||
|
||
Inside the first detail drawer, below the `drawer-row-detail-grid`, add:
|
||
|
||
```vue
|
||
<div v-if="activeLedger" class="production-archive-panel">
|
||
<div>
|
||
<span>生产领料出库单</span>
|
||
<strong>{{ activeLedger.material_lot_no || "-" }}</strong>
|
||
</div>
|
||
<DocumentArchiveActions
|
||
:status="activeLedger.material_out_archive_status"
|
||
@preview="previewDocumentArchive('production-material-out', activeLedger.material_out_archive_business_id || activeLedger.production_ledger_id)"
|
||
@download="downloadDocumentArchive('production-material-out', activeLedger.material_out_archive_business_id || activeLedger.production_ledger_id, `${activeLedger.material_lot_no || '生产领料出库单'}-${activeLedger.product_name || '产品'}归档.pdf`)"
|
||
@regenerate="regenerateDocumentArchive('production-material-out', activeLedger.material_out_archive_business_id || activeLedger.production_ledger_id)"
|
||
/>
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 5: Render inbound settlement archive actions in transaction detail**
|
||
|
||
Inside the `DrawerDataTable` `#detail` slot, after the existing `drawer-row-detail-grid`, add:
|
||
|
||
```vue
|
||
<div v-if="row.archive_business_id" class="production-archive-panel production-archive-panel-inline">
|
||
<div>
|
||
<span>生产入库结算单</span>
|
||
<strong>{{ row.txn_type || "-" }}</strong>
|
||
</div>
|
||
<DocumentArchiveActions
|
||
:status="row.archive_status"
|
||
@preview="previewDocumentArchive('production-inbound-settlement', row.archive_business_id)"
|
||
@download="downloadDocumentArchive('production-inbound-settlement', row.archive_business_id, `${activeLedger?.material_lot_no || '生产入库结算单'}-${row.txn_type || row.production_ledger_txn_id}归档.pdf`)"
|
||
@regenerate="regenerateDocumentArchive('production-inbound-settlement', row.archive_business_id)"
|
||
/>
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 6: Add handlers**
|
||
|
||
In `ProductionLedgerView.vue` script, add:
|
||
|
||
```javascript
|
||
async function previewDocumentArchive(typeKey, businessId) {
|
||
feedbackMessage.value = "";
|
||
errorMessage.value = "";
|
||
if (!businessId) {
|
||
errorMessage.value = "缺少归档业务ID,无法预览";
|
||
return;
|
||
}
|
||
try {
|
||
await openResource(`/document-archives/${typeKey}/${businessId}/latest/preview`);
|
||
} catch (error) {
|
||
errorMessage.value = error.message || "PDF归档预览失败";
|
||
}
|
||
}
|
||
|
||
async function downloadDocumentArchive(typeKey, businessId, fallbackFilename) {
|
||
feedbackMessage.value = "";
|
||
errorMessage.value = "";
|
||
if (!businessId) {
|
||
errorMessage.value = "缺少归档业务ID,无法下载";
|
||
return;
|
||
}
|
||
try {
|
||
await downloadResource(`/document-archives/${typeKey}/${businessId}/latest/download`, fallbackFilename);
|
||
feedbackMessage.value = "PDF归档已下载";
|
||
} catch (error) {
|
||
errorMessage.value = error.message || "PDF归档下载失败";
|
||
}
|
||
}
|
||
|
||
async function regenerateDocumentArchive(typeKey, businessId) {
|
||
feedbackMessage.value = "";
|
||
errorMessage.value = "";
|
||
if (!businessId) {
|
||
errorMessage.value = "缺少归档业务ID,无法重新生成";
|
||
return;
|
||
}
|
||
try {
|
||
const result = await postResource(`/document-archives/${typeKey}/${businessId}/generate`, {});
|
||
feedbackMessage.value = result.message || "PDF归档已重新生成";
|
||
await loadAll();
|
||
} catch (error) {
|
||
errorMessage.value = error.message || "PDF归档重新生成失败";
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Add production archive styles**
|
||
|
||
Add scoped styles to `ProductionLedgerView.vue`:
|
||
|
||
```css
|
||
.production-archive-panel {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 14px;
|
||
margin: 14px 0;
|
||
padding: 12px 14px;
|
||
border: 1px solid rgba(59, 130, 246, 0.18);
|
||
border-radius: 16px;
|
||
background: linear-gradient(135deg, rgba(239, 246, 255, 0.96), rgba(255, 255, 255, 0.9));
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||
}
|
||
|
||
.production-archive-panel > div:first-child {
|
||
display: grid;
|
||
gap: 4px;
|
||
}
|
||
|
||
.production-archive-panel span {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.production-archive-panel strong {
|
||
color: var(--ink);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.production-archive-panel-inline {
|
||
margin-top: 12px;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Update warehouse feedback messages**
|
||
|
||
In `InventoryLedgerView.vue`, add helper:
|
||
|
||
```javascript
|
||
function formatArchiveFeedback(result) {
|
||
if (!result?.archive_status) {
|
||
return "";
|
||
}
|
||
if (result.archive_status === "已归档") {
|
||
return ",PDF归档已生成";
|
||
}
|
||
if (result.archive_status === "归档失败") {
|
||
return `,PDF归档失败${result.archive_error_message ? `:${result.archive_error_message}` : ""}`;
|
||
}
|
||
return `,PDF归档状态:${result.archive_status}`;
|
||
}
|
||
```
|
||
|
||
Update `submitProductionWorkOrderInbound` success message:
|
||
|
||
```javascript
|
||
feedbackMessage.value = `生产台账入库 ${result.material_lot_no || ""} 已保存${formatArchiveFeedback(result)}`;
|
||
```
|
||
|
||
Update `submitGenericOperation` success messages for production branch operations so the saved result appends `formatArchiveFeedback(result)`. For inbound and outbound requests currently calling `postResource` without keeping the response, keep the payload unchanged and store the response:
|
||
|
||
```javascript
|
||
const result = await postResource("/inventory/inbound", payload);
|
||
feedbackMessage.value = `${activeOperation.value?.label || "入库"}已保存${formatArchiveFeedback(result)}`;
|
||
```
|
||
|
||
and:
|
||
|
||
```javascript
|
||
const result = await postResource("/inventory/outbound", payload);
|
||
feedbackMessage.value = `${activeOperation.value?.label || "出库"}已保存${formatArchiveFeedback(result)}`;
|
||
```
|
||
|
||
Do not change validation or payload fields.
|
||
|
||
- [ ] **Step 9: Run frontend static test**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
node --test frontend/src/views/ProductionLedgerView.test.js
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 10: Run frontend build**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd frontend
|
||
npm run build
|
||
```
|
||
|
||
Expected: PASS. A Vite chunk-size warning is acceptable.
|
||
|
||
- [ ] **Step 11: Commit frontend archive UI**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
git add frontend/src/views/ProductionLedgerView.vue frontend/src/views/InventoryLedgerView.vue frontend/src/views/ProductionLedgerView.test.js
|
||
git commit -m "feat: show production warehouse archive actions"
|
||
```
|
||
|
||
Expected: commit succeeds in a git worktree. If no `.git` exists, record the skipped commit.
|
||
|
||
---
|
||
|
||
### Task 6: Full Verification And Manual Browser Check
|
||
|
||
**Files:**
|
||
- No code changes expected.
|
||
|
||
- [ ] **Step 1: Run full focused backend verification**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
PYTHONPATH=backend backend/.venv/bin/pytest \
|
||
backend/tests/test_document_archive_service.py \
|
||
backend/tests/test_quality_inspection_document_archive.py \
|
||
backend/tests/test_document_archive_routes.py \
|
||
backend/tests/test_production_warehouse_document_archive.py \
|
||
backend/tests/test_production_batch_ledger.py \
|
||
backend/tests/test_production_work_order_inbound.py \
|
||
backend/tests/test_selected_stock_lot_production_issue.py \
|
||
backend/tests/test_production_work_order_ledger.py \
|
||
backend/tests/test_scrap_warehouse_flow.py \
|
||
-q
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 2: Run frontend checks**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
node --test frontend/src/views/ProductionLedgerView.test.js
|
||
cd frontend
|
||
npm run build
|
||
```
|
||
|
||
Expected: static test PASS and build PASS. Vite chunk-size warning is acceptable.
|
||
|
||
- [ ] **Step 3: Browser verify production ledger archive actions**
|
||
|
||
Use the in-app Browser and navigate to:
|
||
|
||
```text
|
||
http://127.0.0.1:5173/production-ledger
|
||
```
|
||
|
||
Expected:
|
||
|
||
- Production ledger list loads.
|
||
- Clicking a material lot opens the detail drawer.
|
||
- Detail drawer shows `生产领料出库单` archive actions.
|
||
- Transaction detail rows that have inventory source IDs show `生产入库结算单` archive actions.
|
||
- If old rows show `未生成`, clicking `重新生成` creates the PDF or shows a Chinese error.
|
||
|
||
- [ ] **Step 4: Browser verify warehouse feedback**
|
||
|
||
Use the in-app Browser and navigate to:
|
||
|
||
```text
|
||
http://127.0.0.1:5173/inventory-ledger
|
||
```
|
||
|
||
Expected:
|
||
|
||
- Production ledger inbound save success message includes PDF archive result when backend returns it.
|
||
- Production branch inbound/outbound save success message includes PDF archive result when backend returns it.
|
||
- Existing validations and payload behavior remain unchanged.
|
||
|
||
- [ ] **Step 5: Inspect generated PDF manually**
|
||
|
||
Preview one generated `生产领料出库单` and one generated `生产入库结算单`.
|
||
|
||
Expected:
|
||
|
||
- PDF title is Chinese.
|
||
- Status values are Chinese.
|
||
- Long material lot numbers and remarks wrap instead of truncating.
|
||
- No ellipsis is used in PDF cell content.
|
||
- PDF includes material lot number, product, quantities/weights, unit cost, amount, and remarks.
|
||
|
||
- [ ] **Step 6: Final status report**
|
||
|
||
Report:
|
||
|
||
- Backend tests run and pass/fail count.
|
||
- Frontend tests/build run and pass/fail count.
|
||
- Browser verification result.
|
||
- Any Vite warnings.
|
||
- Whether commits were created or skipped because this workspace has no `.git`.
|
||
|
||
---
|
||
|
||
## Self-Review Checklist
|
||
|
||
- Spec coverage:
|
||
- Production material issue is covered by `生产领料出库单`.
|
||
- Production ledger inbound and branch inbound are covered by `生产入库结算单`.
|
||
- PDF failure does not roll back business save; archive failure is returned in response fields.
|
||
- History rows show `未生成` and can regenerate through existing document archive routes.
|
||
- Batch download remains out of scope.
|
||
- Placeholder scan:
|
||
- No implementation step uses placeholder tokens or unresolved file names.
|
||
- Commands and expected results are explicit.
|
||
- Type consistency:
|
||
- Route keys are `production-material-out` and `production-inbound-settlement`.
|
||
- Document types are `生产领料出库单` and `生产入库结算单`.
|
||
- Archive response fields are consistently named `archive_status`, `archive_business_id`, `archive_document_type`, and `archive_error_message`.
|