1698 lines
52 KiB
Markdown
1698 lines
52 KiB
Markdown
# 全仓库出入库单据纸质化、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.
|
||
- 客料入库补 PDF:Task 2, Task 5, Task 9.
|
||
- 通用 `仓库出入库单`:Task 1, Task 2.
|
||
- 生产专用 PDF 不重复生成通用仓库单:Task 3, Task 10.
|
||
- 仓库流水 PDF 预览/下载/重新生成:Task 3, Task 6.
|
||
- 库存明细流水 PDF 入口:Task 11.
|
||
- 各库流水按时间筛选导出 Excel:Task 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`
|
||
|