ForgeFlow-ERP/docs/superpowers/plans/2026-06-11-all-warehouse-document-archive.md
2026-06-12 16:00:56 +08:00

1698 lines
52 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 全仓库出入库单据纸质化、PDF归档与流水导出 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:** 让嘉恒仓库 6 大库所有出入库填单都采用正式纸质单据风格,保存后生成 PDF 留档,并支持在各库流水中预览、下载、重新生成 PDF以及按当前筛选时间范围导出流水 Excel 清单。
**Architecture:** 采用“双轨归档”:生产主链路继续使用 `生产领料出库单`、`生产入库结算单`,其它仓库出入库统一使用新增 `仓库出入库单`。后端以库存流水 `wh_inventory_txn` 为核心收集归档上下文和导出 Excel前端抽取通用纸质单据底座在仓库流水抽屉复用现有 `DocumentArchiveActions`
**Tech Stack:** FastAPI, SQLAlchemy, Pydantic, ReportLab, openpyxl, Vue 3, Vite, Node test runner.
---
## 设计文件
- Spec: `docs/superpowers/specs/2026-06-11-all-warehouse-document-archive-design.md`
- Related spec: `docs/superpowers/specs/2026-06-11-production-warehouse-document-archive-design.md`
- Related plan: `docs/superpowers/plans/2026-06-11-production-warehouse-document-archive.md`
- Related plan: `docs/superpowers/plans/2026-06-11-production-inbound-archive-entrypoints.md`
## File Structure
### Backend
- Modify: `backend/app/services/document_archives.py`
- Add `DOCUMENT_TYPE_WAREHOUSE_OPERATION = "仓库出入库单"`.
- Add generic warehouse archive context collector.
- Route generic warehouse archive dispatch through `collect_archive_context`.
- Keep production-specific archive logic unchanged.
- Modify: `backend/app/api/routes/document_archives.py`
- Add route key `warehouse-operation`.
- Include `仓库出入库单` in normalized key validation.
- Modify: `backend/app/services/operations.py`
- Extend `get_inventory_txn_ledger_query()` to return archive fields.
- Resolve archive type/business ID per row:
- production material out -> `生产领料出库单`, `source_doc_id`
- production inbound settlement -> `生产入库结算单`, `inventory_txn_id`
- other warehouse operations -> `仓库出入库单`, `inventory_txn_id`
- Modify: `backend/app/schemas/operations.py`
- Add archive fields to `InventoryTxnLedgerRowRead`.
- Modify: `backend/app/api/routes/inventory.py`
- Add `/inventory/transaction-ledger/export`.
- Reuse `get_inventory_txn_ledger_query()` filters.
- Generate Chinese-header `.xlsx` with full values.
- Ensure export is based on current filters, especially start/end date.
- Create: `backend/tests/test_warehouse_operation_document_archive.py`
- Test generic warehouse archive context and PDF generation.
- Modify: `backend/tests/test_document_archive_routes.py`
- Test `warehouse-operation` route key.
- Create: `backend/tests/test_inventory_ledger_export.py`
- Test ledger archive fields and Excel export filters.
### Frontend
- Create: `frontend/src/components/documentForms/WarehouseDocumentFormShell.vue`
- Shared paper-form shell for warehouse in/out forms.
- Create: `frontend/src/components/documentForms/WarehouseDocumentSection.vue`
- Reusable section block for paper-form grouped fields.
- Create: `frontend/src/components/documentForms/WarehouseDocumentActionBar.vue`
- Fixed bottom action area: save, save status, archive shortcuts.
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- Import and use paper-form shell.
- Keep `原材料库 · 客料入库` as first migrated sample and move it onto reusable shell.
- Add `DocumentArchiveActions` to warehouse ledger table.
- Add `导出Excel` button beside `查询`.
- Add export loading state to prevent repeated clicks.
- Add preview/download/regenerate handlers for `warehouse-operation`, `production-material-out`, and `production-inbound-settlement`.
- Modify: `frontend/src/styles/main.css`
- Add paper-form shared styles only if component scoped styles are insufficient.
- Add warehouse ledger archive/export button styles.
- Modify: `frontend/src/views/InventoryLedgerView.test.js`
- Add static tests for warehouse archive actions, Excel export button, and route keys.
---
## Task 1: Add Warehouse Operation Archive Type
**Files:**
- Modify: `backend/app/services/document_archives.py`
- Modify: `backend/app/api/routes/document_archives.py`
- Modify: `backend/tests/test_document_archive_routes.py`
- [ ] **Step 1: Write failing route-key test**
Add this assertion to `test_normalize_document_type_key_supports_route_keys` in `backend/tests/test_document_archive_routes.py`:
```python
self.assertEqual(normalize_document_type_key("warehouse-operation"), "仓库出入库单")
```
Also add a Chinese direct type assertion:
```python
self.assertEqual(normalize_document_type_key("仓库出入库单"), "仓库出入库单")
```
- [ ] **Step 2: Run route test and verify failure**
Run:
```bash
cd backend
python -m pytest tests/test_document_archive_routes.py::DocumentArchiveRouteTest::test_normalize_document_type_key_supports_route_keys -q
```
Expected: FAIL because `warehouse-operation` is not in `DOCUMENT_TYPE_KEY_MAP`.
- [ ] **Step 3: Add archive type constant**
In `backend/app/services/document_archives.py`, add the constant next to existing `DOCUMENT_TYPE_*` constants:
```python
DOCUMENT_TYPE_WAREHOUSE_OPERATION = "仓库出入库单"
```
- [ ] **Step 4: Add route map entry**
In `backend/app/api/routes/document_archives.py`, import the new constant:
```python
DOCUMENT_TYPE_WAREHOUSE_OPERATION,
```
Add these entries to `DOCUMENT_TYPE_KEY_MAP`:
```python
"warehouse-operation": DOCUMENT_TYPE_WAREHOUSE_OPERATION,
DOCUMENT_TYPE_WAREHOUSE_OPERATION: DOCUMENT_TYPE_WAREHOUSE_OPERATION,
```
Update the validation message in `normalize_document_type_key()` so it includes `仓库出入库单`:
```python
raise HTTPException(
status_code=400,
detail="单据类型只能选择销售订单、采购订单、到货入库单、质量校验单、生产领料出库单、生产入库结算单或仓库出入库单",
)
```
- [ ] **Step 5: Run route test and verify pass**
Run:
```bash
cd backend
python -m pytest tests/test_document_archive_routes.py -q
```
Expected: PASS.
---
## Task 2: Build Generic Warehouse Archive Context
**Files:**
- Create: `backend/tests/test_warehouse_operation_document_archive.py`
- Modify: `backend/app/services/document_archives.py`
- [ ] **Step 1: Write failing service test**
Create `backend/tests/test_warehouse_operation_document_archive.py` with this structure:
```python
from __future__ import annotations
import unittest
from decimal import Decimal
from tempfile import TemporaryDirectory
from unittest.mock import patch
from sqlalchemy import BigInteger, create_engine
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
import app.models.document_archive # noqa: F401
import app.models.master_data # noqa: F401
import app.models.miniapp # noqa: F401
import app.models.operations # noqa: F401
import app.models.org # noqa: F401
import app.models.planning # noqa: F401
import app.models.sales # noqa: F401
from app.models.base import Base
from app.models.master_data import Item, Warehouse, WarehouseLocation
from app.models.operations import InventoryTxn, StockLot
from app.services.document_archives import (
ARCHIVE_STATUS_READY,
DOCUMENT_TYPE_WAREHOUSE_OPERATION,
collect_warehouse_operation_archive_context,
generate_document_archive,
)
@compiles(BigInteger, "sqlite")
def _compile_big_integer_for_sqlite(type_, compiler, **kw) -> str:
return "INTEGER"
class WarehouseOperationDocumentArchiveTest(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
future=True,
)
Base.metadata.create_all(engine)
self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
self.db = self.SessionLocal()
def tearDown(self) -> None:
self.db.close()
def _seed_customer_supplied_txn(self) -> InventoryTxn:
warehouse = Warehouse(warehouse_code="RAW", warehouse_name="原材料库", warehouse_type="RAW")
location = WarehouseLocation(location_code="RAW-A", location_name="原材料暂存位", warehouse=warehouse)
item = Item(item_code="RM00001", item_name="冷轧钢板", item_type="RAW", unit="kg", specification="1.2mm")
lot = StockLot(
lot_no="YL0001",
item=item,
warehouse=warehouse,
location=location,
qty_on_hand=Decimal("0"),
weight_on_hand_kg=Decimal("100"),
qty_available=Decimal("0"),
weight_available_kg=Decimal("100"),
unit_cost=Decimal("0"),
status="AVAILABLE",
)
txn = InventoryTxn(
txn_no="TXN-CUSTOMER-001",
txn_type="CUSTOMER_SUPPLIED",
item=item,
warehouse=warehouse,
location=location,
lot=lot,
qty_change=Decimal("0"),
weight_change_kg=Decimal("100"),
unit_cost=Decimal("0"),
amount=Decimal("0"),
source_doc_type="客料入库",
source_doc_id=1,
logistics_waybill_no="SF123",
logistics_freight_amount=Decimal("12.5"),
logistics_photo_url="/uploads/demo.jpg",
remark="客户来料测试",
)
self.db.add_all([warehouse, location, item, lot, txn])
self.db.commit()
self.db.refresh(txn)
return txn
def test_collects_customer_supplied_warehouse_context(self) -> None:
txn = self._seed_customer_supplied_txn()
context = collect_warehouse_operation_archive_context(self.db, txn.id)
self.assertEqual(context.document_type, DOCUMENT_TYPE_WAREHOUSE_OPERATION)
self.assertEqual(context.business_id, txn.id)
self.assertIn("客料入库", context.title)
self.assertEqual(context.partner_label, "仓库")
self.assertEqual(context.partner_name, "原材料库")
self.assertEqual(len(context.lines), 1)
self.assertEqual(context.lines[0].item_name, "冷轧钢板")
self.assertIn("库存批次号YL0001", context.lines[0].remark)
self.assertIn("运单号SF123", context.remark)
def test_generates_pdf_archive_for_warehouse_operation(self) -> None:
txn = self._seed_customer_supplied_txn()
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_WAREHOUSE_OPERATION, txn.id)
self.assertEqual(result.archive_status, ARCHIVE_STATUS_READY)
self.assertEqual(result.document_type, DOCUMENT_TYPE_WAREHOUSE_OPERATION)
self.assertIn("仓库出入库单", result.document_type)
if __name__ == "__main__":
unittest.main()
```
- [ ] **Step 2: Run service test and verify failure**
Run:
```bash
cd backend
python -m pytest tests/test_warehouse_operation_document_archive.py -q
```
Expected: FAIL because `collect_warehouse_operation_archive_context` does not exist.
- [ ] **Step 3: Implement context collector**
In `backend/app/services/document_archives.py`, add this helper near production collectors:
```python
WAREHOUSE_OPERATION_TITLE_LABELS = {
"OPENING": "期初入库单",
"CUSTOMER_SUPPLIED": "客料入库单",
"PRODUCTION_SURPLUS": "生产余料入库单",
"PRODUCTION_SURPLUS_IN": "生产余料入库单",
"OUTSOURCING_SURPLUS": "委外余料入库单",
"WIP_IN": "产中入库单",
"WIP_OUT": "产中出库单",
"OUTSOURCING_IN": "委外入库单",
"OUTSOURCING_OUT": "委外出库单",
"PURCHASE_RETURN_OUT": "退货出库单",
"SCRAP_OUT": "报废出库单",
"REWORK_FINISHED_IN": "返工入库单",
"SALES_OUT": "销售出库单",
"PRODUCTION_OUT": "生产出库单",
"PRODUCTION_SCRAP_IN": "生产废料入库单",
"REWORK_SCRAP_IN": "返工废料入库单",
"OUTSOURCING_SCRAP_IN": "委外废料入库单",
"RETURN_SCRAP_IN": "退货废料入库单",
"SCRAP_SALE_OUT": "售卖出库单",
"RETURN_IN": "退货入库单",
"RETURN_OUT": "返工出库单",
"SPECIAL_IN": "特殊入库单",
"SPECIAL_OUT": "特殊出库单",
}
def warehouse_operation_title(txn_type: str | None) -> str:
normalized = str(txn_type or "").strip().upper()
return WAREHOUSE_OPERATION_TITLE_LABELS.get(normalized, f"{archive_status_text(txn_type)}单")
```
Then add:
```python
def collect_warehouse_operation_archive_context(db: Session, inventory_txn_id: int) -> ArchiveContext:
source_material_lot = aliased(StockLot)
row = db.execute(
select(InventoryTxn, Item, Warehouse, WarehouseLocation, StockLot, source_material_lot)
.join(Item, Item.id == InventoryTxn.item_id)
.join(Warehouse, Warehouse.id == InventoryTxn.warehouse_id)
.outerjoin(WarehouseLocation, WarehouseLocation.id == InventoryTxn.location_id)
.outerjoin(StockLot, StockLot.id == InventoryTxn.lot_id)
.outerjoin(source_material_lot, source_material_lot.id == StockLot.source_material_lot_id)
.where(InventoryTxn.id == inventory_txn_id)
).one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="库存流水不存在")
txn, item, warehouse, location, lot, source_lot = row
title = warehouse_operation_title(txn.txn_type)
lot_no = lot.lot_no if lot else ""
source_lot_no = source_lot.lot_no if source_lot else (lot.source_material_sub_batch_no if lot else "")
weight_value = Decimal(str(txn.weight_change_kg or 0))
qty_value = Decimal(str(txn.qty_change or 0))
total_amount = Decimal(str(txn.amount or 0))
line_remark_parts = [
f"库存流水号:{txn.txn_no}",
f"库存批次号:{lot_no or '-'}",
]
if source_lot_no:
line_remark_parts.append(f"来源库存批次号:{source_lot_no}")
if location:
line_remark_parts.append(f"库位:{location.location_name}")
if txn.remark:
line_remark_parts.append(str(txn.remark))
remark_parts = [
f"业务类型:{title.replace('单', '')}",
f"来源单据:{archive_status_text(txn.source_doc_type)} #{txn.source_doc_id}",
]
if txn.logistics_waybill_no:
remark_parts.append(f"运单号:{txn.logistics_waybill_no}")
if txn.logistics_freight_amount is not None:
remark_parts.append(f"运费:{decimal_text(txn.logistics_freight_amount, 2)}")
if txn.logistics_photo_url:
remark_parts.append("辅助照片:已上传")
if txn.remark:
remark_parts.append(str(txn.remark))
return ArchiveContext(
document_type=DOCUMENT_TYPE_WAREHOUSE_OPERATION,
business_id=txn.id,
document_no=f"{title.replace('单', '')}-{txn.txn_no}",
title=f"{title}归档",
partner_label="仓库",
partner_name=warehouse.warehouse_name,
document_date=txn.biz_time,
due_date_label="库存流水",
due_date=txn.txn_no,
status=title.replace("单", ""),
tax_rate=0,
total_amount=total_amount,
address=location.location_name if location else warehouse.warehouse_name,
remark="".join(remark_parts),
lines=[
ArchiveLine(
line_no=1,
item_code=item.item_code,
item_name=item.item_name,
specification=item.specification,
quantity=qty_value,
delivered_or_received_quantity=weight_value,
unit_price=txn.unit_cost,
line_amount=txn.amount,
promised_or_expected_date=txn.biz_time,
remark="".join(line_remark_parts),
)
],
)
```
- [ ] **Step 4: Wire collector into dispatch**
In `collect_archive_context()` add:
```python
if document_type == DOCUMENT_TYPE_WAREHOUSE_OPERATION:
return collect_warehouse_operation_archive_context(db, business_id)
```
- [ ] **Step 5: Run warehouse archive tests**
Run:
```bash
cd backend
python -m pytest tests/test_warehouse_operation_document_archive.py tests/test_document_archive_service.py -q
```
Expected: PASS.
---
## Task 3: Return Archive Fields in Warehouse Ledger
**Files:**
- Modify: `backend/app/services/operations.py`
- Modify: `backend/app/schemas/operations.py`
- Create: `backend/tests/test_inventory_ledger_export.py`
- [ ] **Step 1: Write failing ledger archive-field test**
In `backend/tests/test_inventory_ledger_export.py`, create a SQLite test that seeds a warehouse transaction and a latest `DocumentArchive`, then asserts `get_inventory_txn_ledger_query()` returns archive fields:
```python
def test_inventory_ledger_returns_warehouse_archive_fields(self) -> None:
txn = self._seed_raw_inventory_txn(txn_type="CUSTOMER_SUPPLIED")
self.db.add(
DocumentArchive(
document_type="仓库出入库单",
business_id=txn.id,
document_no="客料入库-TXN-001",
archive_version=1,
template_version="单据纸面V1",
file_format="PDF",
file_name="客料入库.pdf",
file_path="/tmp/客料入库.pdf",
file_hash="abc",
status="已归档",
)
)
self.db.commit()
row = self.db.execute(get_inventory_txn_ledger_query(warehouse_type="RAW")).mappings().one()
self.assertEqual(row["archive_status"], "已归档")
self.assertEqual(row["archive_business_id"], txn.id)
self.assertEqual(row["archive_document_type"], "仓库出入库单")
self.assertIsNone(row["archive_error_message"])
```
Also add one production material out assertion:
```python
def test_inventory_ledger_points_production_out_to_material_out_archive(self) -> None:
txn = self._seed_raw_inventory_txn(txn_type="PRODUCTION_OUT", source_doc_type="生产台账", source_doc_id=88)
row = self.db.execute(get_inventory_txn_ledger_query(warehouse_type="RAW")).mappings().one()
self.assertEqual(row["archive_document_type"], "生产领料出库单")
self.assertEqual(row["archive_business_id"], 88)
```
- [ ] **Step 2: Run test and verify failure**
Run:
```bash
cd backend
python -m pytest tests/test_inventory_ledger_export.py::InventoryLedgerExportTest::test_inventory_ledger_returns_warehouse_archive_fields -q
```
Expected: FAIL because ledger rows do not expose archive fields.
- [ ] **Step 3: Add schema fields**
In `backend/app/schemas/operations.py`, extend `InventoryTxnLedgerRowRead`:
```python
archive_status: str = "未生成"
archive_business_id: int | None = None
archive_document_type: str | None = None
archive_error_message: str | None = None
```
- [ ] **Step 4: Extend ledger query with archive resolution**
In `backend/app/services/operations.py`, import `DocumentArchive` and the document type constants:
```python
from app.models.document_archive import DocumentArchive
from app.services.document_archives import (
ARCHIVE_STATUS_MISSING,
DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
DOCUMENT_TYPE_WAREHOUSE_OPERATION,
)
```
Inside `get_inventory_txn_ledger_query()`, build these expressions before the select:
```python
archive_document_type_expr = case(
(
(InventoryTxn.source_doc_type == "生产台账") & (InventoryTxn.txn_type == "PRODUCTION_OUT"),
DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
),
(
InventoryTxn.txn_type.in_(("FG_IN", "PRODUCTION_FINISHED_IN", "PRODUCTION_SURPLUS_IN", "PRODUCTION_SCRAP_IN")),
DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
),
else_=DOCUMENT_TYPE_WAREHOUSE_OPERATION,
).label("archive_document_type")
archive_business_id_expr = case(
(
(InventoryTxn.source_doc_type == "生产台账") & (InventoryTxn.txn_type == "PRODUCTION_OUT"),
InventoryTxn.source_doc_id,
),
else_=InventoryTxn.id,
).label("archive_business_id")
latest_archive = (
select(
DocumentArchive.document_type.label("document_type"),
DocumentArchive.business_id.label("business_id"),
DocumentArchive.status.label("status"),
DocumentArchive.error_message.label("error_message"),
func.row_number()
.over(
partition_by=(DocumentArchive.document_type, DocumentArchive.business_id),
order_by=(DocumentArchive.archive_version.desc(), DocumentArchive.id.desc()),
)
.label("row_no"),
)
.where(DocumentArchive.file_format == "PDF")
.subquery()
)
```
Join the latest archive subquery after existing joins:
```python
.outerjoin(
latest_archive,
(latest_archive.c.document_type == archive_document_type_expr)
& (latest_archive.c.business_id == archive_business_id_expr)
& (latest_archive.c.row_no == 1),
)
```
Add fields to the select:
```python
archive_document_type_expr,
archive_business_id_expr,
func.coalesce(latest_archive.c.status, ARCHIVE_STATUS_MISSING).label("archive_status"),
latest_archive.c.error_message.label("archive_error_message"),
```
- [ ] **Step 5: Run ledger archive-field tests**
Run:
```bash
cd backend
python -m pytest tests/test_inventory_ledger_export.py -q
```
Expected: ledger archive-field tests PASS.
---
## Task 4: Add Warehouse Ledger Excel Export Endpoint
**Files:**
- Modify: `backend/app/api/routes/inventory.py`
- Create: `backend/tests/test_inventory_ledger_export.py`
- [ ] **Step 1: Write failing export endpoint test**
Add this test to `backend/tests/test_inventory_ledger_export.py` using a FastAPI `TestClient` with the inventory router:
```python
def test_transaction_ledger_export_uses_date_filter_and_chinese_headers(self) -> None:
self._seed_raw_inventory_txn(txn_type="CUSTOMER_SUPPLIED", txn_no="TXN-IN-RANGE", biz_date="2026-06-10")
self._seed_raw_inventory_txn(txn_type="CUSTOMER_SUPPLIED", txn_no="TXN-OUT-RANGE", biz_date="2026-05-01")
response = self.client.get(
"/inventory/transaction-ledger/export",
params={
"warehouse_type": "RAW",
"start_date": "2026-06-01",
"end_date": "2026-06-30",
"direction": "IN",
},
)
self.assertEqual(response.status_code, 200)
self.assertIn("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", response.headers["content-type"])
workbook = load_workbook(BytesIO(response.content))
sheet = workbook.active
headers = [cell.value for cell in sheet[1]]
self.assertIn("业务时间", headers)
self.assertIn("PDF归档状态", headers)
rows = [[cell.value for cell in row] for row in sheet.iter_rows(min_row=2)]
flattened = "\n".join(str(value) for row in rows for value in row)
self.assertIn("TXN-IN-RANGE", flattened)
self.assertNotIn("TXN-OUT-RANGE", flattened)
```
- [ ] **Step 2: Run export test and verify failure**
Run:
```bash
cd backend
python -m pytest tests/test_inventory_ledger_export.py::InventoryLedgerExportTest::test_transaction_ledger_export_uses_date_filter_and_chinese_headers -q
```
Expected: FAIL because `/inventory/transaction-ledger/export` does not exist.
- [ ] **Step 3: Add export helpers**
In `backend/app/api/routes/inventory.py`, add helpers near `_excel_response()`:
```python
WAREHOUSE_LEDGER_EXPORT_HEADERS = [
("biz_time", "业务时间"),
("warehouse_name", "仓库"),
("location_name", "库位"),
("direction_label", "方向"),
("txn_type_label", "流水类型"),
("txn_no", "流水号"),
("item_code", "物料/产品编码"),
("item_name", "物料/产品名称"),
("lot_no", "库存批次号"),
("source_material_lot_no", "来源库存批次号"),
("qty_change", "数量变化"),
("weight_change_kg", "重量变化(kg)"),
("unit_cost", "单价"),
("amount", "金额"),
("source_doc_label", "来源单据"),
("operator_name", "经办人"),
("logistics_waybill_no", "运单号"),
("logistics_freight_amount", "运费"),
("logistics_photo_label", "辅助照片"),
("remark", "备注"),
("archive_status", "PDF归档状态"),
("archive_business_id", "PDF归档业务ID"),
]
def _ledger_direction_label(value: str | None) -> str:
labels = {"IN": "入库", "OUT": "出库", "ADJUST": "调整", "ALL": "全部"}
return labels.get(str(value or "").upper(), str(value or ""))
def _ledger_export_value(row: dict[str, object], key: str) -> object:
if key == "direction_label":
return _ledger_direction_label(row.get("direction"))
if key == "txn_type_label":
return _inventory_txn_type_label(str(row.get("txn_type") or ""))
if key == "source_doc_label":
source_type = _source_doc_type_label(str(row.get("source_doc_type") or ""))
source_id = row.get("source_doc_id")
return f"{source_type} #{source_id}" if source_id else source_type
if key == "logistics_photo_label":
return "已上传" if row.get("logistics_photo_url") else ""
return row.get(key) or ""
```
If `_inventory_txn_type_label` and `_source_doc_type_label` do not exist in the route module, add explicit local dictionaries with Chinese labels instead of returning English codes.
- [ ] **Step 4: Add export endpoint**
Add this route after `list_inventory_transaction_ledger()`:
```python
@router.get("/transaction-ledger/export")
def export_inventory_transaction_ledger(
warehouse_type: str = Query(...),
warehouse_id: int | None = None,
keyword: str | None = None,
direction: str = "ALL",
txn_type: str | None = None,
item_id: int | None = None,
lot_no: str | None = None,
start_date: date | None = None,
end_date: date | None = None,
sort_key: str = "biz_time",
sort_direction: str = "desc",
db: Session = Depends(get_db),
) -> Response:
normalized_warehouse_type = str(warehouse_type or "").upper()
if normalized_warehouse_type not in WAREHOUSE_LEDGER_TYPES:
raise HTTPException(status_code=400, detail="未知仓库类型,无法导出出入库流水")
normalized_direction = str(direction or "ALL").upper()
if normalized_direction not in {"ALL", "IN", "OUT", "ADJUST"}:
raise HTTPException(status_code=400, detail="流水方向只能是 ALL、IN、OUT、ADJUST")
stmt = get_inventory_txn_ledger_query(
warehouse_type=normalized_warehouse_type,
warehouse_id=warehouse_id,
keyword=keyword,
direction=normalized_direction,
txn_type=txn_type,
item_id=item_id,
lot_no=lot_no,
start_date=start_date,
end_date=end_date,
sort_key=sort_key,
sort_direction=sort_direction,
)
rows = [dict(row) for row in db.execute(stmt).mappings().all()]
workbook = Workbook()
sheet = workbook.active
sheet.title = "出入库流水"
sheet.append([label for _, label in WAREHOUSE_LEDGER_EXPORT_HEADERS])
for row in rows:
sheet.append([_ledger_export_value(row, key) for key, _ in WAREHOUSE_LEDGER_EXPORT_HEADERS])
sheet.freeze_panes = "A2"
sheet.auto_filter.ref = sheet.dimensions
for column_cells in sheet.columns:
header = str(column_cells[0].value or "")
max_length = max(len(str(cell.value or "")) for cell in column_cells)
sheet.column_dimensions[column_cells[0].column_letter].width = min(max(max_length + 2, len(header) + 2), 42)
date_range = f"{start_date or '全部'}_{end_date or '全部'}"
warehouse_label = WAREHOUSE_TYPE_LABELS.get(normalized_warehouse_type, normalized_warehouse_type)
return _excel_response(workbook, f"{warehouse_label}出入库流水_{date_range}.xlsx")
```
- [ ] **Step 5: Run export tests**
Run:
```bash
cd backend
python -m pytest tests/test_inventory_ledger_export.py -q
```
Expected: PASS.
---
## Task 5: Generate Generic Archive After Non-Production Warehouse Saves
**Files:**
- Modify: `backend/app/api/routes/inventory.py`
- Modify: `backend/app/api/routes/sales.py`
- Modify: `backend/app/api/routes/returns.py`
- Modify: `backend/app/services/special_inventory_adjustment.py`
- Modify: `backend/app/services/stocktake.py`
- Test: `backend/tests/test_warehouse_operation_document_archive.py`
- [ ] **Step 1: Add helper for safe archive generation**
In `backend/app/api/routes/inventory.py`, add:
```python
def _generate_warehouse_operation_archive_safely(db: Session, inventory_txn_id: int, created_by: int | None = None) -> DocumentArchiveGenerateResult:
try:
return generate_document_archive(
db,
DOCUMENT_TYPE_WAREHOUSE_OPERATION,
inventory_txn_id,
created_by=created_by,
)
except HTTPException:
raise
except Exception as exc:
return DocumentArchiveGenerateResult(
business_id=inventory_txn_id,
document_type=DOCUMENT_TYPE_WAREHOUSE_OPERATION,
document_no=f"仓库出入库单-{inventory_txn_id}",
archive_status="归档失败",
archive_version=None,
archive_error_message=str(exc),
)
```
If the same helper is needed outside `inventory.py`, move it to `backend/app/services/document_archives.py` as `generate_warehouse_operation_archive_safely()` and import it from each route/service. Prefer the shared service form if more than two files need it.
- [ ] **Step 2: Return archive result for `客料入库` first**
Find the `CUSTOMER_SUPPLIED` save path in `backend/app/api/routes/inventory.py`. After the transaction is committed and refreshed, call:
```python
archive_result = _generate_warehouse_operation_archive_safely(db, txn.id, created_by=_extract_user_id(context))
```
Return the existing payload plus:
```python
"archive_business_id": archive_result.business_id,
"archive_document_type": archive_result.document_type,
"archive_status": archive_result.archive_status,
"archive_error_message": archive_result.archive_error_message,
```
- [ ] **Step 3: Add coverage for remaining non-production inventory routes**
Apply the same safe archive call to plain warehouse operations that create one inventory transaction:
```text
期初入库
委外余料入库
委外出库
报废出库
半成品产中入库
半成品产中出库
半成品委外入库
半成品委外出库
成品委外入库
成品销售出库
辅料生产出库
废料售卖出库
退货入库
退货返工出库
退货废料入库
返工废料入库
特殊入库
特殊出库
```
Do not wrap production material out or production ledger inbound into `仓库出入库单`; those keep production-specific archive types.
- [ ] **Step 4: Run targeted backend tests**
Run:
```bash
cd backend
python -m pytest tests/test_warehouse_operation_document_archive.py tests/test_production_warehouse_document_archive.py tests/test_special_warehouse_adjustment.py -q
```
Expected: PASS.
---
## Task 6: Add PDF Archive Actions to Warehouse Ledger
**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- Modify: `frontend/src/views/InventoryLedgerView.test.js`
- [ ] **Step 1: Write failing frontend static test**
In `frontend/src/views/InventoryLedgerView.test.js`, add:
```js
it("shows document archive actions in warehouse ledger rows", () => {
assert.match(source, /DocumentArchiveActions/);
assert.match(source, /warehouse-operation/);
assert.match(source, /previewWarehouseLedgerArchive/);
assert.match(source, /downloadWarehouseLedgerArchive/);
assert.match(source, /regenerateWarehouseLedgerArchive/);
});
```
- [ ] **Step 2: Run frontend test and verify failure**
Run:
```bash
cd frontend
npm test -- InventoryLedgerView.test.js
```
Expected: FAIL until archive actions are added.
- [ ] **Step 3: Import archive component**
In `frontend/src/views/InventoryLedgerView.vue`, add:
```js
import DocumentArchiveActions from "../components/documentForms/DocumentArchiveActions.vue";
```
- [ ] **Step 4: Add ledger table column**
In the warehouse ledger table header, add:
```vue
<th>单据留档</th>
```
In each row, add:
```vue
<td>
<DocumentArchiveActions
:status="row.archive_status || '未生成'"
@preview="previewWarehouseLedgerArchive(row)"
@download="downloadWarehouseLedgerArchive(row)"
@regenerate="regenerateWarehouseLedgerArchive(row)"
/>
</td>
```
Update empty-state colspan so it includes the new archive column.
- [ ] **Step 5: Add archive handlers**
Add these functions in the script section:
```js
function warehouseLedgerArchiveTypeKey(row) {
const documentType = String(row?.archive_document_type || "");
if (documentType === "生产领料出库单") return "production-material-out";
if (documentType === "生产入库结算单") return "production-inbound-settlement";
return "warehouse-operation";
}
function warehouseLedgerArchiveBusinessId(row) {
return row?.archive_business_id || row?.inventory_txn_id;
}
async function previewWarehouseLedgerArchive(row) {
const businessId = warehouseLedgerArchiveBusinessId(row);
if (!businessId) {
errorMessage.value = "该流水缺少归档业务ID无法预览PDF";
return;
}
await openResource(`/document-archives/${warehouseLedgerArchiveTypeKey(row)}/${businessId}/latest/preview`);
}
async function downloadWarehouseLedgerArchive(row) {
const businessId = warehouseLedgerArchiveBusinessId(row);
if (!businessId) {
errorMessage.value = "该流水缺少归档业务ID无法下载PDF";
return;
}
const filename = `${row?.txn_no || row?.archive_document_type || "仓库出入库单"}归档.pdf`;
await downloadResource(`/document-archives/${warehouseLedgerArchiveTypeKey(row)}/${businessId}/latest/download`, filename);
}
async function regenerateWarehouseLedgerArchive(row) {
const businessId = warehouseLedgerArchiveBusinessId(row);
if (!businessId) {
errorMessage.value = "该流水缺少归档业务ID无法重新生成PDF";
return;
}
await postResource(`/document-archives/${warehouseLedgerArchiveTypeKey(row)}/${businessId}/generate`, {});
await loadWarehouseLedger();
}
```
- [ ] **Step 6: Run frontend test**
Run:
```bash
cd frontend
npm test -- InventoryLedgerView.test.js
```
Expected: PASS.
---
## Task 7: Add Warehouse Ledger Excel Export Button
**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- Modify: `frontend/src/views/InventoryLedgerView.test.js`
- [ ] **Step 1: Write failing frontend export test**
In `frontend/src/views/InventoryLedgerView.test.js`, add:
```js
it("supports exporting warehouse ledger excel with current filters", () => {
assert.match(source, /导出Excel/);
assert.match(source, /exportWarehouseLedgerExcel/);
assert.match(source, /\\/inventory\\/transaction-ledger\\/export/);
assert.match(source, /warehouseLedgerExporting/);
});
```
- [ ] **Step 2: Run frontend test and verify failure**
Run:
```bash
cd frontend
npm test -- InventoryLedgerView.test.js
```
Expected: FAIL until export UI is added.
- [ ] **Step 3: Add export loading state**
Near `warehouseLedgerLoading`, add:
```js
const warehouseLedgerExporting = ref(false);
```
- [ ] **Step 4: Extract shared ledger params builder**
Replace duplicate URLSearchParams construction with:
```js
function buildWarehouseLedgerParams({ includePagination = true } = {}) {
const params = new URLSearchParams({
warehouse_type: activeWarehouseType.value,
direction: warehouseLedgerFilters.direction || "ALL",
sort_key: warehouseLedgerFilters.sort_key || "biz_time",
sort_direction: warehouseLedgerFilters.sort_direction || "desc"
});
if (includePagination) {
params.set("page", String(warehouseLedgerPage.value));
params.set("page_size", String(warehouseLedgerPageSize.value));
}
if (warehouseLedgerFilters.keyword) params.set("keyword", warehouseLedgerFilters.keyword);
if (warehouseLedgerFilters.txn_type) params.set("txn_type", warehouseLedgerFilters.txn_type);
if (warehouseLedgerFilters.start_date) params.set("start_date", warehouseLedgerFilters.start_date);
if (warehouseLedgerFilters.end_date) params.set("end_date", warehouseLedgerFilters.end_date);
return params;
}
```
Update `loadWarehouseLedger()` to call:
```js
const params = buildWarehouseLedgerParams();
```
- [ ] **Step 5: Add export function**
Add:
```js
function warehouseLedgerExportFilename() {
const start = warehouseLedgerFilters.start_date || "全部";
const end = warehouseLedgerFilters.end_date || "全部";
return `${activeInventoryLabel.value}出入库流水_${start}_${end}.xlsx`;
}
async function exportWarehouseLedgerExcel() {
if (!warehouseLedgerFilters.start_date || !warehouseLedgerFilters.end_date) {
feedbackMessage.value = "当前未选择完整时间范围,将导出全部匹配流水";
}
warehouseLedgerExporting.value = true;
try {
const params = buildWarehouseLedgerParams({ includePagination: false });
await downloadResource(`/inventory/transaction-ledger/export?${params.toString()}`, warehouseLedgerExportFilename());
} catch (error) {
errorMessage.value = error.message || "流水Excel导出失败";
} finally {
warehouseLedgerExporting.value = false;
}
}
```
- [ ] **Step 6: Add export button beside query**
In the warehouse ledger filter actions, add:
```vue
<button
class="secondary-button"
type="button"
:disabled="warehouseLedgerLoading || warehouseLedgerExporting"
@click="exportWarehouseLedgerExcel"
>
{{ warehouseLedgerExporting ? "导出中..." : "导出Excel" }}
</button>
```
- [ ] **Step 7: Run frontend export test**
Run:
```bash
cd frontend
npm test -- InventoryLedgerView.test.js
```
Expected: PASS.
---
## Task 8: Extract Warehouse Paper Form Shell
**Files:**
- Create: `frontend/src/components/documentForms/WarehouseDocumentFormShell.vue`
- Create: `frontend/src/components/documentForms/WarehouseDocumentSection.vue`
- Create: `frontend/src/components/documentForms/WarehouseDocumentActionBar.vue`
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- Modify: `frontend/src/styles/main.css`
- [ ] **Step 1: Create shell component**
Create `frontend/src/components/documentForms/WarehouseDocumentFormShell.vue`:
```vue
<template>
<form class="warehouse-document-form" @submit.prevent="$emit('submit')">
<header class="warehouse-document-head">
<div>
<span class="warehouse-document-kicker">嘉恒仓库</span>
<h2>{{ title }}</h2>
</div>
<dl>
<div>
<dt>单据编号</dt>
<dd>{{ documentNo || "保存后自动生成" }}</dd>
</div>
<div>
<dt>业务日期</dt>
<dd>{{ businessDate || "-" }}</dd>
</div>
</dl>
</header>
<main class="warehouse-document-body">
<slot />
</main>
<footer class="warehouse-document-signature">
<span>制单</span>
<span>仓库确认</span>
<span>业务确认</span>
<span>复核</span>
</footer>
</form>
</template>
<script setup>
defineProps({
title: { type: String, required: true },
documentNo: { type: String, default: "" },
businessDate: { type: String, default: "" }
});
defineEmits(["submit"]);
</script>
```
- [ ] **Step 2: Create section component**
Create `frontend/src/components/documentForms/WarehouseDocumentSection.vue`:
```vue
<template>
<section class="warehouse-document-section">
<header>
<span>{{ index }}</span>
<strong>{{ title }}</strong>
</header>
<div class="warehouse-document-section-grid">
<slot />
</div>
</section>
</template>
<script setup>
defineProps({
index: { type: String, required: true },
title: { type: String, required: true }
});
</script>
```
- [ ] **Step 3: Create fixed action bar component**
Create `frontend/src/components/documentForms/WarehouseDocumentActionBar.vue`:
```vue
<template>
<div class="warehouse-document-action-bar">
<div class="warehouse-document-action-state">
<slot name="state" />
</div>
<div class="warehouse-document-action-buttons">
<slot />
</div>
</div>
</template>
```
- [ ] **Step 4: Add shared styles**
Add styles in `frontend/src/styles/main.css`:
```css
.warehouse-document-form {
position: relative;
background:
linear-gradient(90deg, rgba(31, 41, 55, 0.045) 1px, transparent 1px),
linear-gradient(180deg, rgba(31, 41, 55, 0.04) 1px, transparent 1px),
#fffaf0;
background-size: 28px 28px;
border: 1px solid rgba(120, 83, 38, 0.26);
border-radius: 22px;
box-shadow: 0 22px 60px rgba(69, 50, 26, 0.16);
padding: 28px;
}
.warehouse-document-head {
display: flex;
justify-content: space-between;
gap: 24px;
border-bottom: 2px solid rgba(80, 56, 26, 0.36);
padding-bottom: 18px;
}
.warehouse-document-head h2 {
margin: 4px 0 0;
font-size: 28px;
letter-spacing: 0.16em;
color: #3d2b16;
}
.warehouse-document-kicker {
font-size: 12px;
letter-spacing: 0.22em;
color: #9a6a28;
}
.warehouse-document-head dl {
display: grid;
grid-template-columns: repeat(2, minmax(120px, 1fr));
gap: 10px 18px;
margin: 0;
}
.warehouse-document-head dt,
.warehouse-document-head dd {
margin: 0;
}
.warehouse-document-head dt {
font-size: 12px;
color: #8a6a3f;
}
.warehouse-document-head dd {
font-weight: 700;
color: #3d2b16;
}
.warehouse-document-body {
display: grid;
gap: 18px;
padding: 22px 0;
}
.warehouse-document-section {
border: 1px solid rgba(120, 83, 38, 0.22);
border-radius: 16px;
background: rgba(255, 255, 255, 0.72);
padding: 18px;
}
.warehouse-document-section > header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
color: #4b3215;
}
.warehouse-document-section > header span {
display: inline-grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 999px;
background: #c8892f;
color: #fff;
font-size: 12px;
}
.warehouse-document-section-grid {
display: grid;
grid-template-columns: repeat(3, minmax(180px, 1fr));
gap: 14px;
}
.warehouse-document-signature {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
border-top: 1px dashed rgba(80, 56, 26, 0.38);
padding-top: 18px;
color: #7a5a30;
}
.warehouse-document-action-bar {
position: sticky;
bottom: 0;
z-index: 8;
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
margin-top: 18px;
padding: 14px 0 0;
background: linear-gradient(180deg, rgba(255, 250, 240, 0), #fffaf0 32%);
}
@media (max-width: 900px) {
.warehouse-document-head,
.warehouse-document-action-bar {
flex-direction: column;
align-items: stretch;
}
.warehouse-document-section-grid {
grid-template-columns: 1fr;
}
}
```
- [ ] **Step 5: Migrate `原材料库 · 客料入库` to shell**
In `frontend/src/views/InventoryLedgerView.vue`, replace the current paper-style customer supplied block with:
```vue
<WarehouseDocumentFormShell
v-if="activeOperation?.bizType === 'CUSTOMER_SUPPLIED'"
title="客料入库单"
:business-date="formatDateTime(new Date())"
@submit="submitOperation"
>
<WarehouseDocumentSection index="01" title="基础信息">
<!-- keep existing 客料入库 fields here without deleting business fields -->
</WarehouseDocumentSection>
<WarehouseDocumentSection index="02" title="入库明细">
<!-- keep existing material, lot, weight, unit-cost fields here -->
</WarehouseDocumentSection>
<WarehouseDocumentSection index="03" title="辅助信息">
<!-- keep existing logistics, photo, remark fields here -->
</WarehouseDocumentSection>
<WarehouseDocumentActionBar>
<template #state>
<span v-if="lastArchiveStatus">{{ lastArchiveStatus }}</span>
</template>
<button class="primary-button" type="submit" :disabled="operationSubmitting">
{{ operationSubmitting ? "保存中..." : "保存并生成PDF归档" }}
</button>
</WarehouseDocumentActionBar>
</WarehouseDocumentFormShell>
```
Do not delete any current `客料入库` business field. Move fields into sections.
- [ ] **Step 6: Run frontend build**
Run:
```bash
cd frontend
npm run build
```
Expected: PASS.
---
## Task 9: Add Save-Success Archive Shortcuts
**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- [ ] **Step 1: Add last archive result state**
Add:
```js
const lastWarehouseArchiveResult = ref(null);
```
- [ ] **Step 2: Capture backend archive fields after save**
Where a warehouse operation save response is handled, set:
```js
lastWarehouseArchiveResult.value = result?.archive_business_id
? {
businessId: result.archive_business_id,
documentType: result.archive_document_type || "仓库出入库单",
status: result.archive_status || "未生成",
errorMessage: result.archive_error_message || ""
}
: null;
```
- [ ] **Step 3: Show shortcut actions after successful save**
In the operation drawer success area, render:
```vue
<DocumentArchiveActions
v-if="lastWarehouseArchiveResult"
:status="lastWarehouseArchiveResult.status"
@preview="previewWarehouseLedgerArchive({ archive_document_type: lastWarehouseArchiveResult.documentType, archive_business_id: lastWarehouseArchiveResult.businessId })"
@download="downloadWarehouseLedgerArchive({ archive_document_type: lastWarehouseArchiveResult.documentType, archive_business_id: lastWarehouseArchiveResult.businessId, txn_no: activeOperation?.label })"
@regenerate="regenerateWarehouseLedgerArchive({ archive_document_type: lastWarehouseArchiveResult.documentType, archive_business_id: lastWarehouseArchiveResult.businessId })"
/>
```
- [ ] **Step 4: Verify save feedback does not block inventory save**
Run the relevant backend and frontend tests:
```bash
cd backend
python -m pytest tests/test_warehouse_operation_document_archive.py -q
cd ../frontend
npm test -- InventoryLedgerView.test.js
```
Expected: PASS.
---
## Task 10: Migrate Remaining Warehouse Forms to Paper Shell
**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- [ ] **Step 1: Create operation-to-document title map**
Add:
```js
const warehouseOperationDocumentTitles = {
OPENING: "期初入库单",
CUSTOMER_SUPPLIED: "客料入库单",
PRODUCTION_SURPLUS: "生产余料入库单",
OUTSOURCING_SURPLUS: "委外余料入库单",
PRODUCTION_OUT: "生产出库单",
OUTSOURCING_OUT: "委外出库单",
PURCHASE_RETURN_OUT: "退货出库单",
SCRAP_OUT: "报废出库单",
WIP_IN: "产中入库单",
WIP_OUT: "产中出库单",
OUTSOURCING_IN: "委外入库单",
PRODUCTION_FINISHED: "生产入库单",
REWORK_FINISHED_IN: "返工入库单",
SALES_OUT: "销售出库单",
PRODUCTION_SCRAP_IN: "生产废料入库单",
REWORK_SCRAP_IN: "返工废料入库单",
OUTSOURCING_SCRAP_IN: "委外废料入库单",
RETURN_SCRAP_IN: "退货废料入库单",
SCRAP_SALE_OUT: "售卖出库单",
RETURN_IN: "退货入库单",
RETURN_OUT: "返工出库单",
SPECIAL_IN: "特殊入库单",
SPECIAL_OUT: "特殊出库单"
};
```
- [ ] **Step 2: Wrap generic operation drawer content**
In the generic operation drawer, wrap current form fields in:
```vue
<WarehouseDocumentFormShell
:title="warehouseOperationDocumentTitles[activeOperation?.bizType] || `${activeOperation?.label || '仓库'}单`"
:business-date="formatDateTime(new Date())"
@submit="submitOperation"
>
<WarehouseDocumentSection index="01" title="基础信息">
<!-- existing warehouse, operation, provider/customer/person fields -->
</WarehouseDocumentSection>
<WarehouseDocumentSection index="02" title="明细信息">
<!-- existing item/product/lot/qty/weight/cost fields -->
</WarehouseDocumentSection>
<WarehouseDocumentSection index="03" title="辅助信息">
<!-- existing logistics/photo/remark/reason fields -->
</WarehouseDocumentSection>
<WarehouseDocumentActionBar>
<button class="primary-button" type="submit" :disabled="operationSubmitting">
{{ operationSubmitting ? "保存中..." : "保存并生成PDF归档" }}
</button>
</WarehouseDocumentActionBar>
</WarehouseDocumentFormShell>
```
- [ ] **Step 3: Keep production-specific forms on paper shell but production archive keys**
For these entries, keep the same paper shell visual, but do not call `warehouse-operation` in save-success PDF actions:
```text
原材料库 · 生产出库 -> production-material-out
成品库 · 生产入库 -> production-inbound-settlement
原材料库 · 生产余料入库 -> production-inbound-settlement when tied to production inbound settlement
废料库 · 生产废料入库 -> production-inbound-settlement when tied to production inbound settlement
生产台账入库 -> production-inbound-settlement
```
- [ ] **Step 4: Browser verify all operation drawers open**
Use the in-app browser at `http://127.0.0.1:5173/inventory-ledger`.
Verify one operation per warehouse:
```text
原材料库:客料入库、生产出库、特殊出库
半成品库:产中入库、委外出库
成品库:生产入库、销售出库
辅料库:生产出库
废料库:生产废料入库、售卖出库
退货库:退货入库、返工出库
```
Expected: each opens as a paper-style form; no old large explanation card dominates the form.
---
## Task 11: Add Archive Actions to Inventory Detail Drawer Ledger
**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- Modify: `backend/app/services/operations.py`
- [ ] **Step 1: Find detail drawer transaction source**
Search:
```bash
rg -n "get_inventory_txn_query|inventory_txn_id|库存流水|detail" frontend/src/views/InventoryLedgerView.vue backend/app/services/operations.py backend/app/api/routes/inventory.py
```
Use the same archive field strategy as `get_inventory_txn_ledger_query()`.
- [ ] **Step 2: Add backend archive fields to detail transaction query**
If the detail drawer uses `get_inventory_txn_query()`, extend that select with:
```python
archive_document_type_expr,
archive_business_id_expr,
func.coalesce(latest_archive.c.status, ARCHIVE_STATUS_MISSING).label("archive_status"),
latest_archive.c.error_message.label("archive_error_message"),
```
- [ ] **Step 3: Add `单据留档` column in detail drawer**
Render:
```vue
<DocumentArchiveActions
:status="txn.archive_status || '未生成'"
@preview="previewWarehouseLedgerArchive(txn)"
@download="downloadWarehouseLedgerArchive(txn)"
@regenerate="regenerateWarehouseLedgerArchive(txn)"
/>
```
- [ ] **Step 4: Build and smoke test**
Run:
```bash
cd frontend
npm run build
```
Expected: PASS.
---
## Task 12: Verification
**Files:**
- Verify: backend tests
- Verify: frontend tests
- Verify: browser workflows
- [ ] **Step 1: Run backend document tests**
Run:
```bash
cd backend
python -m pytest \
tests/test_document_archive_routes.py \
tests/test_document_archive_service.py \
tests/test_warehouse_operation_document_archive.py \
tests/test_inventory_ledger_export.py \
tests/test_production_warehouse_document_archive.py \
-q
```
Expected: PASS.
- [ ] **Step 2: Run frontend tests**
Run:
```bash
cd frontend
npm test -- InventoryLedgerView.test.js ProductionLedgerView.test.js
```
Expected: PASS.
- [ ] **Step 3: Run frontend build**
Run:
```bash
cd frontend
npm run build
```
Expected: PASS.
- [ ] **Step 4: Browser verification checklist**
In `http://127.0.0.1:5173/inventory-ledger`, verify:
```text
1. 原材料库 · 客料入库保存后显示 PDF 状态和预览/下载/重新生成。
2. 工具 -> 流水中每行有“单据留档”。
3. 已归档流水可预览 PDF。
4. 未归档流水点“重新生成”后刷新状态。
5. 流水筛选开始日期、结束日期后点“导出Excel”下载 xlsx。
6. 未选时间范围点“导出Excel”时给轻提示并导出当前筛选全部流水。
7. Excel 表头为中文,备注、批次号、来源单据完整,不出现前端省略号。
8. 生产台账入库形成的多条流水指向同一张“生产入库结算单”。
9. 生产出库流水指向“生产领料出库单”,不是“仓库出入库单”。
```
---
## Self-Review
### Spec Coverage
- 6 大库所有出入库纸质化Task 8, Task 10.
- 客料入库补 PDFTask 2, Task 5, Task 9.
- 通用 `仓库出入库单`Task 1, Task 2.
- 生产专用 PDF 不重复生成通用仓库单Task 3, Task 10.
- 仓库流水 PDF 预览/下载/重新生成Task 3, Task 6.
- 库存明细流水 PDF 入口Task 11.
- 各库流水按时间筛选导出 ExcelTask 4, Task 7.
- PDF/Excel 不省略、不英文状态Task 2, Task 4, Task 12.
### Placeholder Scan
- No `TBD`.
- No vague "handle edge cases" step.
- Every code-changing task includes the exact file and representative code or command.
### Type Consistency
- Archive route key: `warehouse-operation`.
- Archive document type: `仓库出入库单`.
- Ledger fields:
- `archive_status`
- `archive_business_id`
- `archive_document_type`
- `archive_error_message`
- Existing production route keys preserved:
- `production-material-out`
- `production-inbound-settlement`