794 lines
25 KiB
Markdown
794 lines
25 KiB
Markdown
# 客户装/送货物流默认记忆 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:** 运输计划选装货/送货客户时自动带出该客户在该角色下上次保存的地址、经纬度、联系方式、注意事项;计划新建/编辑保存后租户内 upsert 记忆。
|
||
|
||
**Architecture:** 新建表 `pl_customer_stop_pref`(唯一键 tenant + customer + stop_type)。读接口供表单选客户;写不单独暴露给业务员,在 `create_plan_full` / `update_plan` 的 multi-stop 保存路径中,于 `replace_plan_stops` 之后、`db.commit()` 之前用 savepoint upsert(失败只打日志,不阻断计划保存)。前端在 load/delivery 选客户时先清空再 GET 覆盖。
|
||
|
||
**Tech Stack:** MySQL 5.7、FastAPI、SQLAlchemy、Pydantic v2、Vue 3、Element Plus
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-08-26-pl-customer-stop-pref-design.md`
|
||
|
||
---
|
||
|
||
## 文件结构
|
||
|
||
| 操作 | 文件 | 职责 |
|
||
|------|------|------|
|
||
| Create | `docs/sql/migrations/2026-08-26-pl-customer-stop-pref.sql` | DDL |
|
||
| Create | `backend/app/schemas/pl/customer_stop_pref.py` | 读响应 Schema |
|
||
| Create | `backend/app/services/pl_customer_stop_pref_service.py` | get / upsert / 从 load+deliveries 批量写入 |
|
||
| Create | `backend/app/api/pl/customer_stop_pref.py` | `GET /customer-stop-pref` |
|
||
| Create | `backend/tests/test_pl_customer_stop_pref_service.py` | 纯函数/合并逻辑单测 |
|
||
| Modify | `backend/app/models/pl_models.py` | 模型 `PlCustomerStopPref` |
|
||
| Modify | `backend/app/api/pl/__init__.py` | 注册 router |
|
||
| Modify | `backend/app/api/pl/plan.py` | create-full / update 挂钩 upsert |
|
||
| Modify | `web-admin/src/api/pl/index.ts` | `customerStopPrefApi` |
|
||
| Modify | `web-admin/src/components/business/pl-plan-load-section/index.vue` | 选客户拉 load 记忆 |
|
||
| Modify | `web-admin/src/components/business/pl-plan-delivery-section/index.vue` | 选客户拉 delivery 记忆 |
|
||
|
||
说明:实际 HTTP 前缀与现有 PL 一致为 `/transport/...`(前端 `request` 基路径已含 `/api`),对应 `GET /transport/customer-stop-pref`(非 spec 草稿中的 `/api/admin/pl/...` 字面路径)。
|
||
|
||
---
|
||
|
||
### Task 1: DDL 迁移
|
||
|
||
**Files:**
|
||
- Create: `docs/sql/migrations/2026-08-26-pl-customer-stop-pref.sql`
|
||
|
||
- [ ] **Step 1: 写迁移 SQL**
|
||
|
||
```sql
|
||
-- 运输计划 · 客户装/送货物流默认记忆
|
||
-- 设计: docs/superpowers/specs/2026-08-26-pl-customer-stop-pref-design.md
|
||
|
||
SET NAMES utf8mb4;
|
||
|
||
CREATE TABLE IF NOT EXISTS `pl_customer_stop_pref` (
|
||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||
`customer_id` varchar(64) NOT NULL COMMENT '客户ID',
|
||
`customer_name` varchar(200) DEFAULT NULL COMMENT '客户名称',
|
||
`stop_type` varchar(20) NOT NULL COMMENT 'load-装货 delivery-送货',
|
||
`address` varchar(500) DEFAULT NULL COMMENT '地址',
|
||
`lng` decimal(10,6) DEFAULT NULL COMMENT '经度',
|
||
`lat` decimal(10,6) DEFAULT NULL COMMENT '纬度',
|
||
`contact` varchar(200) DEFAULT NULL COMMENT '联系方式',
|
||
`notes` varchar(500) DEFAULT NULL COMMENT '注意事项',
|
||
`tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
|
||
`order_num` int(11) DEFAULT NULL 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` smallint(6) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
|
||
PRIMARY KEY (`id`) USING BTREE,
|
||
UNIQUE KEY `uk_pl_cust_stop_pref` (`tenant_no`, `customer_id`, `stop_type`) USING BTREE,
|
||
KEY `idx_tenant` (`tenant_no`) USING BTREE
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运输计划-客户装卸货默认记忆';
|
||
```
|
||
|
||
- [ ] **Step 2: 在本地/目标库执行迁移**(实现者按环境执行;计划本身不假定已执行)
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add docs/sql/migrations/2026-08-26-pl-customer-stop-pref.sql
|
||
git commit -m "$(cat <<'EOF'
|
||
docs: add pl_customer_stop_pref migration for stop logistics memory
|
||
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: SQLAlchemy 模型
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/models/pl_models.py`(在 `PlTransportAddress` 类后新增)
|
||
|
||
- [ ] **Step 1: 新增模型类**
|
||
|
||
在 `PlTransportAddress` 之后插入:
|
||
|
||
```python
|
||
class PlCustomerStopPref(Base):
|
||
"""客户装/送货物流默认记忆 pl_customer_stop_pref"""
|
||
|
||
__tablename__ = "pl_customer_stop_pref"
|
||
|
||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||
customer_id = Column(String(64), nullable=False, comment="客户ID")
|
||
customer_name = Column(String(200), nullable=True, comment="客户名称")
|
||
stop_type = Column(String(20), nullable=False, comment="load-装货 delivery-送货")
|
||
address = Column(String(500), nullable=True, comment="地址")
|
||
lng = Column(DECIMAL(10, 6), nullable=True, comment="经度")
|
||
lat = Column(DECIMAL(10, 6), nullable=True, comment="纬度")
|
||
contact = Column(String(200), nullable=True, comment="联系方式")
|
||
notes = Column(String(500), nullable=True, comment="注意事项")
|
||
tenant_no = Column(String(64), nullable=False, default="shanxia2.0", 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="删除标记:0-正常 1-已删除")
|
||
```
|
||
|
||
(确认文件顶部已 `from datetime import datetime` 且已导入 `DECIMAL`、`Column` 等,与同文件其它模型一致。)
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add backend/app/models/pl_models.py
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(pl): add PlCustomerStopPref model
|
||
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Service + 单测(合并顺序 / 跳过空客户)
|
||
|
||
**Files:**
|
||
- Create: `backend/app/services/pl_customer_stop_pref_service.py`
|
||
- Create: `backend/tests/test_pl_customer_stop_pref_service.py`
|
||
|
||
- [ ] **Step 1: 写失败用例(纯函数:从段列表抽出 upsert 条目)**
|
||
|
||
```python
|
||
"""客户装/送货默认记忆:条目合并规则"""
|
||
from app.services.pl_customer_stop_pref_service import build_stop_pref_upsert_items
|
||
|
||
|
||
def test_build_items_skips_empty_customer():
|
||
items = build_stop_pref_upsert_items(
|
||
load={"customer_id": "", "address": "A"},
|
||
deliveries=[{"customer_id": "c1", "address": "D1", "contact": "1", "notes": "n"}],
|
||
)
|
||
assert len(items) == 1
|
||
assert items[0]["stop_type"] == "delivery"
|
||
assert items[0]["customer_id"] == "c1"
|
||
|
||
|
||
def test_build_items_load_and_delivery_independent():
|
||
items = build_stop_pref_upsert_items(
|
||
load={
|
||
"customer_id": "c1",
|
||
"customer_name": "甲",
|
||
"address": "装址",
|
||
"lng": 1.1,
|
||
"lat": 2.2,
|
||
"contact": "装联",
|
||
"notes": "装注",
|
||
},
|
||
deliveries=[
|
||
{
|
||
"customer_id": "c1",
|
||
"customer_name": "甲",
|
||
"address": "送址",
|
||
"contact": "送联",
|
||
"notes": "送注",
|
||
}
|
||
],
|
||
)
|
||
assert len(items) == 2
|
||
by_type = {i["stop_type"]: i for i in items}
|
||
assert by_type["load"]["address"] == "装址"
|
||
assert by_type["delivery"]["address"] == "送址"
|
||
|
||
|
||
def test_build_items_same_delivery_customer_last_wins():
|
||
items = build_stop_pref_upsert_items(
|
||
load=None,
|
||
deliveries=[
|
||
{"customer_id": "c1", "address": "第一段", "contact": "a", "notes": "1"},
|
||
{"customer_id": "c2", "address": "别家", "contact": "b", "notes": "2"},
|
||
{"customer_id": "c1", "address": "最后段", "contact": "c", "notes": "3"},
|
||
],
|
||
)
|
||
delivery_c1 = [i for i in items if i["stop_type"] == "delivery" and i["customer_id"] == "c1"]
|
||
assert len(delivery_c1) == 1
|
||
assert delivery_c1[0]["address"] == "最后段"
|
||
assert delivery_c1[0]["contact"] == "c"
|
||
```
|
||
|
||
- [ ] **Step 2: 跑测确认失败**
|
||
|
||
Run: `cd backend && source venv/bin/activate && pytest tests/test_pl_customer_stop_pref_service.py -v`
|
||
Expected: FAIL(模块/函数不存在)
|
||
|
||
- [ ] **Step 3: 实现 service(含 get / upsert / 批量)**
|
||
|
||
```python
|
||
"""客户装/送货物流默认记忆"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from decimal import Decimal
|
||
from typing import Any, Mapping, Optional, Sequence
|
||
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.utils import generate_uuid
|
||
from app.models.pl_models import PlCustomerStopPref
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
STOP_LOAD = "load"
|
||
STOP_DELIVERY = "delivery"
|
||
|
||
|
||
def _as_str(value: Any) -> str:
|
||
return str(value or "").strip()
|
||
|
||
|
||
def _row_to_item(stop_type: str, row: Mapping[str, Any]) -> Optional[dict]:
|
||
customer_id = _as_str(row.get("customer_id") or row.get("customerId"))
|
||
if not customer_id:
|
||
return None
|
||
lng = row.get("lng")
|
||
lat = row.get("lat")
|
||
return {
|
||
"customer_id": customer_id,
|
||
"customer_name": _as_str(row.get("customer_name") or row.get("customerName")) or None,
|
||
"stop_type": stop_type,
|
||
"address": (row.get("address") if row.get("address") is not None else "") or "",
|
||
"lng": lng,
|
||
"lat": lat,
|
||
"contact": (row.get("contact") if row.get("contact") is not None else "") or "",
|
||
"notes": (row.get("notes") if row.get("notes") is not None else "") or "",
|
||
}
|
||
|
||
|
||
def build_stop_pref_upsert_items(
|
||
*,
|
||
load: Optional[Mapping[str, Any]],
|
||
deliveries: Optional[Sequence[Mapping[str, Any]]],
|
||
) -> list[dict]:
|
||
"""抽出待 upsert 条目;同客户同 stop_type 后者覆盖(保持列表中每种键一条)。"""
|
||
merged: dict[tuple[str, str], dict] = {}
|
||
if load:
|
||
item = _row_to_item(STOP_LOAD, load)
|
||
if item:
|
||
merged[(item["customer_id"], item["stop_type"])] = item
|
||
for row in deliveries or []:
|
||
item = _row_to_item(STOP_DELIVERY, row)
|
||
if item:
|
||
merged[(item["customer_id"], item["stop_type"])] = item
|
||
return list(merged.values())
|
||
|
||
|
||
def get_customer_stop_pref(
|
||
db: Session,
|
||
tenant_no: str,
|
||
customer_id: str,
|
||
stop_type: str,
|
||
) -> Optional[PlCustomerStopPref]:
|
||
cid = _as_str(customer_id)
|
||
st = _as_str(stop_type)
|
||
if not cid or st not in (STOP_LOAD, STOP_DELIVERY):
|
||
return None
|
||
return (
|
||
db.query(PlCustomerStopPref)
|
||
.filter(
|
||
PlCustomerStopPref.tenant_no == tenant_no,
|
||
PlCustomerStopPref.customer_id == cid,
|
||
PlCustomerStopPref.stop_type == st,
|
||
PlCustomerStopPref.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
|
||
|
||
def upsert_customer_stop_pref(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
item: Mapping[str, Any],
|
||
user_id: Optional[str],
|
||
dept_id: Optional[str],
|
||
) -> None:
|
||
customer_id = _as_str(item.get("customer_id"))
|
||
stop_type = _as_str(item.get("stop_type"))
|
||
if not customer_id or stop_type not in (STOP_LOAD, STOP_DELIVERY):
|
||
return
|
||
row = get_customer_stop_pref(db, tenant_no, customer_id, stop_type)
|
||
lng = item.get("lng")
|
||
lat = item.get("lat")
|
||
if row is None:
|
||
row = PlCustomerStopPref(
|
||
id=generate_uuid(),
|
||
tenant_no=tenant_no,
|
||
customer_id=customer_id,
|
||
create_by=user_id,
|
||
create_dept_id=dept_id or "",
|
||
)
|
||
db.add(row)
|
||
row.customer_name = item.get("customer_name")
|
||
row.stop_type = stop_type
|
||
row.address = item.get("address") if item.get("address") is not None else ""
|
||
row.lng = Decimal(str(lng)) if lng is not None and str(lng) != "" else None
|
||
row.lat = Decimal(str(lat)) if lat is not None and str(lat) != "" else None
|
||
row.contact = item.get("contact") if item.get("contact") is not None else ""
|
||
row.notes = item.get("notes") if item.get("notes") is not None else ""
|
||
row.update_by = user_id
|
||
row.del_flag = 0
|
||
|
||
|
||
def upsert_prefs_from_plan_stops(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
load: Optional[Mapping[str, Any]],
|
||
deliveries: Optional[Sequence[Mapping[str, Any]]],
|
||
user_id: Optional[str],
|
||
dept_id: Optional[str],
|
||
) -> None:
|
||
"""供 plan 保存调用;异常由调用方用 savepoint 吞掉。"""
|
||
for item in build_stop_pref_upsert_items(load=load, deliveries=deliveries):
|
||
upsert_customer_stop_pref(
|
||
db,
|
||
tenant_no=tenant_no,
|
||
item=item,
|
||
user_id=user_id,
|
||
dept_id=dept_id,
|
||
)
|
||
|
||
|
||
def safe_upsert_prefs_from_plan_stops(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
load: Optional[Any],
|
||
deliveries: Optional[Sequence[Any]],
|
||
user_id: Optional[str],
|
||
dept_id: Optional[str],
|
||
) -> None:
|
||
"""savepoint 包裹;失败只记日志,不抛给计划主流程。"""
|
||
load_map = None
|
||
if load is not None:
|
||
load_map = load.model_dump(by_alias=False) if hasattr(load, "model_dump") else dict(load)
|
||
delivery_maps = []
|
||
for d in deliveries or []:
|
||
delivery_maps.append(
|
||
d.model_dump(by_alias=False) if hasattr(d, "model_dump") else dict(d)
|
||
)
|
||
try:
|
||
with db.begin_nested():
|
||
upsert_prefs_from_plan_stops(
|
||
db,
|
||
tenant_no=tenant_no,
|
||
load=load_map,
|
||
deliveries=delivery_maps,
|
||
user_id=user_id,
|
||
dept_id=dept_id,
|
||
)
|
||
except Exception:
|
||
logger.exception(
|
||
"upsert customer stop pref failed tenant=%s (plan save continues)",
|
||
tenant_no,
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 4: 跑测通过**
|
||
|
||
Run: `cd backend && source venv/bin/activate && pytest tests/test_pl_customer_stop_pref_service.py -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/app/services/pl_customer_stop_pref_service.py backend/tests/test_pl_customer_stop_pref_service.py
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(pl): add customer stop pref service and unit tests
|
||
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Schema + GET API + 注册路由
|
||
|
||
**Files:**
|
||
- Create: `backend/app/schemas/pl/customer_stop_pref.py`
|
||
- Create: `backend/app/api/pl/customer_stop_pref.py`
|
||
- Modify: `backend/app/api/pl/__init__.py`
|
||
|
||
- [ ] **Step 1: Schema**
|
||
|
||
```python
|
||
"""客户装/送货物流默认记忆 Schema"""
|
||
from decimal import Decimal
|
||
from typing import Optional
|
||
|
||
from pydantic import BaseModel, Field
|
||
|
||
|
||
class CustomerStopPrefRead(BaseModel):
|
||
customer_id: str = Field(alias="customerId")
|
||
stop_type: str = Field(alias="stopType")
|
||
address: Optional[str] = None
|
||
lng: Optional[Decimal] = None
|
||
lat: Optional[Decimal] = None
|
||
contact: Optional[str] = None
|
||
notes: Optional[str] = None
|
||
|
||
model_config = {"from_attributes": True, "populate_by_name": True}
|
||
```
|
||
|
||
- [ ] **Step 2: API**
|
||
|
||
```python
|
||
"""客户装/送货物流默认记忆 API"""
|
||
from typing import Optional
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.database import get_db
|
||
from app.core.db_auth import DbUserContext, get_db_current_user
|
||
from app.core.tenant_context import get_tenant_no
|
||
from app.schemas.pl.customer_stop_pref import CustomerStopPrefRead
|
||
from app.services.pl_customer_stop_pref_service import (
|
||
STOP_DELIVERY,
|
||
STOP_LOAD,
|
||
get_customer_stop_pref,
|
||
)
|
||
|
||
router = APIRouter(tags=["客户装卸货默认记忆"])
|
||
|
||
|
||
@router.get("/customer-stop-pref", response_model=Optional[CustomerStopPrefRead])
|
||
def read_customer_stop_pref(
|
||
customer_id: str = Query(..., alias="customerId"),
|
||
stop_type: str = Query(..., alias="stopType", description="load | delivery"),
|
||
db: Session = Depends(get_db),
|
||
user: DbUserContext = Depends(get_db_current_user),
|
||
) -> Optional[CustomerStopPrefRead]:
|
||
if stop_type not in (STOP_LOAD, STOP_DELIVERY):
|
||
raise HTTPException(status_code=400, detail="stopType 须为 load 或 delivery")
|
||
tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
|
||
row = get_customer_stop_pref(db, tenant_no, customer_id, stop_type)
|
||
if not row:
|
||
return None
|
||
return CustomerStopPrefRead.model_validate(row)
|
||
```
|
||
|
||
- [ ] **Step 3: 注册**
|
||
|
||
在 `backend/app/api/pl/__init__.py`:
|
||
|
||
```python
|
||
from app.api.pl import base_data, vehicle_recharge, vehicle_maintenance, vehicle_urea, transport_address
|
||
from app.api.pl import customer_stop_pref # 新增
|
||
|
||
# 运输通用地址库
|
||
pl_router.include_router(transport_address.router)
|
||
|
||
# 客户装/送货默认记忆
|
||
pl_router.include_router(customer_stop_pref.router)
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add backend/app/schemas/pl/customer_stop_pref.py backend/app/api/pl/customer_stop_pref.py backend/app/api/pl/__init__.py
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(pl): expose GET customer-stop-pref API
|
||
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: 计划 create-full / update 挂钩
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/api/pl/plan.py`
|
||
|
||
- [ ] **Step 1: 增加 import**
|
||
|
||
```python
|
||
from app.services.pl_customer_stop_pref_service import safe_upsert_prefs_from_plan_stops
|
||
```
|
||
|
||
- [ ] **Step 2: 在 `create_plan_full` 中,`replace_plan_stops(...)` 成功之后、`db.commit()` 之前调用**
|
||
|
||
定位现有片段(约 1184–1217 行):`if multi_stop and load_body is not None: replace_plan_stops(...)` 之后加入:
|
||
|
||
```python
|
||
if multi_stop and load_body is not None:
|
||
replace_plan_stops(...) # 已有
|
||
safe_upsert_prefs_from_plan_stops(
|
||
db,
|
||
tenant_no=tenant_no,
|
||
load=load_body,
|
||
deliveries=delivery_bodies,
|
||
user_id=user.id,
|
||
dept_id=user.dept_id,
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 3: 在 `update_plan` 同样位置挂钩**
|
||
|
||
定位约 1623–1636 行 `replace_plan_stops` 之后、`plan.update_by = user.id` / `db.commit()` 之前:
|
||
|
||
```python
|
||
if multi_stop and load_body is not None:
|
||
replace_plan_stops(...) # 已有
|
||
safe_upsert_prefs_from_plan_stops(
|
||
db,
|
||
tenant_no=tenant_no,
|
||
load=load_body,
|
||
deliveries=delivery_bodies,
|
||
user_id=user.id,
|
||
dept_id=user.dept_id,
|
||
)
|
||
```
|
||
|
||
仅 multi-stop 路径写入记忆(与当前表单主路径一致);legacy 无 load/deliveries 的旧写入不写记忆。
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add backend/app/api/pl/plan.py
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(pl): upsert customer stop prefs on plan create/update
|
||
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: 前端 API
|
||
|
||
**Files:**
|
||
- Modify: `web-admin/src/api/pl/index.ts`
|
||
|
||
- [ ] **Step 1: 在 `transportAddressApi` 附近新增**
|
||
|
||
```typescript
|
||
export type CustomerStopPrefStopType = 'load' | 'delivery'
|
||
|
||
export interface CustomerStopPref {
|
||
customerId: string
|
||
stopType: CustomerStopPrefStopType
|
||
address?: string
|
||
lng?: number | null
|
||
lat?: number | null
|
||
contact?: string
|
||
notes?: string
|
||
}
|
||
|
||
export const customerStopPrefApi = {
|
||
get: (params: { customerId: string; stopType: CustomerStopPrefStopType }) =>
|
||
request.get<CustomerStopPref | null>('/transport/customer-stop-pref', { params })
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add web-admin/src/api/pl/index.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(web-admin): add customerStopPrefApi
|
||
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: 装货区选客户带出
|
||
|
||
**Files:**
|
||
- Modify: `web-admin/src/components/business/pl-plan-load-section/index.vue`
|
||
|
||
- [ ] **Step 1: import API 与 pickBxData**
|
||
|
||
```typescript
|
||
import { customerStopPrefApi } from '@/api/pl/index'
|
||
import { pickBxData } from '@/utils/http/bx'
|
||
```
|
||
|
||
- [ ] **Step 2: 增加清空物流字段 + 异步应用记忆**
|
||
|
||
```typescript
|
||
function clearLogisticsFields() {
|
||
patch({
|
||
address: '',
|
||
lng: null,
|
||
lat: null,
|
||
contact: '',
|
||
notes: ''
|
||
})
|
||
}
|
||
|
||
async function applyStopPrefFromCustomer(customerId: string) {
|
||
try {
|
||
const res = await customerStopPrefApi.get({ customerId, stopType: 'load' })
|
||
const pref = pickBxData<CustomerStopPref | null>(res)
|
||
if (!pref) return
|
||
// 防止异步返回时用户已换客户
|
||
if (String(props.modelValue.customerId) !== String(customerId)) return
|
||
patch({
|
||
address: pref.address || '',
|
||
lng: pref.lng ?? null,
|
||
lat: pref.lat ?? null,
|
||
contact: pref.contact || '',
|
||
notes: pref.notes || ''
|
||
})
|
||
} catch {
|
||
// 静默:不阻断选客户
|
||
}
|
||
}
|
||
```
|
||
|
||
(`CustomerStopPref` 从 `@/api/pl/index` 导入类型。)
|
||
|
||
- [ ] **Step 3: 改 `handleCustomerChange`**
|
||
|
||
清空客户时追加清空物流字段;选定客户后在现有 patch(客户/合同/地磅)之后调用 `clearLogisticsFields()` 再 `applyStopPrefFromCustomer`:
|
||
|
||
```typescript
|
||
function handleCustomerChange(customerId?: string) {
|
||
if (!customerId) {
|
||
patch({
|
||
customerId: '',
|
||
customerName: '',
|
||
contracts: [],
|
||
generateDibang: 0,
|
||
dibangDirection: 'none',
|
||
address: '',
|
||
lng: null,
|
||
lat: null,
|
||
contact: '',
|
||
notes: ''
|
||
})
|
||
return
|
||
}
|
||
const customer = customerOptions.value.find((item) => String(item.id) === String(customerId))
|
||
const customerName = customer?.customerName || ''
|
||
const defaults = resolveStopDibangDefaults(customerName, 'load')
|
||
patch({
|
||
customerId: String(customerId),
|
||
customerName,
|
||
contracts: [],
|
||
generateDibang: defaults.generateDibang,
|
||
dibangDirection: defaults.dibangDirection,
|
||
address: '',
|
||
lng: null,
|
||
lat: null,
|
||
contact: '',
|
||
notes: ''
|
||
})
|
||
void applyStopPrefFromCustomer(String(customerId))
|
||
}
|
||
```
|
||
|
||
注意:`applyCustomerFromPicker` 已调用 `handleCustomerChange`,无需再改。
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add web-admin/src/components/business/pl-plan-load-section/index.vue
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(web-admin): autofill load logistics from customer stop pref
|
||
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: 送货区选客户带出
|
||
|
||
**Files:**
|
||
- Modify: `web-admin/src/components/business/pl-plan-delivery-section/index.vue`
|
||
|
||
- [ ] **Step 1: 与装货对称**
|
||
|
||
Import `customerStopPrefApi`、`pickBxData`、`CustomerStopPref`。
|
||
|
||
在 `handleCustomerChange(index, customerId)`(或等价函数)中:
|
||
|
||
- 清空客户 → 清空该段 `address/lng/lat/contact/notes`
|
||
- 选定客户 → 先清空这五项,再 `customerStopPrefApi.get({ customerId, stopType: 'delivery' })`,返回后校验 `modelValue[index].customerId` 仍匹配再 `patchDelivery`
|
||
|
||
`applyCustomerFromPicker` 走同一 `handleCustomerChange`。
|
||
|
||
示例核心:
|
||
|
||
```typescript
|
||
async function applyDeliveryStopPref(index: number, customerId: string) {
|
||
try {
|
||
const res = await customerStopPrefApi.get({ customerId, stopType: 'delivery' })
|
||
const pref = pickBxData<CustomerStopPref | null>(res)
|
||
if (!pref) return
|
||
const current = props.modelValue[index]
|
||
if (!current || String(current.customerId) !== String(customerId)) return
|
||
patchDelivery(index, {
|
||
address: pref.address || '',
|
||
lng: pref.lng ?? null,
|
||
lat: pref.lat ?? null,
|
||
contact: pref.contact || '',
|
||
notes: pref.notes || ''
|
||
})
|
||
} catch {
|
||
// 静默
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add web-admin/src/components/business/pl-plan-delivery-section/index.vue
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(web-admin): autofill delivery logistics from customer stop pref
|
||
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: 验收与图谱
|
||
|
||
- [ ] **Step 1: 后端单测再跑**
|
||
|
||
Run: `cd backend && source venv/bin/activate && pytest tests/test_pl_customer_stop_pref_service.py -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 2: 手工验收(对照 spec §4)**
|
||
|
||
1. 库已执行 migration
|
||
2. 新建计划:装货选客户 A → 无记忆字段空 → 填地址/联系方式/注意事项 → 保存
|
||
3. 再建计划:装货再选 A → 带出上次值
|
||
4. 同一客户作送货 → 不带出装货记忆;送货保存后下次送货带出
|
||
5. 编辑改地址保存 → 同事/新窗口再选同客户同角色 → 新值
|
||
6. 打开编辑不换客户 → 不被记忆覆盖
|
||
7. (可选)临时让 upsert 抛错 → 计划仍保存成功
|
||
|
||
- [ ] **Step 3: 更新前端知识图谱**
|
||
|
||
Run: `./scripts/update-graph.sh frontend`(若改了 backend 模型/服务再跑 `backend` 或 `all`)
|
||
|
||
- [ ] **Step 4: 最终 commit(若有残留未提交)**
|
||
|
||
```bash
|
||
git status
|
||
# 仅提交本功能相关文件
|
||
```
|
||
|
||
---
|
||
|
||
## Spec 覆盖自检
|
||
|
||
| Spec 要求 | Task |
|
||
|-----------|------|
|
||
| 表 `pl_customer_stop_pref` + 唯一键 | Task 1–2 |
|
||
| GET 按 customer + stopType | Task 4 |
|
||
| create/update upsert,空字段覆盖,装送独立,同客户多段后者覆盖 | Task 3、5 |
|
||
| upsert 失败不阻断计划 | Task 3 `safe_upsert_*`、Task 5 |
|
||
| 选客户一律覆盖;先清空再填 | Task 7–8 |
|
||
| 清空客户清空物流字段 | Task 7–8 |
|
||
| 编辑打开不拉记忆 | 未在打开路径调用 GET(Task 7–8 仅选客户) |
|
||
| 租户隔离 | `get_tenant_no` + 查询过滤 |
|
||
| 无管理页 / 无独立写 API | 未建 |
|
||
|
||
## Placeholder 扫描
|
||
|
||
无 TBD/TODO;路径与函数名前后一致(`build_stop_pref_upsert_items`、`safe_upsert_prefs_from_plan_stops`、`customerStopPrefApi`)。
|