Files
shanxia2.0/docs/superpowers/plans/2026-08-26-pl-customer-stop-pref.md
T
2026-08-27 14:23:18 +08:00

794 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 客户装/送货物流默认记忆 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`)。