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

25 KiB
Raw Blame History

客户装/送货物流默认记忆 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)
  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(若有残留未提交)
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)。