25 KiB
客户装/送货物流默认记忆 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
-- 运输计划 · 客户装/送货物流默认记忆
-- 设计: 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
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 之后插入:
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
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 条目)
"""客户装/送货默认记忆:条目合并规则"""
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 / 批量)
"""客户装/送货物流默认记忆"""
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
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
"""客户装/送货物流默认记忆 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
"""客户装/送货物流默认记忆 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:
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
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
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(...) 之后加入:
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() 之前:
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
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附近新增
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
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
import { customerStopPrefApi } from '@/api/pl/index'
import { pickBxData } from '@/utils/http/bx'
- Step 2: 增加清空物流字段 + 异步应用记忆
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:
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
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。
示例核心:
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
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)
- 库已执行 migration
- 新建计划:装货选客户 A → 无记忆字段空 → 填地址/联系方式/注意事项 → 保存
- 再建计划:装货再选 A → 带出上次值
- 同一客户作送货 → 不带出装货记忆;送货保存后下次送货带出
- 编辑改地址保存 → 同事/新窗口再选同客户同角色 → 新值
- 打开编辑不换客户 → 不被记忆覆盖
- (可选)临时让 upsert 抛错 → 计划仍保存成功
- Step 3: 更新前端知识图谱
Run: ./scripts/update-graph.sh frontend(若改了 backend 模型/服务再跑 backend 或 all)
- Step 4: 最终 commit(若有残留未提交)
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)。