feat(pl): hard-block over-limit contract qty on arrange and remind after success
Validate arranged+this plan qty against fixed contract product quantities on transport arrange; return post-success remaining/framework cumulative reminders. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
d326996976
commit
3011cb9dae
10 files changed
+1424
-8
No files matched your search
@@ -0,0 +1,276 @@
|
||||
# 运输计划安排 · 合同数量硬拦与成功后提醒 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:** 在 `POST /transport/order/arrange/{plan_id}` 写运输前调用校验(复用 `pl_contract_plan_qty` 汇总口径);超限 `422`;成功则在 `TransportArrangeResult.contractQtyReminder` 带回明细,前端成功后 `ElMessageBox.alert`。
|
||||
|
||||
**Tech Stack:** FastAPI / SQLAlchemy / Pydantic v2;Vue 3 + Element Plus;pytest
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-30-pl-plan-contract-qty-check-design.md`
|
||||
**Demo:** `docs/fresh-mint-pl-plan-contract-qty-check-demo.html`
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `backend/app/services/pl_contract_plan_qty.py` | 纯函数:有效合计、是否固定量、评估 violations/reminders;编排:按计划收集合同校验 |
|
||||
| `backend/app/schemas/pl/transport.py` | `ContractQtyReminder*` + `TransportArrangeResult` 扩展 |
|
||||
| `backend/app/api/pl/transport.py` | arrange 前 assert;result 附带 reminder |
|
||||
| `backend/tests/test_pl_contract_qty_arrange_check.py` | 纯函数 +(可选)编排单测 |
|
||||
| `web-admin/src/api/pl/index.ts` | TS 类型 |
|
||||
| `web-admin/src/utils/pl-contract-qty-reminder.ts` | 格式化超额/提醒文案 |
|
||||
| `web-admin/src/composables/usePlPlanArrangeSubmit.ts` 或调用方 | 成功后弹提醒;422 超额弹窗 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 纯函数 — 有效合计与产品判定
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/pl_contract_plan_qty.py`
|
||||
- Test: `backend/tests/test_pl_contract_qty_arrange_check.py`
|
||||
|
||||
- [ ] **Step 1: 写失败单测**
|
||||
|
||||
```python
|
||||
from app.services.pl_contract_plan_qty import (
|
||||
effective_arranged_total,
|
||||
contract_product_has_fixed_qty,
|
||||
evaluate_product_qty_for_arrange,
|
||||
)
|
||||
|
||||
def test_effective_arranged_total_subtracts_old():
|
||||
assert effective_arranged_total(arranged_total=100, old_plan_qty=30, new_plan_qty=40) == 110.0
|
||||
|
||||
def test_framework_customer_demand_not_fixed():
|
||||
assert contract_product_has_fixed_qty(
|
||||
contract_type="framework_sale", quantity=0, quantity_mode="customer_demand"
|
||||
) is False
|
||||
|
||||
def test_sale_positive_qty_is_fixed():
|
||||
assert contract_product_has_fixed_qty(
|
||||
contract_type="sale", quantity=100, quantity_mode="fixed"
|
||||
) is True
|
||||
|
||||
def test_evaluate_overflow_violation():
|
||||
r = evaluate_product_qty_for_arrange(
|
||||
has_fixed_qty=True,
|
||||
contract_qty=100,
|
||||
arranged_total=80,
|
||||
old_plan_qty=0,
|
||||
new_plan_qty=30,
|
||||
)
|
||||
assert r["kind"] == "violation"
|
||||
assert r["overflowQty"] == 10.0
|
||||
|
||||
def test_evaluate_remaining_reminder():
|
||||
r = evaluate_product_qty_for_arrange(
|
||||
has_fixed_qty=True,
|
||||
contract_qty=200,
|
||||
arranged_total=150,
|
||||
old_plan_qty=0,
|
||||
new_plan_qty=40,
|
||||
)
|
||||
assert r["kind"] == "remaining"
|
||||
assert r["remainingQty"] == 10.0
|
||||
|
||||
def test_evaluate_framework_cumulative():
|
||||
r = evaluate_product_qty_for_arrange(
|
||||
has_fixed_qty=False,
|
||||
contract_qty=None,
|
||||
arranged_total=126.5,
|
||||
old_plan_qty=0,
|
||||
new_plan_qty=28,
|
||||
)
|
||||
assert r["kind"] == "framework_cumulative"
|
||||
assert r["arrangedTotalQty"] == 154.5
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测确认失败**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_pl_contract_qty_arrange_check.py -q
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 实现纯函数(同文件末尾或清晰分区)**
|
||||
|
||||
- `effective_arranged_total(arranged_total, old_plan_qty, new_plan_qty) -> float`(round 3)
|
||||
- `contract_product_has_fixed_qty(contract_type, quantity, quantity_mode) -> bool`
|
||||
- 框架 + `customer_demand` → False
|
||||
- quantity 有效且 `> 0` → True
|
||||
- 否则 False
|
||||
- `evaluate_product_qty_for_arrange(...)` → `{kind, ...}`
|
||||
- fixed 且 effective > contractQty → violation
|
||||
- fixed 且不超 → remaining
|
||||
- 非 fixed 且 new_plan_qty > 0 → framework_cumulative
|
||||
- new_plan_qty == 0 → skip(返回 None 或 kind=skip)
|
||||
|
||||
- [ ] **Step 4: 跑测通过并 commit**
|
||||
|
||||
```bash
|
||||
pytest tests/test_pl_contract_qty_arrange_check.py -q
|
||||
git add backend/app/services/pl_contract_plan_qty.py backend/tests/test_pl_contract_qty_arrange_check.py
|
||||
git commit -m "feat(pl): add contract qty arrange check pure helpers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 编排 — 按计划收集 violations / reminders
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/pl_contract_plan_qty.py`
|
||||
- Test: `backend/tests/test_pl_contract_qty_arrange_check.py`(可用 SimpleNamespace / mock 聚合)
|
||||
|
||||
- [ ] **Step 1: 实现 `collect_plan_contract_qty_checks(db, tenant_no, plan_id)`**
|
||||
|
||||
逻辑概要:
|
||||
|
||||
1. 加载计划;查本计划全部 `PlPlanContract`(及遗留 `plan.contract_id`)
|
||||
2. 按 `contract_id` 分组;对每个合同:
|
||||
- `aggregate_contract_product_plan_qtys(...)` 得 arrangedTotal
|
||||
- 加载 CRM 合同 + 产品行(`CrmContract` / `CrmContractProduct`)
|
||||
- 计算本计划在该合同 scope 下各 product 的 **old/new**(同为当前库内 plan_qty;校验时「旧」=「新」均为当前值即可——因为 arrangedTotal 在首次安排前不含本计划,调整时含本计划旧量;`effective = arranged - old + new` 且 old=new=当前库内量 → `effective = arranged`。
|
||||
**重要纠正:** 调整时用户可能已先「保存」改过 plan_qty,则库内已是新量,arrangedTotal 仍含**保存前**旧量吗?
|
||||
→ 若先保存再 arrange:库内已是新量,但 arrangedTotal 按库内段产品汇总,**已含新量**,则 `old` 与 `new` 都应取当前库内量,`effective = arranged` 正确(扣新加新抵消)。
|
||||
→ 若未先保存(仅列表安排、数量未改):同样 old=new=库内量。
|
||||
→ **结论:在「安排前计划已落库」前提下,`old_plan_qty = new_plan_qty = 本计划对该合同 scope 的当前 plan_qty 汇总`,`effective = arrangedTotal`。**
|
||||
Spec 中的「扣旧加新」在「保存并安排先落库」后数学上等价于直接用含本计划的 arrangedTotal;若本计划尚不在已安排桶(首次),arrangedTotal 不含本计划,则必须 `effective = arrangedTotal + new`(old=0 因不在桶内)。
|
||||
|
||||
实现时用:
|
||||
|
||||
```python
|
||||
in_bucket = _bucket_for_plan(plan...) is not None
|
||||
plan_qty_on_contract = sum_qtys_for_scope(... 仅本计划对该合同的 scope ...)
|
||||
if in_bucket:
|
||||
effective = arranged_total # 已含当前库内量
|
||||
old = plan_qty_on_contract
|
||||
new = plan_qty_on_contract
|
||||
else:
|
||||
effective = arranged_total + plan_qty_on_contract
|
||||
old = 0
|
||||
new = plan_qty_on_contract
|
||||
```
|
||||
|
||||
对每个合同产品 × 本计划在该合同上有量的 product_id(或合同所有产品中本计划涉及的)调用 `evaluate_product_qty_for_arrange`。
|
||||
|
||||
3. 返回 `(violations: list[dict], reminders: list[dict])`
|
||||
|
||||
- [ ] **Step 2: `assert_contract_qty_for_arrange`** — violations 非空则 `HTTPException(422, detail={code, message, items})`
|
||||
|
||||
- [ ] **Step 3: 单测覆盖 in_bucket / not in_bucket / 多产品**
|
||||
|
||||
- [ ] **Step 4: commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(pl): collect and assert contract qty on arrange"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Schema + arrange API 接线
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/schemas/pl/transport.py`
|
||||
- Modify: `backend/app/api/pl/transport.py`
|
||||
|
||||
- [ ] **Step 1: 增加 Pydantic 模型**
|
||||
|
||||
```python
|
||||
class ContractQtyReminderItem(BaseModel):
|
||||
contract_id: str = Field(..., alias="contractId")
|
||||
contract_no: Optional[str] = Field(None, alias="contractNo")
|
||||
contract_type: Optional[str] = Field(None, alias="contractType")
|
||||
stop_kind: str = Field(..., alias="stopKind") # load | delivery
|
||||
product_id: str = Field(..., alias="productId")
|
||||
product_name: Optional[str] = Field(None, alias="productName")
|
||||
mode: str # remaining | framework_cumulative
|
||||
contract_qty: Optional[float] = Field(None, alias="contractQty")
|
||||
arranged_total_qty: float = Field(..., alias="arrangedTotalQty")
|
||||
remaining_qty: Optional[float] = Field(None, alias="remainingQty")
|
||||
this_plan_qty: float = Field(..., alias="thisPlanQty")
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
class ContractQtyReminder(BaseModel):
|
||||
items: list[ContractQtyReminderItem] = Field(default_factory=list)
|
||||
model_config = {"populate_by_name": True}
|
||||
```
|
||||
|
||||
`TransportArrangeResult` 增加 `contract_qty_reminder: Optional[ContractQtyReminder] = Field(None, alias="contractQtyReminder")`
|
||||
|
||||
- [ ] **Step 2: `arrange_transport` 在危化证校验后、写运输前:**
|
||||
|
||||
```python
|
||||
from app.services.pl_contract_plan_qty import (
|
||||
assert_contract_qty_for_arrange,
|
||||
build_contract_qty_reminder_for_plan,
|
||||
)
|
||||
assert_contract_qty_for_arrange(db, tenant_no=tenant_no, plan_id=plan_id)
|
||||
```
|
||||
|
||||
成功 `_build_arrange_result` 后:
|
||||
|
||||
```python
|
||||
result.contract_qty_reminder = build_contract_qty_reminder_for_plan(db, tenant_no=tenant_no, plan_id=plan_id)
|
||||
```
|
||||
|
||||
注意:assert 时计划尚未变为 arranged;reminder 在 commit 后计划状态已更新为 arranged,应用**同一 evaluate 公式**基于「即将/已经计入」的有效合计,避免剩余量跳变。推荐:assert 与 reminder 在写运输**前**用同一 `collect` 结果——violations 则抛错;无 violations 则缓存 `reminders`,成功后原样写入 result(不要在 commit 后再算一遍导致口径漂移)。
|
||||
|
||||
- [ ] **Step 3: 手动或集成测 arrange 超限返回 422**
|
||||
|
||||
- [ ] **Step 4: commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(pl): enforce contract qty on transport arrange API"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 前端类型与弹窗
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/api/pl/index.ts`
|
||||
- Create: `web-admin/src/utils/pl-contract-qty-reminder.ts`
|
||||
- Modify: 安排成功调用链(`usePlPlanArrangeSubmit.ts` 的调用方:`pl-plan-form-dialog`、列表安排弹窗等)
|
||||
|
||||
- [ ] **Step 1: 扩展 `TransportArrangeResult` 类型**
|
||||
|
||||
- [ ] **Step 2: 工具函数**
|
||||
|
||||
```ts
|
||||
export function formatContractQtyViolationMessage(detail: unknown): string
|
||||
export function formatContractQtyReminderMessage(reminder: ContractQtyReminder): string
|
||||
export async function showContractQtyReminderIfAny(reminder?: ContractQtyReminder | null)
|
||||
export async function showContractQtyViolationIfMatch(error: unknown): Promise<boolean>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `submitPlanArrange` 成功后调用 `showContractQtyReminderIfAny`;catch 中优先 `showContractQtyViolationIfMatch`**
|
||||
|
||||
找到所有 `transportApi.arrange` / `submitPlanArrange` 调用点一并接上。
|
||||
|
||||
- [ ] **Step 4: 文案对齐 Demo(知道了 / 无法安排运输)**
|
||||
|
||||
- [ ] **Step 5: commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(web-admin): show contract qty block and post-arrange reminders"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 验证与图谱
|
||||
|
||||
- [ ] `pytest tests/test_pl_contract_qty_arrange_check.py tests/test_pl_contract_plan_qty.py -q`
|
||||
- [ ] 前端相关文件 lint(若改 vue)
|
||||
- [ ] `./scripts/update-graph.sh backend`(或 frontend,视改动)
|
||||
- [ ] 对照 spec 测试清单手工过一遍关键路径
|
||||
|
||||
---
|
||||
|
||||
## 执行备注
|
||||
|
||||
- 最小改动;不改合同详情三列语义
|
||||
- 草稿仅保存不调用 collect/assert
|
||||
- YAGNI:不做独立预检 API
|
||||
Reference in new issue
Block a user