docs: add qc material month import design and implementation plan

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-19 12:50:46 +08:00
1 parent 6957952b4c
commit f7ee984d04
2 files changed
+1045

No files matched your search

@@ -0,0 +1,810 @@
# 质检物料库 · 月初固定导入 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:** 在质检物料库提供可维护的「月初入库模板」与「月初导入」预览确认入库,减少每月固定物品的重复录入。
**Architecture:** 新增租户级模板表与 CRUD/preview/confirm API;confirm 同事务 `ensure` 物资后复用现有 `batch_stock_in`。前端在批次列表工具栏增加两个入口,弹窗拆成独立组件,避免继续膨胀 `index.vue`。
**Tech Stack:** FastAPI / SQLAlchemy / Pydantic v2 / Vue 3 / Element Plus;权限沿用 `biz:qcMaterial:list`。
**Spec:** `docs/superpowers/specs/2026-08-19-qc-material-month-import-design.md`
---
## File map
| File | Responsibility |
|------|----------------|
| Create: `docs/sql/2026-08-19-qc-material-month-template.sql` | 建表 SQL |
| Modify: `backend/app/models/qc_material.py` | 增加 `BizQcMaterialMonthTemplate` |
| Create: `backend/app/crud/qc_material_stock_in_no.py` | 入库编号生成纯函数 + 查库取下一序号 |
| Create: `backend/tests/test_qc_material_stock_in_no.py` | 编号生成单测 |
| Create: `backend/app/crud/qc_material_month_template.py` | 模板 CRUD、preview、confirm |
| Create: `backend/tests/test_qc_material_month_template.py` | 模板校验 / ensure 物资纯逻辑单测 |
| Modify: `backend/app/crud/qc_material.py` | `get_material_by_code`;`create_material(..., commit=True)` 支持不提交 |
| Modify: `backend/app/api/qc.py` | 模板 list/create/update/delete/preview/confirm 路由与 Body |
| Create: `web-admin/src/api/qc-material-month-template.ts` | 前端 API |
| Create: `web-admin/src/views/quality-inspection/qc-material/QcMaterialMonthTemplateDialog.vue` | 维护模板弹窗 |
| Create: `web-admin/src/views/quality-inspection/qc-material/QcMaterialMonthImportDialog.vue` | 月初导入预览弹窗 |
| Modify: `web-admin/src/views/quality-inspection/qc-material/index.vue` | 工具栏按钮 + 挂载两弹窗 |
---
### Task 1: SQL 建表
**Files:**
- Create: `docs/sql/2026-08-19-qc-material-month-template.sql`
- [ ] **Step 1: Write SQL**
```sql
-- 质检物料库:月初入库模板
-- MySQL 5.7 / utf8mb4;可重复执行需人工判断表是否已存在
CREATE TABLE IF NOT EXISTS `biz_qc_material_month_template` (
`id` varchar(64) NOT NULL COMMENT '主键ID',
`material_id` varchar(64) DEFAULT NULL COMMENT '关联 biz_qc_material.id,可空',
`material_code` varchar(64) NOT NULL COMMENT '物料编码',
`material_name` varchar(200) NOT NULL COMMENT '物料名称',
`spec_model` varchar(200) DEFAULT NULL COMMENT '规格型号',
`unit` varchar(32) DEFAULT NULL COMMENT '单位',
`catalog_no` varchar(128) NOT NULL COMMENT '货号',
`batch_no` varchar(128) NOT NULL COMMENT '批号',
`manufacturer` varchar(200) NOT NULL COMMENT '厂家',
`unit_price` decimal(18,2) NOT NULL COMMENT '单价(元)',
`amount` decimal(18,2) NOT NULL COMMENT '金额(元)',
`in_qty` decimal(18,4) NOT NULL COMMENT '默认入库数量',
`expire_date` date NOT NULL COMMENT '默认过期日期',
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
`order_num` int(11) DEFAULT 0 COMMENT '排序号',
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
PRIMARY KEY (`id`),
KEY `idx_qc_mmt_tenant` (`tenant_no`, `del_flag`, `order_num`),
KEY `idx_qc_mmt_material` (`material_id`, `del_flag`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='质检物料月初入库模板';
```
- [ ] **Step 2: Commit**
```bash
git add docs/sql/2026-08-19-qc-material-month-template.sql
git commit -m "$(cat <<'EOF'
docs(sql): add qc material month import template table
EOF
)"
```
---
### Task 2: SQLAlchemy 模型
**Files:**
- Modify: `backend/app/models/qc_material.py`
- Modify: `backend/app/models/models.py`
- [ ] **Step 1: Append model class**(放在 `BizQcMaterial` 之后、`BizQcMaterialStockBatch` 之前)
```python
class BizQcMaterialMonthTemplate(Base):
__tablename__ = "biz_qc_material_month_template"
id = Column(String(64), primary_key=True, comment="主键ID")
material_id = Column(String(64), nullable=True, comment="关联物料ID")
material_code = Column(String(64), nullable=False, comment="物料编码")
material_name = Column(String(200), nullable=False, comment="物料名称")
spec_model = Column(String(200), nullable=True, comment="规格型号")
unit = Column(String(32), nullable=True, comment="单位")
catalog_no = Column(String(128), nullable=False, comment="货号")
batch_no = Column(String(128), nullable=False, comment="批号")
manufacturer = Column(String(200), nullable=False, comment="厂家")
unit_price = Column(Numeric(18, 2), nullable=False, comment="单价(元)")
amount = Column(Numeric(18, 2), nullable=False, comment="金额(元)")
in_qty = Column(Numeric(18, 4), nullable=False, comment="默认入库数量")
expire_date = Column(Date, nullable=False, comment="默认过期日期")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
```
- [ ] **Step 2: 在 `backend/app/models/models.py` 末尾 import 旁补充导出**
找到:
```python
from app.models.qc_material import BizQcMaterial, BizQcMaterialStockBatch, BizQcMaterialUsage # noqa: E402,F401
```
改为:
```python
from app.models.qc_material import ( # noqa: E402,F401
BizQcMaterial,
BizQcMaterialMonthTemplate,
BizQcMaterialStockBatch,
BizQcMaterialUsage,
)
```
- [ ] **Step 3: Commit**
```bash
git add backend/app/models/qc_material.py backend/app/models/models.py
git commit -m "$(cat <<'EOF'
feat(backend): add BizQcMaterialMonthTemplate model
EOF
)"
```
---
### Task 3: 入库编号生成(TDD)
**Files:**
- Create: `backend/app/crud/qc_material_stock_in_no.py`
- Create: `backend/tests/test_qc_material_stock_in_no.py`
- [ ] **Step 1: Write the failing test**
```python
# backend/tests/test_qc_material_stock_in_no.py
"""质检物料入库编号:RK + YYYYMMDD + 3位序号。"""
import unittest
from datetime import date
from app.crud.qc_material_stock_in_no import (
format_stock_in_no,
max_seq_from_stock_in_nos,
next_stock_in_nos,
)
class TestFormatStockInNo(unittest.TestCase):
def test_format(self):
self.assertEqual(format_stock_in_no(date(2026, 8, 19), 1), "RK20260819001")
self.assertEqual(format_stock_in_no(date(2026, 8, 19), 12), "RK20260819012")
self.assertEqual(format_stock_in_no(date(2026, 8, 19), 999), "RK20260819999")
class TestMaxSeq(unittest.TestCase):
def test_empty(self):
self.assertEqual(max_seq_from_stock_in_nos("RK20260819", []), 0)
def test_parses_matching_prefix(self):
nos = ["RK20260819001", "RK20260819007", "OTHER", "RK20260818099"]
self.assertEqual(max_seq_from_stock_in_nos("RK20260819", nos), 7)
def test_ignores_bad_suffix(self):
nos = ["RK20260819ABC", "RK20260819100"]
self.assertEqual(max_seq_from_stock_in_nos("RK20260819", nos), 100)
class TestNextStockInNos(unittest.TestCase):
def test_allocates_contiguous(self):
got = next_stock_in_nos(date(2026, 8, 19), count=3, existing_nos=["RK20260819002"])
self.assertEqual(got, ["RK20260819003", "RK20260819004", "RK20260819005"])
def test_zero_count(self):
self.assertEqual(next_stock_in_nos(date(2026, 8, 19), count=0, existing_nos=[]), [])
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd backend && source venv/bin/activate && pytest tests/test_qc_material_stock_in_no.py -v
```
Expected: FAIL(模块不存在)
- [ ] **Step 3: Implement**
```python
# backend/app/crud/qc_material_stock_in_no.py
"""质检物料入库编号生成:RK + YYYYMMDD + 3位序号。"""
from __future__ import annotations
import re
from datetime import date
from typing import Iterable, List
from sqlalchemy.orm import Session
from app.models.qc_material import BizQcMaterialStockBatch
_SUFFIX_RE = re.compile(r"^(\d{3})$")
def format_stock_in_no(d: date, seq: int) -> str:
if seq < 1 or seq > 999:
raise ValueError(f"入库编号序号超出范围: {seq}")
return f"RK{d.strftime('%Y%m%d')}{seq:03d}"
def prefix_for_date(d: date) -> str:
return f"RK{d.strftime('%Y%m%d')}"
def max_seq_from_stock_in_nos(prefix: str, nos: Iterable[str]) -> int:
max_seq = 0
for raw in nos:
s = (raw or "").strip()
if not s.startswith(prefix):
continue
suffix = s[len(prefix) :]
m = _SUFFIX_RE.match(suffix)
if not m:
continue
max_seq = max(max_seq, int(m.group(1)))
return max_seq
def next_stock_in_nos(
d: date,
*,
count: int,
existing_nos: Iterable[str],
) -> List[str]:
if count <= 0:
return []
prefix = prefix_for_date(d)
start = max_seq_from_stock_in_nos(prefix, existing_nos) + 1
if start + count - 1 > 999:
raise ValueError("当日入库编号序号已用尽(上限 999)")
return [format_stock_in_no(d, start + i) for i in range(count)]
def list_existing_stock_in_nos_for_prefix(
db: Session, *, tenant_no: str, prefix: str
) -> List[str]:
like = f"{prefix}%"
rows = (
db.query(BizQcMaterialStockBatch.stock_in_no)
.filter(
BizQcMaterialStockBatch.tenant_no == tenant_no,
BizQcMaterialStockBatch.del_flag == 0,
BizQcMaterialStockBatch.stock_in_no.like(like),
)
.all()
)
return [r[0] for r in rows if r[0]]
def allocate_stock_in_nos(
db: Session,
*,
tenant_no: str,
stock_in_date: date,
count: int,
) -> List[str]:
prefix = prefix_for_date(stock_in_date)
existing = list_existing_stock_in_nos_for_prefix(db, tenant_no=tenant_no, prefix=prefix)
return next_stock_in_nos(stock_in_date, count=count, existing_nos=existing)
```
- [ ] **Step 4: Run test to verify it passes**
```bash
cd backend && source venv/bin/activate && pytest tests/test_qc_material_stock_in_no.py -v
```
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add backend/app/crud/qc_material_stock_in_no.py backend/tests/test_qc_material_stock_in_no.py
git commit -m "$(cat <<'EOF'
feat(backend): add RK+date stock-in number allocator for qc materials
EOF
)"
```
---
### Task 4: 物资 ensure 前置 — create_material(commit=False)
**Files:**
- Modify: `backend/app/crud/qc_material.py`
- [ ] **Step 1: Add `get_material_by_code`**
在 `get_material` 旁增加:
```python
def get_material_by_code(
db: Session, material_code: str, *, tenant_no: str
) -> Optional[BizQcMaterial]:
code = (material_code or "").strip()
if not code:
return None
return (
db.query(BizQcMaterial)
.filter(
BizQcMaterial.tenant_no == tenant_no,
BizQcMaterial.material_code == code,
BizQcMaterial.del_flag == 0,
)
.first()
)
```
- [ ] **Step 2: `create_material` 增加 `commit: bool = True`**
`db.add(obj)` 后改为:
```python
if commit:
db.commit()
db.refresh(obj)
else:
db.flush()
return obj
```
- [ ] **Step 3: Commit**
```bash
git add backend/app/crud/qc_material.py
git commit -m "$(cat <<'EOF'
feat(backend): support non-committing qc material create and lookup by code
EOF
)"
```
---
### Task 5: 模板 CRUD + preview + confirm
**Files:**
- Create: `backend/app/crud/qc_material_month_template.py`
- Create: `backend/tests/test_qc_material_month_template.py`
- [ ] **Step 1: Write unit tests for field validation**
测试 `_require_template_fields`:缺货号、数量≤0、缺编码/名称 → `ValueError`;合法 payload 返回规范化 dict。
- [ ] **Step 2: Implement CRUD module**
关键签名(须完整实现):
```python
# backend/app/crud/qc_material_month_template.py
"""质检物料月初入库模板 CRUD / preview / confirm。"""
def _row_to_dict(row: BizQcMaterialMonthTemplate) -> dict: ...
def list_templates(db, *, tenant_no: str) -> list: ...
def create_template(db, *, data: dict, tenant_no: str, user_id: Optional[str], create_dept_id: str = "") -> dict: ...
def update_template(db, row_id: str, *, data: dict, tenant_no: str, user_id: Optional[str]) -> dict: ...
def soft_delete_template(db, row_id: str, *, tenant_no: str) -> None: ...
def preview_templates(db, *, tenant_no: str, stock_in_date: date) -> list:
"""加载模板行,分配默认 stock_in_no,返回预览明细。"""
def ensure_material_id(
db,
*,
tenant_no: str,
user_id: Optional[str],
material_id: Optional[str],
material_code: str,
material_name: str,
spec_model: Optional[str],
unit: Optional[str],
) -> str:
"""
1) material_id 有值:存在且未删;status!=1 则 ValueError「物资已停用」
2) 否则按 code 查找;找到则返回 id(不覆盖主数据名称)
3) 否则 create_material(..., commit=False)
"""
def confirm_month_import(
db,
*,
tenant_no: str,
user_id: Optional[str],
stock_in_date: date,
items: list,
remark: Optional[str] = None,
operate_time: Optional[datetime] = None,
) -> int:
"""ensure 物资后调用 batch_stock_in;失败由其 rollback,未提交 insert 一并回滚。"""
```
`preview_templates` 返回驼峰字段:`templateId`, `materialId`, `materialCode`, `materialName`, `specModel`, `unit`, `stockInNo`, `stockInDate`, `expireDate`, `catalogNo`, `batchNo`, `manufacturer`, `unitPrice`, `amount`, `qty`, `remark`。
- [ ] **Step 3: Run tests**
```bash
cd backend && source venv/bin/activate && pytest tests/test_qc_material_month_template.py tests/test_qc_material_stock_in_no.py -v
```
Expected: PASS
- [ ] **Step 4: Commit**
```bash
git add backend/app/crud/qc_material_month_template.py backend/tests/test_qc_material_month_template.py
git commit -m "$(cat <<'EOF'
feat(backend): add qc material month template CRUD preview and confirm
EOF
)"
```
---
### Task 6: API 路由
**Files:**
- Modify: `backend/app/api/qc.py`
- [ ] **Step 1: 在 `BatchStockInBody` 附近增加 Pydantic models**
```python
class QcMonthTemplateBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
material_id: Optional[str] = Field(None, alias="materialId")
material_code: str = Field(..., alias="materialCode")
material_name: str = Field(..., alias="materialName")
spec_model: Optional[str] = Field(None, alias="specModel")
unit: Optional[str] = None
catalog_no: str = Field(..., alias="catalogNo")
batch_no: str = Field(..., alias="batchNo")
manufacturer: str
unit_price: float = Field(..., alias="unitPrice")
amount: float
in_qty: float = Field(..., alias="inQty", gt=0)
expire_date: str = Field(..., alias="expireDate")
order_num: Optional[int] = Field(None, alias="orderNum")
remark: Optional[str] = None
class QcMonthTemplatePreviewBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
stock_in_date: str = Field(..., alias="stockInDate")
class QcMonthTemplateConfirmItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
material_id: Optional[str] = Field(None, alias="materialId")
material_code: str = Field(..., alias="materialCode")
material_name: str = Field(..., alias="materialName")
spec_model: Optional[str] = Field(None, alias="specModel")
unit: Optional[str] = None
stock_in_no: str = Field(..., alias="stockInNo")
expire_date: str = Field(..., alias="expireDate")
catalog_no: str = Field(..., alias="catalogNo")
batch_no: str = Field(..., alias="batchNo")
manufacturer: str
unit_price: float = Field(..., alias="unitPrice")
amount: float
qty: float = Field(..., gt=0)
remark: Optional[str] = None
class QcMonthTemplateConfirmBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
stock_in_date: str = Field(..., alias="stockInDate")
items: List[QcMonthTemplateConfirmItem] = Field(..., min_length=1)
remark: Optional[str] = None
stock_in_time: Optional[str] = Field(None, alias="stockInTime")
```
- [ ] **Step 2: 增加 6 个路由**(均 `require_menu_code("biz:qcMaterial:list")`)
| Method | Path |
|--------|------|
| GET | `/qc-material-month-templates` |
| POST | `/qc-material-month-templates` |
| PUT | `/qc-material-month-templates/{id}` |
| DELETE | `/qc-material-month-templates/{id}` |
| POST | `/qc-material-month-templates/preview` |
| POST | `/qc-material-month-templates/confirm` |
`confirm`:解析日期 → `confirm_month_import` → `success_response({"count": n})`。金额字段与批量入库一致调用 `_resolve_stock_in_amounts`(若适用)。
- [ ] **Step 3: Commit**
```bash
git add backend/app/api/qc.py
git commit -m "$(cat <<'EOF'
feat(api): expose qc material month template endpoints
EOF
)"
```
---
### Task 7: 前端 API 模块
**Files:**
- Create: `web-admin/src/api/qc-material-month-template.ts`
- [ ] **Step 1: Implement**
```typescript
import request from '@/utils/http'
export interface QcMonthTemplateRow {
id: string
materialId?: string
materialCode: string
materialName: string
specModel?: string
unit?: string
catalogNo: string
batchNo: string
manufacturer: string
unitPrice: number
amount: number
inQty: number
expireDate: string
orderNum?: number
remark?: string
}
export interface QcMonthImportPreviewLine {
templateId?: string
materialId?: string
materialCode: string
materialName: string
specModel?: string
unit?: string
stockInNo: string
stockInDate: string
expireDate: string
catalogNo: string
batchNo: string
manufacturer: string
unitPrice: number
amount: number
qty: number
remark?: string
}
export function fetchQcMonthTemplateList() {
return request.get<QcMonthTemplateRow[]>({
url: '/web/admin/qc-material-month-templates'
})
}
export function fetchCreateQcMonthTemplate(data: Record<string, any>) {
return request.post<QcMonthTemplateRow>({
url: '/web/admin/qc-material-month-templates',
data
})
}
export function fetchUpdateQcMonthTemplate(id: string, data: Record<string, any>) {
return request.put<QcMonthTemplateRow>({
url: `/web/admin/qc-material-month-templates/${id}`,
data
})
}
export function fetchDeleteQcMonthTemplate(id: string) {
return request.delete<void>({
url: `/web/admin/qc-material-month-templates/${id}`
})
}
export function fetchPreviewQcMonthImport(stockInDate: string) {
return request.post<QcMonthImportPreviewLine[]>({
url: '/web/admin/qc-material-month-templates/preview',
data: { stockInDate }
})
}
export function fetchConfirmQcMonthImport(data: {
stockInDate: string
items: QcMonthImportPreviewLine[]
remark?: string
stockInTime?: string
}) {
return request.post<{ count: number }>({
url: '/web/admin/qc-material-month-templates/confirm',
data
})
}
```
- [ ] **Step 2: Commit**
```bash
git add web-admin/src/api/qc-material-month-template.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): add qc month template API client
EOF
)"
```
---
### Task 8: 维护模板弹窗组件
**Files:**
- Create: `web-admin/src/views/quality-inspection/qc-material/QcMaterialMonthTemplateDialog.vue`
- [ ] **Step 1: 实现弹窗**
要点:
- `el-dialog`:`class="dialog-form-layout"`、`append-to-body`、`modal-class="protocol-dialog-mask"`、宽约 `960px`、`align-center`
- `v-model`;打开时 `fetchQcMonthTemplateList`
- 表格 + 新增/编辑子表单(`label-position="top"`,`form-grid-2`)
- 可选关联启用物资(`fetchQcMaterialOptions`)
- 字段:物资、编码、名称、规格、单位、货号、批号、厂家、单价、金额、数量、过期日期、备注
- 金额默认 `unitPrice * inQty`(未手工改时联动)
- 删除:`ElMessageBox.confirm` 后调删除 API
- 底栏 Close / Check 图标;空列表 `el-empty`
- Props/Emits:`modelValue` / `update:modelValue` / `saved`
- [ ] **Step 2: Commit**
```bash
git add web-admin/src/views/quality-inspection/qc-material/QcMaterialMonthTemplateDialog.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): add qc month template maintenance dialog
EOF
)"
```
---
### Task 9: 月初导入预览弹窗
**Files:**
- Create: `web-admin/src/views/quality-inspection/qc-material/QcMaterialMonthImportDialog.vue`
- [ ] **Step 1: 实现弹窗**
要点:
- 打开后调 `fetchPreviewQcMonthImport(stockInDate)`
- 顶部入库日期变更时重新 preview(保证序号与库一致)
- 明细可编辑;行删除仅本地;不提供预览新增行
- 确认:`fetchConfirmQcMonthImport`;成功后 `emit('success')`
- 前端校验对齐批量入库必填
- [ ] **Step 2: Commit**
```bash
git add web-admin/src/views/quality-inspection/qc-material/QcMaterialMonthImportDialog.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): add qc month import preview dialog
EOF
)"
```
---
### Task 10: 挂到质检物料库页
**Files:**
- Modify: `web-admin/src/views/quality-inspection/qc-material/index.vue`
- [ ] **Step 1: 工具栏增加按钮**(建议在批量废弃与导出之间)
```vue
<el-button class="btn-ghost" @click="handleMonthImport">
<el-icon><Calendar /></el-icon>
月初导入
</el-button>
<el-button class="btn-ghost" @click="monthTemplateVisible = true">
<el-icon><Setting /></el-icon>
维护月初模板
</el-button>
```
显式 import `Calendar`、`Setting`(若页面未自动导入)。
- [ ] **Step 2: `handleMonthImport`**
先拉模板列表;空则 `ElMessage.warning('请先维护月初模板')` 并打开维护弹窗;否则打开导入弹窗。解包方式与本页现有 `request` 返回习惯一致。
- [ ] **Step 3: 挂载组件**
```vue
<QcMaterialMonthTemplateDialog v-model="monthTemplateVisible" />
<QcMaterialMonthImportDialog
v-model="monthImportVisible"
@success="handleMonthImportSuccess"
/>
```
成功回调刷新批次列表(现有 `loadData` / 等价函数)。
- [ ] **Step 4: 本地 UI 冒烟**
1. 打开质检物料库
2. 维护模板 1~2 行
3. 月初导入 → 改日期看编号 → 确认
4. 列表出现新批次;同月再导一次仍成功
- [ ] **Step 5: 更新图谱**
```bash
./scripts/update-graph.sh all
```
- [ ] **Step 6: Commit**
```bash
git add web-admin/src/views/quality-inspection/qc-material/index.vue \
web-admin/src/views/quality-inspection/qc-material/QcMaterialMonthTemplateDialog.vue \
web-admin/src/views/quality-inspection/qc-material/QcMaterialMonthImportDialog.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): wire month import into qc material library toolbar
EOF
)"
```
---
### Task 11: 验收对照 Spec
- [ ] **Step 1: 对照清单**
| Spec 项 | 验证 |
|---------|------|
| 独立模板表 | SQL + model |
| 维护弹窗 CRUD | Task 8 |
| 两步预览确认 | Task 9–10 |
| RK 编号 | Task 3 + preview |
| 自动建物资 | confirm ensure |
| 同月可多次 | 手工冒烟 |
| 复用批量入库 | confirm → batch_stock_in |
| 仅质检 | 无其它模块改动 |
- [ ] **Step 2: 跑测试**
```bash
cd backend && source venv/bin/activate && pytest tests/test_qc_material_stock_in_no.py tests/test_qc_material_month_template.py -v
```
- [ ] **Step 3: 全部通过后可将 Spec 状态改为「已实现」**
---
## Spec coverage self-check
| Spec 章节 | Plan Task |
|-----------|-----------|
| §3 模板表 | Task 1–2 |
| §4.1 维护 | Task 5–6, 8, 10 |
| §4.2 导入预览确认 | Task 5–6, 9–10 |
| §4.3 编号 | Task 3 |
| §5 confirm 薄封装 | Task 5–6 |
| §6 工具栏 / 弹窗 | Task 8–10 |
| §7 停用物资 / 回滚 | Task 5 ensure + batch_stock_in |
| §8 测试 | Task 3, 5, 11 |
| 非目标 | 无对应 Task(刻意) |
## Type consistency
- 预览/确认:`stockInNo`, `expireDate`, `catalogNo`, `batchNo`, `unitPrice`, `qty`
- 模板数量字段:`inQty`;确认映射为 `qty`
@@ -0,0 +1,235 @@
# 质检物料库 · 月初固定导入设计
> 日期: 2026-08-19
> 状态: 已实现(待库表执行与联调验收)
> 父模块: 质量检测 → 质检物料库
> 相关: `docs/superpowers/specs/2026-06-11-qc-material-batch-inbound-design.md`
## 1. 背景与目标
### 现状
质检物料库(`/#/quality-group/quality-base/qc-material`)已支持:
- 单条入库 / 批量入库(入库批次:编号、日期、货号、批号、厂家、单价、金额、数量、过期日期等)
- 批量出库、批量废弃
- 批次列表、变动记录、库存盘点等
业务侧每月初会固定入库一批物品,字段大体相同,目前需反复手工录入,效率低。
### 目标
1. 提供租户可维护的 **月初入库模板**(固定那一批物品,用户可自行增删改)
2. 提供 **月初导入**:按模板展开预览 → 用户可改 → 确认后走现有批量入库链路,生成入库批次与变动流水
3. 预览时按规则 **默认生成入库编号**(可改);物资不存在时可按模板信息 **自动创建**
### 已确认决策
| 问题 | 决策 |
|------|------|
| 导入本质 | 模板驱动的批量入库(非 Excel 文件导入) |
| 模板字段完整度 | 尽量带齐现有入库字段;每月主要改入库日期 / 编号,其余可微调 |
| 维护入口 | 仍在质检物料库页:工具栏「月初导入」+「维护月初模板」弹窗 |
| 同月重复导入 | 允许随时多次执行,系统不拦截 |
| 入库编号 | 预览时默认生成 `RK + YYYYMMDD + 3位序号`,用户可改(现网手工录入,本期补生成能力) |
| 物资关系 | 可关联已有物资;也可只填物资信息,确认入库时若不存在则自动创建 |
| 执行交互 | 两步:打开预览(可改)→ 确认才真正入库 |
| 本期范围 | 仅质检物料库 |
| 实现路径 | 独立模板表 + 复用现有批量入库 API |
### 非目标
- 定时/cron 自动入库
- 生产物料库 / 市场物料库同步改造
- 同月幂等或「每月只能导一次」
- 改造现有单条入库弹窗的编号生成(可后续复用同一生成函数)
- Excel 文件上传导入
- 模板行携带质检附件 URL
---
## 2. 架构与数据流
```
维护月初模板 (CRUD)
↓
biz_qc_material_month_template(租户级模板行)
↓
月初导入预览(选入库日期 → 展开 + 生成编号 → 可改可删行)
↓
现有批量入库 API(必要时同事务 ensure 物资)
↓
biz_qc_material_stock_batch + biz_qc_material.stock_qty + biz_qc_material_usage
```
原则:
- **模板不直接改库存**;只有确认入库才写批次与流水
- **预览中的增删改不回写模板**(仅影响本次导入)
- 库存一致性完全依赖现有 `batch_stock_in` 事务语义(任一行失败整单回滚)
---
## 3. 数据模型
### 3.1 新表 `biz_qc_material_month_template`
一行 = 模板中的一条固定入库明细。字段对齐入库批次(不含入库编号 / 入库日期)。
| 字段 | 说明 |
|------|------|
| `id` | VARCHAR(64) UUID PK |
| `material_id` | 可空;关联已有 `biz_qc_material.id` |
| `material_code` | 物资编码(关联时冗余;待建物资时必填) |
| `material_name` | 物资名称 |
| `spec_model` | 规格型号 |
| `unit` | 单位 |
| `catalog_no` | 货号(必填,与入库一致) |
| `batch_no` | 批号(必填) |
| `manufacturer` | 厂家(必填) |
| `unit_price` | 单价(元,必填) |
| `amount` | 金额(元,必填;维护时可按单价×数量默认) |
| `in_qty` | 默认入库数量(>0) |
| `expire_date` | 默认过期日期(必填,与现网批次一致) |
| `remark` | 备注 |
| 公共字段 | `tenant_no`, `order_num`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` |
**规则:**
- 租户隔离:所有读写按当前用户 `tenant_no` 过滤 / 写入
- 软删除:`del_flag=1`
- 列表默认按 `order_num ASC, create_time ASC`
- 若填写 `material_id`:保存时校验物资存在且未删除;建议同步冗余编码/名称/规格/单位(物资主数据变更后,模板冗余可被用户手动刷新或下次编辑覆盖)
- 若未填 `material_id`:`material_code`、`material_name` 必填;导入确认时按 `tenant_no + material_code` 查找启用物资,找不到则创建(`stock_qty=0`,`status=1`)
SQL 脚本落在 `docs/sql/2026-08-19-qc-material-month-template.sql`(实现阶段)。
---
## 4. 业务流程
### 4.1 维护月初模板
入口:质检物料库 · 批次列表工具栏「维护月初模板」。
- 弹窗内表格展示当前租户全部模板行
- 支持新增一行、编辑、删除(软删)、调整顺序(`order_num`)
- 选择已有物资:下拉选启用物资,带出编码/名称/规格/单位
- 手填新物资:清空 `material_id`,填编码/名称等;编码在模板内建议唯一(同租户模板行不重复 `material_code`,避免混淆)
- 不维护入库编号、入库日期
### 4.2 月初导入
1. 用户点击「月初导入」
2. 若模板行数为 0:提示先维护模板,并引导打开维护弹窗;不打开空预览
3. 打开预览弹窗:
- 顶部「本次入库日期」默认今天,可改
- 调用 preview:按模板行展开明细,并为每行生成默认 `stock_in_no`
- 用户可改任意字段、删除本行(不回写模板);**本期不要求在预览中新增模板外行**(需要额外物资用普通入库/批量入库)
4. 确认入库:
- 对每行:`ensure_material`(已有 id 则用;否则按编码查/建)
- 组装与现有批量入库相同的 payload
- 调用现有批量入库逻辑(同一事务)
5. 成功:关闭弹窗、刷新批次列表、提示成功条数
### 4.3 入库编号生成
格式:`RK` + `YYYYMMDD`(取自本次入库日期)+ **3 位十进制序号**(左补零),例:`RK20260819001`。
算法(preview 与最终提交前均可调用):
1. 取租户内当日(按入库日期对应的 `YYYYMMDD`)已存在且匹配 `RK{YYYYMMDD}%` 的 `stock_in_no`,解析最大序号
2. 从 `max+1` 起为预览中的 N 行连续分配
3. 提交时仍做租户内唯一校验;若冲突(并发入库),返回明确错误,用户改编号后重试
说明:现网单条/批量入库仍为手工录入;本能力仅服务月初导入预览默认值。生成函数宜放在后端可复用处,便于日后单条入库复用。
---
## 5. API 设计
前缀与现有质检物料一致:`/web/admin/...`,经现有鉴权 / 租户中间件。
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/qc-material-month-templates` | 当前租户模板列表 |
| POST | `/qc-material-month-templates` | 新增一行 |
| PUT | `/qc-material-month-templates/{id}` | 更新 |
| DELETE | `/qc-material-month-templates/{id}` | 软删 |
| POST | `/qc-material-month-templates/preview` | Body: `{ stockInDate }` → 展开行 + 默认编号 |
| POST | `/qc-materials/batch-stock-in`(已有) | 确认入库;前端也可走等价 `/qc-material-batches/batch-stock-in` |
**确认入库包装(推荐实现方式):**
- 新增薄封装 `POST /qc-material-month-templates/confirm`:接收预览编辑后的明细列表 + `stockInDate`;服务端 `ensure_material` 后调用已有 `batch_stock_in`
- 或:前端先调 ensure/创建物资 API 再调批量入库——**不推荐**(非原子)
本设计采用 **`confirm` 薄封装**,保证「建物资 + 入库」同事务。
**权限:** 与质检物料库入库相同(能入库即可维护模板与月初导入);不新增独立菜单。按钮可见性跟随现有入库能力。
---
## 6. 前端交互
页面:`web-admin/src/views/quality-inspection/qc-material/index.vue`(批次列表工具栏)。
- 工具栏顺序建议:已选 N 项 → **入库**(唯一 `type="primary"`)→ 批量出库 / 批量废弃(现有)→ **月初导入** → **维护月初模板**
- 两个弹窗:对齐现有批量入库弹窗风格(`label-position="top"` / 表格明细 / 无 backdrop blur)
- 维护弹窗:保存单行即时调 API,或底部统一保存——实现阶段优先 **单行保存 + 删除确认**,与现有 CRUD 习惯一致
- 预览弹窗:金额随单价×数量联动规则对齐现有入库(未手工改金额则重算)
- 按钮须带 Element Plus 图标(工程 UI 约定)
界面模式:对照批量入库弹窗(P 系列选型在实现计划中引用 `docs/web-admin-ui-pattern-index.md`)。
---
## 7. 校验与错误处理
| 场景 | 行为 |
|------|------|
| 模板为空点导入 | 警告提示,不打开预览 |
| 模板行缺必填 | 保存模板时拦截 |
| 关联物资已删除/停用 | 维护保存时警告;预览时标出该行,确认前须改绑或去掉该行 |
| 待建物资编码已存在 | `ensure` 时绑定已有物资,不重复创建 |
| 待建物资编码冲突名称不同 | 绑定已有物资并以已有主数据为准;备注中可不强制覆盖名称(避免静默改主数据) |
| 入库编号重复 | 整单失败回滚,提示冲突编号 |
| 数量 ≤ 0 / 缺必填批次字段 | 与现有批量入库一致,整单失败 |
| 并发同日多用户预览 | 允许;提交时唯一校验兜底 |
`source_type`:确认入库产生的流水仍为现有手动入库类型(如 `manual_in`);若需区分「月初导入」,可新增 `month_template_in`——**本期默认仍用 `manual_in`**,避免报表分叉;若产品后续要统计,再加枚举。
---
## 8. 测试要点
- 模板 CRUD 租户隔离与软删
- 空模板导入被拦截
- 预览编号:同日多行连续;跨日日期切换后编号日期段正确
- 关联已有物资导入成功,库存与流水正确
- 仅填编码的新物资:首次导入自动创建并入库;第二次导入复用同一物资
- 预览删行不影响模板
- 人为制造编号冲突时整单回滚、库存不变
- 停用物资关联行:确认被拒绝或强制用户处理
---
## 9. 组件边界(便于实现拆分)
| 单元 | 职责 | 依赖 |
|------|------|------|
| 模板 CRUD API/CRUD | 模板行持久化 | `BizQcMaterialMonthTemplate` |
| preview 服务 | 展开 + 编号生成 | 模板表 + 已有 `stock_in_no` |
| confirm 服务 | ensure 物资 + 调 `batch_stock_in` | `qc_material` + `qc_material_stock_batch` |
| 前端维护弹窗 | 模板编辑 UX | 模板 API |
| 前端导入弹窗 | 预览编辑 UX | preview + confirm API |
---
## 10. 开放问题(实现前可再定,有默认)
| 项 | 默认 |
|----|------|
| 预览是否允许临时加行 | 否(用普通批量入库) |
| 流水 `source_type` | 继续 `manual_in` |
| 单条入库是否同步默认编号 | 否(仅月初导入) |