瀏覽代碼

docs: 自部署地图032评估规划与036多店结算spec

- 032 补充 osm 需求评估、模型评估、plan/tasks、模型探针脚本
- 新增 036 同一用户多店结算与同骑手配送 spec/plan
- 新增 026 连锁门店复制商品 App 接入说明文档
qmj 6 天之前
父節點
當前提交
2ae2da3d58

+ 1 - 1
docs/map-api-usage.md

@@ -54,5 +54,5 @@
 ## 关联
 
 - 台湾 OSM 需求差距与后续事项(2026-10-10,先记录、暂不实施):[osm-requirements-assessment.md](../specs/032-self-hosted-map-integration/osm-requirements-assessment.md)。测试服务器已支持独立机车路网,但 foodie 自部署适配尚未传车型;现有 car-only 假设不代表该测试服务现状。
-- 切换计划:`specs/032-self-hosted-map-integration/spec.md`(自部署 Foodie_HCM_Map:PostGIS 搜索 + OSRM 算路 + OpenFreeMap 底图;plan/tasks 未生成,未开工)
+- 切换规格:`specs/032-self-hosted-map-integration/spec.md`(自部署 Foodie_HCM_Map:PostGIS 搜索 + OSRM 算路 + OpenFreeMap 底图)。2026-10-12 四项机车模型评估已验证,见 [model-evaluation.md](../specs/032-self-hosted-map-integration/model-evaluation.md);plan/tasks 仅覆盖评估,后续生产改造尚未开始。
 - 自部署地图仓库:`D:\QtwCode\foodie\Foodie_HCM_Map`

+ 171 - 0
docs/store-menu-copy-app-api.md

@@ -0,0 +1,171 @@
+# 连锁门店复制商品(026):App 端接入说明
+
+**日期**:2026-10-12
+**分支**:`test-202609v2`(后端已实现并过测试;商家 PC 端 foodie-store 已上线同款功能,本文档供 App 端复用同一组接口)
+**一句话**:连锁商家在 App 里把一家门店的全部分类+商品**复制**到另一家门店;复制前可**预览差异**,同名但内容不同的商品由商家选择**保留本店版本**或**用源店覆盖**;可重复执行(幂等)。
+
+**功能规格**:`specs/026-store-menu-copy/spec.md`;PC 端参考实现:`foodie-store/src/components/StoreMenuCopyDialog.vue`。
+
+---
+
+## 1. 功能与角色
+
+- 面向**多家门店的商家**:主账号(`userType=1`)可对自己名下所有门店操作;分管账号(022 子账号)仅当源、目标两店都在其授权范围内可用。
+- 摊位商家(`userType=4`)只有一个绑定摊位,**不出现**复制入口。
+- 核心价值:新开门店初始化菜单、向其他门店推新品/改价,替代逐条录入。
+- 本功能**零 DDL、无同步状态表**:差异一律实时计算,预览差异为 0 即等价「已是最新」。
+
+## 2. 通用约定
+
+| 项 | 说明 |
+|---|---|
+| 鉴权 | 请求头 `token: <商家登录令牌>`;服务端校验操作者对源、目标两店**均有权限**,越权返回错误且不产生任何写入 |
+| 响应包裹 | `{ "code": 200, "msg": "...", "data": ... }`;业务失败 `code=500`,`msg` 为服务端本地化文案 |
+| 错误文案语言 | 请求带查询参数 `lang`(如 `lang=vi` / `zh` / `en`,缺省繁中),**App 直接展示 msg,不要自己映射错误文案** |
+| 超时 | 预览与执行建议设 **≥ 15 秒**(PC 端即 15s;后端 100 商品规模 < 10 秒) |
+| 入口控制 | 当前账号可访问门店数 < 2 时,App 不显示「复制商品」入口;源店与目标店不能是同一家 |
+
+## 3. 前置接口:门店列表
+
+选择源门店/展示入口前,先取当前账号有权限的门店:
+
+```http
+GET /chanting/store/storelistlist?page=1&size=100
+token: <商家登录令牌>
+```
+
+- 返回 `data` 为分页对象 `IPage<PosStore>`,App 主要用 `records[].id`(门店 id)和 `records[].posName`(门店名);`total` 判断门店数是否 ≥ 2。
+- 返回范围:主账号 = 名下全部门店;分管账号 = 授权范围内门店;摊位主 = 仅绑定摊位(1 家,无入口)。
+
+## 4. 接口:复制预览
+
+```http
+POST /chanting/food/menu-copy/preview
+token: <商家登录令牌>
+Content-Type: application/json
+
+{
+  "sourceStoreId": 101,
+  "targetStoreId": 202
+}
+```
+
+响应 `data`(`StoreMenuCopyPreviewResponse`):
+
+```json
+{
+  "code": 200,
+  "data": {
+    "sourceStoreId": 101,
+    "targetStoreId": 202,
+    "addedCategoryCount": 5,
+    "mergedCategoryCount": 2,
+    "addedProductCount": 12,
+    "identicalProductCount": 23,
+    "differentProductCount": 3,
+    "differences": [
+      {
+        "sourceProductId": 5001,
+        "targetProductId": 6102,
+        "name": "麦辣鸡腿堡",
+        "language": "3",
+        "fields": [
+          { "field": "price", "sourceValue": "75", "targetValue": "65" }
+        ]
+      }
+    ]
+  }
+}
+```
+
+**计数含义**:
+
+| 字段 | 含义 |
+|---|---|
+| `addedCategoryCount` | 将新建的分类数 |
+| `mergedCategoryCount` | 同名分类合并数(目标店已有,复用) |
+| `addedProductCount` | 将新增的商品数 |
+| `identicalProductCount` | 同名且内容一致、将跳过的商品数 |
+| `differentProductCount` | 同名但内容不同的商品数(差异明细见 `differences`) |
+
+**差异明细 `differences[]`**:每个差异商品含 `sourceProductId` / `targetProductId` / `name` / `language`(多语言行各自独立匹配),`fields[]` 为字段级前后值。`field` 取值固定为以下 7 个之一:
+
+| `field` | 含义 | 前端展示建议 |
+|---|---|---|
+| `price` | 价格 | 金额 |
+| `image` | 图片 | 缩略图对比(URL 引用,直接展示) |
+| `introduce` | 介绍 | 文本,过长截断 |
+| `foodSku` | 属性(SKU) | 原始串,建议只提示「有变化」 |
+| `sort` | 排序 | 数字 |
+| `recommend` | 推荐 | 0/1 |
+| `specs` | 规格关联集合 | 建议只提示「有变化」 |
+
+**判定口径**:分类按(分类名+语言)匹配;商品按(商品名+语言)匹配;内容比对字段即上表 7 项。
+
+## 5. 接口:复制执行
+
+```http
+POST /chanting/food/menu-copy/execute
+token: <商家登录令牌>
+Content-Type: application/json
+
+{
+  "sourceStoreId": 101,
+  "targetStoreId": 202,
+  "conflictStrategy": "KEEP_TARGET"
+}
+```
+
+- `conflictStrategy` 差异商品处理方式:
+  - `KEEP_TARGET`:保留本店版本(**默认**,传空/不传即此值)
+  - `OVERWRITE_TARGET`:用源店版本覆盖差异商品的内容字段
+  - 大小写不敏感;其他值返回错误
+- 执行为**单事务**:全部成功或全部回滚;**幂等**:同参数重复执行,第二次新增 0、覆盖 0。
+
+响应 `data`(`StoreMenuCopyResult`):
+
+```json
+{
+  "code": 200,
+  "data": {
+    "addedCategoryCount": 5,
+    "addedProductCount": 12,
+    "skippedProductCount": 23,
+    "overwrittenProductCount": 0
+  }
+}
+```
+
+## 6. 错误响应
+
+`code=500`,`msg` 为按 `lang` 下发的本地化文案(App 直接展示)。可能的 key 与繁中文案:
+
+| msg key | 繁中文案 | 触发场景 |
+|---|---|---|
+| `menu.copy.store.required` | 來源門店和目標門店不能為空 | 缺 sourceStoreId / targetStoreId |
+| `menu.copy.store.same` | 來源門店和目標門店不能相同 | 两店相同 |
+| `menu.copy.strategy.invalid` | 差異商品處理方式無效 | conflictStrategy 非法值 |
+| `merchant.store.access.denied` | 無權存取該店鋪 | 任一店无权限 / 非商家账号 |
+
+## 7. 业务规则要点(App 交互需对齐)
+
+- **覆盖不破坏本店状态**:`OVERWRITE_TARGET` 只更新内容字段(价格、图片、介绍、属性、排序、推荐、规格关联),**不改**目标店商品的 id、上架状态、审核状态——目标店临时下架的商品覆盖后仍保持下架。
+- **新增商品**继承源店的上架与审核状态,复制后可直接被顾客下单;图片直接引用源店 URL,不复制文件。
+- **规格**:目标店缺模板时按(规格名+语言)新建模板与规格值;已存在的规格模板自身的规格值**不会被修改**(门店内共享资产)。
+- **多语言**:同一商品各语言行独立匹配、独立复制,不做跨语言合并。
+- **预览与执行之间存在秒级时间窗**:若期间菜单被改动,以执行时快照为准,结果计数可能与预览略有出入(单商家操作场景可接受)。
+
+## 8. 推荐 App 交互流程(对齐 PC 端)
+
+1. 商品管理页当前门店即**目标店**;门店数 < 2 隐藏「复制商品」入口。
+2. 弹窗选择**源门店**(选项排除目标店)→ 调预览接口。
+3. 展示 5 个计数;`differentProductCount > 0` 时可展开差异明细(商品名 + 字段前后值),并提供单选:
+   - 保留本店版本(`KEEP_TARGET`,默认)
+   - 用源店覆盖(`OVERWRITE_TARGET`,选中时给出醒目确认提示)
+4. 无可复制内容(新增分类=0、新增商品=0,且未选覆盖)时,执行按钮置灰。
+5. 确认执行 → 展示结果 4 计数(新增分类/新增商品/跳过/覆盖)→ 刷新商品列表。
+6. 幂等可重跑:再次执行全部命中跳过,可当「同步校验」用。
+
+## 9. App 自身多语言
+
+App 端功能 UI 文案需覆盖 **vi / zh / tw / en** 四语。PC 端 `menuCopy.*` 文案 key 可直接参考 `foodie-store/src/lang/{zh,tw,en,vi}.js` 的同名节点。

+ 551 - 0
specs/032-self-hosted-map-integration/evaluation/model-probe-results.json

@@ -0,0 +1,551 @@
+{
+  "timestamp": "2026-10-12T02:06:52.919727+00:00",
+  "image": "ghcr.io/project-osrm/osrm-backend:v5.27.1",
+  "release": "/opt/foodie-map-tw-20260909",
+  "before": {
+    "map_api/settings.py": "bd48a4a87f9d65640f0f5646d97e1c7aab592020732f81903d529d143586bca7",
+    "map_api/routing.py": "d9d6d6822a7f608b0649f5dc229b102628c6cfc5b32382bc950185740db2caa3",
+    "docker/osrm/profiles/motorcycle.lua": "6f8741c9a6130434074be06302a28c204ed25ed9f5b760f776ac9f6b3bedbe26",
+    "container:/app/settings.py": "bd48a4a87f9d65640f0f5646d97e1c7aab592020732f81903d529d143586bca7",
+    "container:/app/routing.py": "d9d6d6822a7f608b0649f5dc229b102628c6cfc5b32382bc950185740db2caa3",
+    "container:car.lua": "cb3df0546318665609606b746a1297f3d65ca3c2ff825f8a6f3c57247d86a2d3",
+    "built_profile": "bd9949c7160d4edb207185b7fa73eec57fbd6ca970def80da3fa45e380381162",
+    "pbf": "7b8368383eb1567299e59cd559b1f9999d09206e63d18f3e039e8f6391d4c5e7"
+  },
+  "cases": [
+    {
+      "name": "baseline:motorcycle_no",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "distance": 1028.5,
+        "duration": 148.1,
+        "weight": 148.1
+      }
+    },
+    {
+      "name": "candidate:motorcycle_no",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "distance": 1028.5,
+        "duration": 148.1,
+        "weight": 148.1
+      }
+    },
+    {
+      "name": "baseline:motorcar_no",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "distance": 221.5,
+        "duration": 32,
+        "weight": 32
+      }
+    },
+    {
+      "name": "candidate:motorcar_no",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "distance": 221.5,
+        "duration": 32,
+        "weight": 32
+      }
+    },
+    {
+      "name": "baseline:motorway",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "distance": 1028.3,
+        "duration": 148.1,
+        "weight": 148.1
+      }
+    },
+    {
+      "name": "candidate:motorway",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "distance": 1028.3,
+        "duration": 148.1,
+        "weight": 148.1
+      }
+    },
+    {
+      "name": "baseline:motorway_link",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "distance": 1028.3,
+        "duration": 148.1,
+        "weight": 148.1
+      }
+    },
+    {
+      "name": "candidate:motorway_link",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "distance": 1028.3,
+        "duration": 148.1,
+        "weight": 148.1
+      }
+    },
+    {
+      "name": "baseline:trunk",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "distance": 1028.3,
+        "duration": 148.1,
+        "weight": 148.1
+      }
+    },
+    {
+      "name": "candidate:trunk",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "distance": 1028.3,
+        "duration": 148.1,
+        "weight": 148.1
+      }
+    },
+    {
+      "name": "baseline:trunk_permitted",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "distance": 221.6,
+        "duration": 9.4,
+        "weight": 9.4
+      }
+    },
+    {
+      "name": "candidate:trunk_permitted",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "distance": 221.6,
+        "duration": 9.4,
+        "weight": 9.4
+      }
+    },
+    {
+      "name": "baseline:motorroad",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "distance": 1028,
+        "duration": 148.1,
+        "weight": 148.1
+      }
+    },
+    {
+      "name": "candidate:motorroad",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "distance": 1028,
+        "duration": 148.1,
+        "weight": 148.1
+      }
+    },
+    {
+      "name": "baseline:motorroad_permitted",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "distance": 221.6,
+        "duration": 32,
+        "weight": 32
+      }
+    },
+    {
+      "name": "candidate:motorroad_permitted",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "distance": 221.6,
+        "duration": 32,
+        "weight": 32
+      }
+    },
+    {
+      "name": "car_vs_motorcycle_access",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "car": {
+          "distance": 221.5,
+          "duration": 32,
+          "weight": 32
+        },
+        "motorcycle": {
+          "distance": 1028.5,
+          "duration": 148.1,
+          "weight": 148.1
+        }
+      }
+    },
+    {
+      "name": "baseline:oneway",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "forward": {
+          "distance": 221.6,
+          "duration": 32,
+          "weight": 32
+        },
+        "reverse": {
+          "distance": 1028,
+          "duration": 148.1,
+          "weight": 148.1
+        }
+      }
+    },
+    {
+      "name": "baseline:oneway_motorcycle_override",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "distance": 221.6,
+        "duration": 32,
+        "weight": 32
+      }
+    },
+    {
+      "name": "baseline:motorcycle_turn",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "distance": 653.6,
+        "duration": 94.3,
+        "weight": 94.3
+      }
+    },
+    {
+      "name": "baseline:generic_turn",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "distance": 653.6,
+        "duration": 94.3,
+        "weight": 94.3
+      }
+    },
+    {
+      "name": "candidate:oneway",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "forward": {
+          "distance": 221.6,
+          "duration": 32,
+          "weight": 32
+        },
+        "reverse": {
+          "distance": 1028,
+          "duration": 148.1,
+          "weight": 151.1
+        }
+      }
+    },
+    {
+      "name": "candidate:oneway_motorcycle_override",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "distance": 221.6,
+        "duration": 32,
+        "weight": 32
+      }
+    },
+    {
+      "name": "candidate:motorcycle_turn",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "distance": 653.6,
+        "duration": 94.3,
+        "weight": 100.3
+      }
+    },
+    {
+      "name": "candidate:generic_turn",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "distance": 653.6,
+        "duration": 94.3,
+        "weight": 100.3
+      }
+    },
+    {
+      "name": "motorcycle_speed_limit",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "car": {
+          "distance": 221.6,
+          "duration": 11,
+          "weight": 11
+        },
+        "motorcycle": {
+          "distance": 221.6,
+          "duration": 49.8,
+          "weight": 49.8
+        }
+      }
+    },
+    {
+      "name": "gravel_forced:weight_not_eta",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "baseline": {
+          "distance": 221.6,
+          "duration": 20,
+          "weight": 20
+        },
+        "candidate": {
+          "distance": 221.6,
+          "duration": 20,
+          "weight": 99.6
+        }
+      }
+    },
+    {
+      "name": "signals:weight_not_eta",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "baseline": {
+          "distance": 221.6,
+          "duration": 34,
+          "weight": 34
+        },
+        "candidate": {
+          "distance": 221.6,
+          "duration": 34,
+          "weight": 42
+        }
+      }
+    },
+    {
+      "name": "unrestricted_turn:weight_not_eta",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "baseline": {
+          "distance": 190.4,
+          "duration": 29.6,
+          "weight": 29.6
+        },
+        "candidate": {
+          "distance": 190.4,
+          "duration": 29.6,
+          "weight": 32.6
+        }
+      }
+    },
+    {
+      "name": "gravel_preference_changes_path",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "baseline": {
+          "distance": 221.6,
+          "duration": 20,
+          "weight": 20
+        },
+        "candidate": {
+          "distance": 423.1,
+          "duration": 60.9,
+          "weight": 63.9
+        }
+      }
+    },
+    {
+      "name": "baseline:offroad",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "code": "NoSegment"
+      }
+    },
+    {
+      "name": "baseline:disconnected",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "code": "NoRoute"
+      }
+    },
+    {
+      "name": "candidate:offroad",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "code": "NoSegment"
+      }
+    },
+    {
+      "name": "candidate:disconnected",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "code": "NoRoute"
+      }
+    },
+    {
+      "name": "conditional_access_not_enforced",
+      "kind": "limitation_probe",
+      "passed": true,
+      "observations": {
+        "distance": 221.6,
+        "duration": 32,
+        "weight": 32
+      }
+    },
+    {
+      "name": "ordered_three_stop_route",
+      "kind": "assertion",
+      "passed": true,
+      "observations": {
+        "legs": 2,
+        "distance": 2057,
+        "duration": 323.6,
+        "weight": 323.6
+      }
+    }
+  ],
+  "production_probes": {
+    "config": {
+      "api_base": "/api",
+      "basemap_style": "https://tiles.openfreemap.org/styles/liberty",
+      "bounds": [
+        118.0,
+        21.5,
+        123.0,
+        26.5
+      ],
+      "center": [
+        121.5654,
+        25.033
+      ],
+      "country": "TW",
+      "dataset": "taiwan",
+      "locale": "zh-TW",
+      "pricing_provider": "foodie",
+      "routing_profile": "car",
+      "routing_profiles": [
+        "car",
+        "motorcycle"
+      ],
+      "routing_provider": "osrm",
+      "search_provider": "local",
+      "test_only": false,
+      "traffic_aware": false
+    },
+    "health/ready": {
+      "routing": "check /route",
+      "search": {
+        "country": "TW",
+        "dataset": "taiwan",
+        "dataset_version": "7b8368383eb1567299e59cd559b1f9999d09206e63d18f3e039e8f6391d4c5e7",
+        "indexed_at": "Wed, 09 Sep 2026 03:50:29 GMT"
+      },
+      "status": "ready"
+    },
+    "route:car": {
+      "profile": "car",
+      "distance_meters": 693,
+      "duration_seconds": 63,
+      "traffic_aware": false
+    },
+    "route/multi:car": {
+      "profile": "car",
+      "distance_meters": 693,
+      "duration_seconds": 63,
+      "traffic_aware": false
+    },
+    "route:motorcycle": {
+      "profile": "motorcycle",
+      "distance_meters": 693,
+      "duration_seconds": 87,
+      "traffic_aware": false
+    },
+    "route/multi:motorcycle": {
+      "profile": "motorcycle",
+      "distance_meters": 693,
+      "duration_seconds": 87,
+      "traffic_aware": false
+    }
+  },
+  "errors": [],
+  "combined_profile_matches_graph": true,
+  "candidate_sha256": "27c9697ce6cd67c9343d5b300274958b418015f5794fb8f999e325f49af43cfe",
+  "fixture_sha256": "8ae060942fb1eea16b82c7f5c01351645ef97309ce161044fc71590543c014ba",
+  "temporary_workspace": "/tmp/foodie-model-eval-herz4q09",
+  "after": {
+    "map_api/settings.py": "bd48a4a87f9d65640f0f5646d97e1c7aab592020732f81903d529d143586bca7",
+    "map_api/routing.py": "d9d6d6822a7f608b0649f5dc229b102628c6cfc5b32382bc950185740db2caa3",
+    "docker/osrm/profiles/motorcycle.lua": "6f8741c9a6130434074be06302a28c204ed25ed9f5b760f776ac9f6b3bedbe26",
+    "container:/app/settings.py": "bd48a4a87f9d65640f0f5646d97e1c7aab592020732f81903d529d143586bca7",
+    "container:/app/routing.py": "d9d6d6822a7f608b0649f5dc229b102628c6cfc5b32382bc950185740db2caa3",
+    "container:car.lua": "cb3df0546318665609606b746a1297f3d65ca3c2ff825f8a6f3c57247d86a2d3",
+    "built_profile": "bd9949c7160d4edb207185b7fa73eec57fbd6ca970def80da3fa45e380381162",
+    "pbf": "7b8368383eb1567299e59cd559b1f9999d09206e63d18f3e039e8f6391d4c5e7"
+  },
+  "production_files_unchanged": true,
+  "production_containers_running": true,
+  "temporary_containers_removed": true,
+  "temporary_workspace_removed": true,
+  "references": {
+    "valhalla_commit": "34639a33510eb0764da77650e91f6efbb70e8daa",
+    "sources": {
+      "src/sif/motorcyclecost.cc": {
+        "url": "https://raw.githubusercontent.com/valhalla/valhalla/34639a33510eb0764da77650e91f6efbb70e8daa/src/sif/motorcyclecost.cc",
+        "sha256": "346556fefbd280c43d3bbf426d3546c2acce09ab59e21214d4507b752802ed80",
+        "bytes": 35728
+      },
+      "src/sif/motorscootercost.cc": {
+        "url": "https://raw.githubusercontent.com/valhalla/valhalla/34639a33510eb0764da77650e91f6efbb70e8daa/src/sif/motorscootercost.cc",
+        "sha256": "905fe91bf2e148cf73c3f26ff2a791b6b5d40838196c759c40b4151d56d79279",
+        "bytes": 36269
+      },
+      "LICENSE.md": {
+        "url": "https://raw.githubusercontent.com/valhalla/valhalla/34639a33510eb0764da77650e91f6efbb70e8daa/LICENSE.md",
+        "sha256": "b199717d0140b89f1b6bfd44f06de1610ede0bbef4b41232c04580837aeb8b13",
+        "bytes": 44
+      },
+      "COPYING": {
+        "url": "https://raw.githubusercontent.com/valhalla/valhalla/34639a33510eb0764da77650e91f6efbb70e8daa/COPYING",
+        "sha256": "8bdb14aebb005ffa75ee3890aa10b242b30c840caa01b996c56338ca279f37cd",
+        "bytes": 1135
+      }
+    },
+    "map_refs": {
+      "HEAD": "f8d104a4fd9a12d4197593e711135c2b9662db72",
+      "origin/taiwan-map-profiles": "8906fe1d49d7304e6ea3a579bea2d81d0bd31db6"
+    },
+    "branch_files": {
+      "HEAD": {
+        "map_api/settings.py": "bb7bbd16bd04e1a7cd66e19d4fb56648049df3d2a45bdfa5e7aa15c6638f10ae",
+        "map_api/routing.py": "37af230ae95747e66929faa5cebeceea702e2ed6ae01f3732232d6cd225de344",
+        "docker/osrm/profiles/motorcycle.lua": null
+      },
+      "origin/taiwan-map-profiles": {
+        "map_api/settings.py": "c463570695f1400c548fe3d324f546f483805ba0dd5be4ced6e09aa0311498a8",
+        "map_api/routing.py": "8742a21d255950dc5a15fa1f68a70f25cb8ff5f30e512276d83083a88e438f4e",
+        "docker/osrm/profiles/motorcycle.lua": "6f8741c9a6130434074be06302a28c204ed25ed9f5b760f776ac9f6b3bedbe26"
+      }
+    },
+    "branch_lua_sha256": "6f8741c9a6130434074be06302a28c204ed25ed9f5b760f776ac9f6b3bedbe26"
+  },
+  "branch_lua_matches_deployment": true
+}

+ 348 - 0
specs/032-self-hosted-map-integration/evaluation/run_model_probe.py

@@ -0,0 +1,348 @@
+"""Reproduce the approved model assessment without changing production files.
+
+Uses the existing SSH alias for read-only baseline inspection and disposable,
+resource-limited OSRM containers. Synthetic XML and candidate Lua are generated
+only under an owned /tmp directory. No business database or credentials are read.
+"""
+import hashlib
+import json
+from pathlib import Path
+import subprocess
+import sys
+import urllib.request
+
+VALHALLA_SHA = "34639a33510eb0764da77650e91f6efbb70e8daa"
+MAP_REPO = Path("D:/QtwCode/foodie/Foodie_HCM_Map")
+OUTPUT = Path(__file__).with_name("model-probe-results.json")
+
+
+def remote_probe():
+    """This function executes on the SSH host, with the standard library only."""
+    import datetime
+    import math
+    import tempfile
+    import time
+    import urllib.error
+    import urllib.parse
+    import xml.etree.ElementTree as ET
+
+    image = "ghcr.io/project-osrm/osrm-backend:v5.27.1"
+    release = Path("/opt/foodie-map-tw-20260909")
+    production = ["foodie-map-tw-20260909-api-1", "foodie-map-tw-20260909-osrm-1",
+                  "foodie-map-tw-20260909-osrm-motorcycle-1"]
+
+    def command(args):
+        completed = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=90)
+        if completed.returncode:
+            raise RuntimeError("Command failed: " + " ".join(args) + "\n" + completed.stdout.decode(errors="replace")[-4000:])
+        return completed.stdout
+
+    def fingerprints():
+        files = ["map_api/settings.py", "map_api/routing.py", "docker/osrm/profiles/motorcycle.lua"]
+        hashes = {p: hashlib.sha256((release / p).read_bytes()).hexdigest() for p in files}
+        for p in ("settings.py", "routing.py"):
+            data = command(["docker", "exec", production[0], "cat", "/app/" + p])
+            hashes["container:/app/" + p] = hashlib.sha256(data).hexdigest()
+        hashes["container:car.lua"] = hashlib.sha256(command(
+            ["docker", "exec", production[2], "cat", "/opt/car.lua"])).hexdigest()
+        hashes["built_profile"] = command(["docker", "exec", production[2], "cat",
+                                          "/data/profile.sha256"]).decode().strip()
+        hashes["pbf"] = command(["docker", "exec", production[2], "cat",
+                                "/data/build.sha256"]).decode().strip()
+        return hashes
+
+    def read_url(url):
+        with urllib.request.urlopen(url, timeout=8) as response:
+            return json.load(response)
+
+    result = {"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
+              "image": image, "release": str(release), "before": fingerprints(),
+              "cases": [], "production_probes": {}, "errors": []}
+    baseline = (release / "docker/osrm/profiles/motorcycle.lua").read_text()
+    car_lua = command(["docker", "exec", production[2], "cat", "/opt/car.lua"])
+    result["combined_profile_matches_graph"] = hashlib.sha256(
+        car_lua + baseline.encode()).hexdigest() == result["before"]["built_profile"]
+    for name in ("config", "health/ready"):
+        result["production_probes"][name] = read_url("http://127.0.0.1:8081/api/" + name)
+    points = "pickup_lat=25.0330&pickup_lng=121.5654&drop_lat=25.0375&drop_lng=121.5637"
+    for profile in ("car", "motorcycle"):
+        for endpoint, params in (("route", points),
+                                 ("route/multi", "coords=25.0330,121.5654;25.0375,121.5637")):
+            route = read_url("http://127.0.0.1:8081/api/" + endpoint + "?" + params + "&profile=" + profile)
+            result["production_probes"][endpoint + ":" + profile] = {
+                k: route.get(k) for k in ("profile", "distance_meters", "duration_seconds", "traffic_aware")}
+
+    # Probe chosen by this assessment: keep access restrictions; penalize gravel
+    # through rate (not speed), and add a signal weight (not duration). Neither
+    # number is a copied Valhalla constant or an approved production calibration.
+    candidate = baseline.replace("    car.process_way(profile, proxy, result, relations)", """
+    car.process_way(profile, proxy, result, relations)
+    if way:get_value_by_key('surface') == 'gravel' then
+        if result.forward_rate and result.forward_rate > 0 then
+            result.forward_rate = result.forward_rate * 0.2
+        end
+        if result.backward_rate and result.backward_rate > 0 then
+            result.backward_rate = result.backward_rate * 0.2
+        end
+    end""").replace("return {setup=setup, process_way=process_way, process_node=car.process_node, process_turn=car.process_turn}", """
+local function process_turn(profile, turn)
+    car.process_turn(profile, turn)
+    if turn.has_traffic_light then turn.weight = turn.weight + 8 end
+    if math.abs(turn.angle) > 60 then turn.weight = turn.weight + 3 end
+end
+return {setup=setup, process_way=process_way, process_node=car.process_node, process_turn=process_turn}
+""")
+    if candidate == baseline or "turn.weight = turn.weight + 8" not in candidate:
+        raise RuntimeError("Unexpected baseline shape; no automatic production adaptation.")
+    result["candidate_sha256"] = hashlib.sha256(candidate.encode()).hexdigest()
+
+    root = ET.Element("osm", version="0.6", generator="foodie-isolated-model-assessment")
+    counter = 0
+    samples = {}
+
+    def node(lat, lon, tags=None):
+        nonlocal counter
+        counter += 1
+        element = ET.SubElement(root, "node", id=str(counter), lat=str(lat), lon=str(lon), version="1")
+        for key, value in (tags or {}).items():
+            ET.SubElement(element, "tag", k=key, v=value)
+        return counter
+
+    def way(ids, tags):
+        nonlocal counter
+        counter += 1
+        element = ET.SubElement(root, "way", id=str(counter), version="1")
+        for identifier in ids:
+            ET.SubElement(element, "nd", ref=str(identifier))
+        for key, value in tags.items():
+            ET.SubElement(element, "tag", k=key, v=value)
+        return counter
+
+    # Independent diamonds ensure a prohibited short road has a real legal
+    # detour. Inputs use exact shared junctions, avoiding arbitrary snap choices.
+    def diamond(name, tags, width=0.004, signal=False, detour=True):
+        lat, lon = 25.1 + len(samples) * 0.01, 121.5
+        a, b = node(lat, lon), node(lat + 0.002, lon)
+        mid = node(lat + 0.001, lon, {"highway": "traffic_signals"} if signal else {})
+        way([a, mid, b], {"highway": "residential", "name": name, **tags})
+        if detour:
+            c, d = node(lat, lon + width), node(lat + 0.002, lon + width)
+            way([a, c, d, b], {"highway": "residential", "name": name + "_detour"})
+        if signal:
+            branch = node(lat + 0.001, lon - 0.0003)
+            way([mid, branch], {"highway": "residential", "name": "signal_branch"})
+        samples[name] = [(lon, lat), (lon, lat + 0.002)]
+
+    diamond("motorcycle_no", {"motorcycle": "no"})
+    diamond("motorcar_no", {"motorcar": "no", "motorcycle": "yes"})
+    for road in ("motorway", "motorway_link", "trunk"):
+        diamond(road, {"highway": road})
+    diamond("trunk_permitted", {"highway": "trunk", "motorcycle": "yes"})
+    diamond("motorroad", {"motorroad": "yes"})
+    diamond("motorroad_permitted", {"motorroad": "yes", "motorcycle": "yes"})
+    diamond("oneway", {"oneway": "yes"})
+    diamond("oneway_override", {"oneway": "yes", "oneway:motorcycle": "no"})
+    diamond("speed_override", {"maxspeed:forward": "90", "maxspeed:motorcycle": "20"}, detour=False)
+    diamond("gravel_choice", {"highway": "primary", "surface": "gravel"}, width=0.001)
+    diamond("gravel_forced", {"highway": "primary", "surface": "gravel"}, detour=False)
+    diamond("signals", {}, signal=True, detour=False)
+    diamond("conditional_access", {"motorcycle:conditional": "no @ (00:00-24:00)"})
+
+    # Turn relation distinguishes real path exclusion from simply assigning a
+    # higher turn penalty. No two-stage-turn claim is made by this fixture.
+    def turn_case(name, restriction_key):
+        nonlocal counter
+        lat, lon = 25.1 + len(samples) * 0.01, 121.5
+        a, via, b = node(lat, lon), node(lat + 0.001, lon), node(lat + 0.001, lon + 0.001)
+        c, d = node(lat + 0.003, lon), node(lat + 0.003, lon + 0.001)
+        from_way = way([a, via], {"highway": "residential"})
+        to_way = way([via, b], {"highway": "residential"})
+        way([via, c, d, b], {"highway": "residential"})
+        if restriction_key:
+            counter += 1
+            relation = ET.SubElement(root, "relation", id=str(counter), version="1")
+            for kind, ref, role in (("way", from_way, "from"), ("node", via, "via"), ("way", to_way, "to")):
+                ET.SubElement(relation, "member", type=kind, ref=str(ref), role=role)
+            ET.SubElement(relation, "tag", k="type", v="restriction")
+            ET.SubElement(relation, "tag", k=restriction_key, v="no_right_turn")
+        samples[name] = [(lon, lat + 0.0001), (lon + 0.0009, lat + 0.001)]
+
+    turn_case("motorcycle_turn", "restriction:motorcycle")
+    turn_case("generic_turn", "restriction")
+    turn_case("unrestricted_turn", None)
+    # libosmium expects ordered entity types even though generated scenarios
+    # were appended independently. Keep nodes before ways before relations.
+    entities = sorted(list(root), key=lambda e: ({"node": 0, "way": 1, "relation": 2}[e.tag], int(e.attrib["id"])))
+    root[:] = entities
+    fixture = ET.tostring(root, encoding="utf-8", xml_declaration=True)
+    samples["offroad"] = [samples["motorcycle_no"][0], (121.7, 25.7)]
+    samples["disconnected"] = [samples["motorcycle_no"][0], samples["trunk_permitted"][1]]
+    result["fixture_sha256"] = hashlib.sha256(fixture).hexdigest()
+    containers = []
+    urls = {}
+
+    def progress(message):
+        print(message, file=sys.stderr, flush=True)
+
+    def route(profile, case, reverse=False):
+        coords = samples[case][::-1] if reverse else samples[case]
+        coordinates = ";".join(f"{lon},{lat}" for lon, lat in coords)
+        url = urls[profile] + "/route/v1/driving/" + coordinates + "?overview=full&geometries=geojson&radiuses=10;10"
+        try:
+            data = read_url(url)
+        except urllib.error.HTTPError as error:
+            data = json.load(error)
+        data["test_profile"] = profile
+        return data
+
+    def check(name, condition, observations, kind="assertion"):
+        row = {"name": name, "kind": kind, "passed": bool(condition), "observations": observations}
+        result["cases"].append(row)
+        progress(f"{name}: {'PASS' if condition else 'FAIL'}")
+
+    def metric(data):
+        if data.get("code") != "Ok":
+            return {"code": data.get("code")}
+        return {k: data["routes"][0][k] for k in ("distance", "duration", "weight")}
+
+    def distance(data):
+        return data["routes"][0]["distance"] if data.get("code") == "Ok" else math.inf
+
+    try:
+        with tempfile.TemporaryDirectory(prefix="foodie-model-eval-", dir="/tmp") as temp:
+            workspace = Path(temp)
+            result["temporary_workspace"] = str(workspace)
+            for profile, lua in (("car", None), ("baseline", baseline), ("candidate", candidate)):
+                folder = workspace / profile
+                folder.mkdir()
+                (folder / "fixture.osm").write_bytes(fixture)
+                if lua:
+                    (folder / "model.lua").write_text(lua)
+                profile_path = "/data/model.lua" if lua else "/opt/car.lua"
+                base_args = ["docker", "run", "--rm", "--cpus=0.5", "--memory=256m",
+                             "-v", f"{folder}:/data", "--entrypoint", "sh", image]
+                progress("Building isolated " + profile)
+                build = "osrm-extract --threads 1 -p " + profile_path + " /data/fixture.osm && osrm-partition --threads 1 /data/fixture.osrm && osrm-customize --threads 1 /data/fixture.osrm"
+                command(base_args[:3] + ["--network", "none"] + base_args[3:] + ["-c", build])
+                identifier = command(["docker", "run", "-d", "--rm", "--cpus=0.5", "--memory=256m",
+                                      "-p", "127.0.0.1::5000", "-v", f"{folder}:/data:ro", image,
+                                      "osrm-routed", "--algorithm", "mld", "--threads", "1", "/data/fixture.osrm"]).decode().strip()
+                containers.append(identifier)
+                info = json.loads(command(["docker", "inspect", identifier]))[0]
+                port = info["NetworkSettings"]["Ports"]["5000/tcp"][0]["HostPort"]
+                urls[profile] = "http://127.0.0.1:" + port
+                for _ in range(30):
+                    try:
+                        read_url(urls[profile] + "/nearest/v1/driving/121.5,25.1")
+                        break
+                    except (urllib.error.URLError, TimeoutError):
+                        time.sleep(0.2)
+                else:
+                    raise RuntimeError("Isolated engine did not start")
+
+            for case, blocked in (("motorcycle_no", True), ("motorcar_no", False),
+                                  ("motorway", True), ("motorway_link", True), ("trunk", True),
+                                  ("trunk_permitted", False), ("motorroad", True),
+                                  ("motorroad_permitted", False)):
+                for profile in ("baseline", "candidate"):
+                    data = route(profile, case)
+                    ok = data.get("code") == "Ok" and (distance(data) > 700 if blocked else distance(data) < 300)
+                    check(profile + ":" + case, ok, metric(data))
+            car, moto = route("car", "motorcycle_no"), route("baseline", "motorcycle_no")
+            check("car_vs_motorcycle_access", distance(car) < 300 < distance(moto),
+                  {"car": metric(car), "motorcycle": metric(moto)})
+            for profile in ("baseline", "candidate"):
+                forward, backward = route(profile, "oneway"), route(profile, "oneway", True)
+                check(profile + ":oneway", distance(forward) < 300 and distance(backward) > 700,
+                      {"forward": metric(forward), "reverse": metric(backward)})
+                override = route(profile, "oneway_override", True)
+                check(profile + ":oneway_motorcycle_override", distance(override) < 300, metric(override))
+                for turn in ("motorcycle_turn", "generic_turn"):
+                    data = route(profile, turn)
+                    check(profile + ":" + turn, distance(data) > 500 and data.get("code") == "Ok", metric(data))
+            speed_car, speed_moto = route("car", "speed_override"), route("baseline", "speed_override")
+            check("motorcycle_speed_limit", metric(speed_moto).get("duration", 0) > metric(speed_car).get("duration", 0),
+                  {"car": metric(speed_car), "motorcycle": metric(speed_moto)})
+            for case in ("gravel_forced", "signals", "unrestricted_turn"):
+                base, proposed = route("baseline", case), route("candidate", case)
+                base_metric, proposed_metric = metric(base), metric(proposed)
+                ok = (base.get("code") == proposed.get("code") == "Ok" and
+                      abs(base_metric["duration"] - proposed_metric["duration"]) < 0.2 and
+                      proposed_metric["weight"] > base_metric["weight"])
+                check(case + ":weight_not_eta", ok, {"baseline": base_metric, "candidate": proposed_metric})
+            base, proposed = route("baseline", "gravel_choice"), route("candidate", "gravel_choice")
+            check("gravel_preference_changes_path", distance(base) < 300 and distance(proposed) > 350,
+                  {"baseline": metric(base), "candidate": metric(proposed)})
+            for profile in ("baseline", "candidate"):
+                for case, expected in (("offroad", "NoSegment"), ("disconnected", "NoRoute")):
+                    data = route(profile, case)
+                    check(profile + ":" + case, data.get("code") == expected, metric(data))
+            conditional = route("baseline", "conditional_access")
+            # A diagnostic, not a hidden assertion that unsupported time rules work.
+            check("conditional_access_not_enforced", distance(conditional) < 300, metric(conditional), "limitation_probe")
+            repeated = [*samples["motorcycle_no"], samples["motorcycle_no"][0]]
+            coords = ";".join(f"{lon},{lat}" for lon, lat in repeated)
+            multi = read_url(urls["baseline"] + "/route/v1/driving/" + coords + "?overview=false")
+            check("ordered_three_stop_route", multi.get("code") == "Ok" and len(multi["routes"][0]["legs"]) == 2,
+                  {"legs": len(multi.get("routes", [{}])[0].get("legs", [])), **metric(multi)})
+    except Exception as error:
+        result["errors"].append(str(error))
+    finally:
+        for identifier in containers:
+            # Only identifiers created by this run are stopped; no Compose
+            # lifecycle command can accidentally target a production service.
+            subprocess.run(["docker", "stop", "--time", "1", identifier], capture_output=True, timeout=15)
+        result["after"] = fingerprints()
+        result["production_files_unchanged"] = result["before"] == result["after"]
+        result["production_containers_running"] = all(
+            command(["docker", "inspect", "--format", "{{.State.Running}}", name]).decode().strip() == "true"
+            for name in production)
+        result["temporary_containers_removed"] = all(
+            subprocess.run(["docker", "inspect", identifier], capture_output=True).returncode != 0
+            for identifier in containers)
+        result["temporary_workspace_removed"] = not Path(result.get("temporary_workspace", "/nonexistent")).exists()
+    print(json.dumps(result, ensure_ascii=False))
+
+
+def main():
+    import inspect
+    sources = {}
+    for path in ("src/sif/motorcyclecost.cc", "src/sif/motorscootercost.cc", "LICENSE.md", "COPYING"):
+        url = f"https://raw.githubusercontent.com/valhalla/valhalla/{VALHALLA_SHA}/{path}"
+        with urllib.request.urlopen(url, timeout=20) as response:
+            data = response.read()
+        sources[path] = {"url": url, "sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)}
+    refs = {}
+    for ref in ("HEAD", "origin/taiwan-map-profiles"):
+        refs[ref] = subprocess.check_output(["git", "rev-parse", ref], cwd=MAP_REPO).decode().strip()
+    lua = subprocess.check_output(["git", "show", "origin/taiwan-map-profiles:docker/osrm/profiles/motorcycle.lua"], cwd=MAP_REPO)
+    branch_files = {}
+    for ref in refs:
+        branch_files[ref] = {}
+        for path in ("map_api/settings.py", "map_api/routing.py", "docker/osrm/profiles/motorcycle.lua"):
+            snapshot = subprocess.run(["git", "show", ref + ":" + path], cwd=MAP_REPO,
+                                      stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
+            branch_files[ref][path] = hashlib.sha256(snapshot.stdout).hexdigest() if snapshot.returncode == 0 else None
+    references = {"valhalla_commit": VALHALLA_SHA, "sources": sources, "map_refs": refs, "branch_files": branch_files,
+                  "branch_lua_sha256": hashlib.sha256(lua).hexdigest()}
+    source = "import hashlib,json,subprocess,sys,urllib.request\nfrom pathlib import Path\n" + inspect.getsource(remote_probe) + "\nremote_probe()\n"
+    process = subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "tokyo-map", "python3 -"],
+                             input=source, text=True, encoding="utf-8", stdout=subprocess.PIPE, timeout=360)
+    if process.returncode:
+        raise RuntimeError("Remote assessment failed before producing a complete result")
+    result = json.loads(process.stdout)
+    result["references"] = references
+    result["branch_lua_matches_deployment"] = references["branch_lua_sha256"] == result["before"]["docker/osrm/profiles/motorcycle.lua"]
+    # Generated machine evidence, not a manually edited source/configuration.
+    OUTPUT.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+    failures = [row["name"] for row in result["cases"] if not row["passed"]]
+    print(json.dumps({"cases": len(result["cases"]), "failures": failures, "errors": result["errors"],
+                      "files_unchanged": result["production_files_unchanged"], "evidence": str(OUTPUT)}, ensure_ascii=False))
+    if failures or result["errors"] or not all(result[key] for key in
+            ("combined_profile_matches_graph", "branch_lua_matches_deployment", "production_files_unchanged",
+             "production_containers_running", "temporary_containers_removed", "temporary_workspace_removed")):
+        sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()

+ 98 - 0
specs/032-self-hosted-map-integration/model-evaluation.md

@@ -0,0 +1,98 @@
+# 配送机车模型评估结论
+
+日期:2026-10-12。范围:本轮用户确认的基线核对、规则对照、小样本验证、最终结论四项评估。
+
+## 结论
+
+**继续使用 OSRM v5.27.1 是当前静态配送路线需求的可行方向;没有证据要求立即换成 Valhalla,也不需要先把它的 C++ 全部翻译成 Lua。** 现有模型已有机车通行与转向能力,可优先补充路面、信号灯和转向的静态软权重,并校准速度和距离偏好。
+
+真实 OSRM 合成路网试验验证了这些机制。但这不是台湾全境导航验收,也没有运行 Valhalla 引擎做路线等价比较。按时间变化的通行限制、实时交通、请求级动态偏好和完整两段式转弯不能凭本次试验宣称支持。
+
+## 1. 代码与部署基线
+
+| 对象 | 固定基线 / 实际核对 |
+|---|---|
+| 地图本地 master / 远端 master | `f8d104a4fd9a12d4197593e711135c2b9662db72`,只有汽车配置 |
+| 地图双车型分支 | `8906fe1d49d7304e6ea3a579bea2d81d0bd31db6`,已有机车 Lua |
+| 测试服务器部署 | `/opt/foodie-map-tw-20260909`,汽车、机车为两个 OSRM v5.27.1 容器 |
+| 部署机车 Lua | SHA-256 `6f8741c9a6130434074be06302a28c204ed25ed9f5b760f776ac9f6b3bedbe26`,与双车型分支完全一致 |
+| 实际 API 文件 | 宿主机 settings/routing 与 API 容器对应文件哈希一致;但两者均不等于上述任一分支的对应版本 |
+| 路网指纹 | 已构建路网指纹等于镜像内 car.lua 与部署 motorcycle.lua 拼接指纹;PBF 指纹见机器证据 |
+| Valhalla 参考源码 | 固定提交 `34639a33510eb0764da77650e91f6efbb70e8daa`;两模型、LICENSE.md、COPYING 均记录下载哈希 |
+
+**不能把部署代码整体认定为双车型分支的原样发布。** 机车 Lua 可按已核对的分支文件研究,但后续修改 API 前需要整合已部署的车型、多站点能力,不能盲目覆盖文件或切换到旧 master。
+
+实际测试服务器请求同一组台北公开坐标(`25.0330,121.5654` → `25.0375,121.5637`):
+
+| 接口 | 汽车 profile=car | 机车 profile=motorcycle |
+|---|---|---|
+| GET /api/route | 693 米 / 63 秒 | 693 米 / 87 秒 |
+| GET /api/route/multi | 693 米 / 63 秒 | 693 米 / 87 秒 |
+
+两个接口都接受 `profile` 并切到相应引擎,`traffic_aware=false`。因此 foodie 无需另造地图车型接口,但其 `SelfHostedRouteDistanceProvider` 当前仍未把 `vehicleType` 映射后传过去。
+
+## 2. 规则对照:借鉴什么,不照搬什么
+
+| 规则 | Valhalla 机制 | 当前 OSRM / 本轮判断 |
+|---|---|---|
+| 车型通行权限 | motorcycle 使用机车 access mask;motor_scooter 使用 moped access mask | 已读 motorcycle/motor_vehicle/vehicle/access,可复用;模型名称不能直接等同台湾机车类别 |
+| 高速/快速道路 | 通行 mask 与道路偏好分离,motorcycle 的 use_highways 是软偏好 | 当前硬排除 motorway/link;trunk/motorroad 明确 motorcycle=yes 等许可时仍放行。不能把“避开高速”当作所有禁行路段的完整保证 |
+| 单行与转向禁止 | 道路方向、简单及复杂限制 | 已有 oneway:motorcycle、restriction:motorcycle 和通用 restriction;真实夹具通过,优先保留而非重写 |
+| 路面不佳的偏好 | motorcycle 对路面加成本;scooter 还影响速度 | 可用 forward/backward_rate 调整权重,也可另校准实际速度;本轮验证权重变化不必同步扩大时间 |
+| 信号灯和转向 | 通过公共转向时间及独立成本估算 | OSRM process_turn 可分别改 weight/duration;本轮验证信号灯与转向软权重,不能把固定灯等待值当作实时信号周期 |
+| 时间/距离偏好 | use_distance/shortest 与其它成本组合 | OSRM 可构建固定的 rate/weight 策略;需要选择静态业务权重,不保证最短距离、最少灯和最短时间同时成立 |
+| 最高速度与道路速度 | top_speed 截断并配合速度惩罚 | 当前仅部分道路速度及机车 maxspeed 覆盖;max_speed_for_map_matching 不是算路最高速度上限,后续需单独校准 |
+| 坡度与道路密度 | 使用图中的 weighted_grade、density 等属性 | 当前没有相同数据链;静态扩展需可靠的坡度/密度数据,不能复制 C++ 常量就获得这些能力 |
+| 请求时动态偏好 | 在请求时使用 costing_options | OSRM Lua 主要在预处理执行;固定配置可以迁移,请求级任意组合不能仅靠一份静态 Lua 等价复刻 |
+| 条件通行、实时封路 | EvaluateRestrictions、日期/时间和交通数据 | 当前 Lua 未处理测试中的 motorcycle:conditional;生产若要求预约时间条件导航或实时交通,应另设计时间数据/引擎方案 |
+| 两段式转弯 | 需实际路网和当地转向规则共同支持 | 源码阅读未证明存在通用台湾待转实现;禁止某个转向不等于完整待转流程,本轮不宣称达标 |
+
+参考的是规则思路与机制,不是模型的全部常量。motor_scooter 的默认 45 km/h 等参数面向其 moped 模型,不直接作为我们的配送机车默认值。motorcycle 的旅游/越野偏好也不应原样加入外卖配送。
+
+固定参考链接:[摩托车源码](https://github.com/valhalla/valhalla/blob/34639a33510eb0764da77650e91f6efbb70e8daa/src/sif/motorcyclecost.cc)、[踏板/轻便机车源码](https://github.com/valhalla/valhalla/blob/34639a33510eb0764da77650e91f6efbb70e8daa/src/sif/motorscootercost.cc)、[API 文档](https://github.com/valhalla/valhalla/blob/34639a33510eb0764da77650e91f6efbb70e8daa/docs/docs/api/route/api-reference.md)、[OSRM 5.27.1 Lua 文档](https://github.com/Project-OSRM/osrm-backend/blob/v5.27.1/docs/profiles.md)。Valhalla COPYING 声明 MIT,实际复制源码时需保留相应版权与许可声明;本轮未复制其 C++ 实现。
+
+## 3. 小样本验证结果
+
+Windows 本机 Docker daemon 未运行,因此复用已配置 SSH 到测试服务器运行一次性隔离试验。没有安装依赖、修改正式配置或重启原服务。
+
+- 生成合成 OSM XML,并分别构建汽车、部署机车基线、一次性机车候选三份小路网。
+- 构建容器无网络;算路容器仅绑定 127.0.0.1 的随机端口,每容器限制 0.5 CPU / 256MB,单线程。
+- 候选只在拥有的 /tmp 临时目录生成:保留硬通行规则,gravel 的 rate ×0.2、信号灯 weight +8、较大转向 weight +3。数值由本次探针选择,仅验证机制,不是 Valhalla 原值或生产批准参数。
+- **35 项常规断言通过,另 1 项限制探针确认了条件禁行未生效;无脚本错误。** “限制探针通过”表示成功复现不足,不表示该功能受支持。
+
+| 验证内容 | 实际结果 |
+|---|---|
+| motorcycle=no;motorcar=no 但 motorcycle=yes | 汽车与机车按不同权限选路;基线和候选均正确 |
+| motorway/link、无许可 trunk/motorroad | 基线和候选均绕行;明确许可的 trunk/motorroad 均能通行,说明当前是该保守策略而非无条件全部排除 |
+| 单行、机车特定单行覆盖 | 两个机车版本均按预期选路 |
+| 通用及机车专属转向关系 | 均绕过禁止的直接转向,约 653.6 米 |
+| 车型限速 | 同一路段约 221.6 米,汽车 11 秒、机车 49.8 秒,证明车型限速参与实际时间计算 |
+| 固定碎石路:权重与估时分离 | 距离 221.6 米、时间均 20 秒,权重由 20 变 99.6 |
+| 固定信号路口:权重与估时分离 | 时间均 34 秒,权重由 34 变 42 |
+| 固定转向:权重与估时分离 | 时间均 29.6 秒,权重由 29.6 变 32.6 |
+| 有替代路线时的路面偏好 | 基线 221.6 米/20 秒;候选改走铺装绕路 423.1 米/60.9 秒。这是实际路径变化,不是把原路径 ETA 乘惩罚系数 |
+| 道路吸附失败、不同连通分量 | 分别 NoSegment、NoRoute,未伪造直线成功 |
+| 按序三站点往返 | 保留两条 leg 并返回整线距离 |
+| 全天禁止的 motorcycle:conditional | 基线仍走约 221.6 米直路,确认当前没有执行该条件 |
+
+试验同时说明:软偏好设得过强可能明显绕远,不能把“借鉴路面权重”直接等同于“配送最优”。正式参数需用实际订单、骑手入口和运营目标校准。
+
+### 证据与复现
+
+- [脚本](evaluation/run_model_probe.py),仅使用 Python 标准库、已有 SSH 和服务器现成 OSRM 镜像。
+- [机器证据](evaluation/model-probe-results.json),保存固定来源哈希、全部断言、实际指标及清理核对结果。
+- 按 [plan.md](plan.md) 的命令复现。此脚本会创建临时文件和临时容器;只停止本次创建的容器 ID,不操作生产 Compose 项目。
+- 检查确认:生产文件前后哈希一致,三个原服务容器仍运行,临时容器及临时目录均已移除。无业务库读写,无凭据入库或入文档。
+
+## 4. 后续生产实施的最小范围
+
+本节是评估建议,不是本次已执行的生产改造:
+
+1. 保留部署机车通行/单行/转向规则,先明确适用机车类别及快速道路保守策略,避免照搬通用 moped 或旅游摩托车模型。
+2. 仅补充经业务确认的静态距离、路面、信号灯、转向权重与速度上限;时间和偏好分开,不直接采用试验常量。
+3. foodie 将 vehicleType=1/2 映射为 profile=motorcycle/car,透传到已经支持车型的 GET /route/multi;同步校准 032 的历史 car-only 假设与 ETA 口径。
+4. 在版本对齐后重建独立机车路网,按真实台湾取送点回归、性能压测和回退验收,再决定上线。
+
+**当前基础静态需求无需改 OSRM C++ 的结论有机制与夹具证据;完整复刻 Valhalla 或实现动态时间导航则没有这一保证。** 本轮没有部署/运行 Valhalla,没有验证两引擎同图同参数的等价路线、台湾机车法规全覆盖、实际配送 ETA、P99 或长期运行容量。
+
+本轮四项评估已验证;生产实现、全量路网更新与线上切换仍未开始。未自动 commit/push。

+ 8 - 4
specs/032-self-hosted-map-integration/osm-requirements-assessment.md

@@ -4,7 +4,9 @@
 
 来源:用户提供的《OSM地理信息服务后端需求文档.docx》及本次会话的代码核对、测试服务器接口实测。
 
-用户决定:先记录,后续再实施。本记录列出的追加工作状态为 **未开始**;不表示 032 已有能力未实现,也不表示本次已修改业务代码或完成地图验收。本次不切换分支、不部署、不修改数据库,不调整 App。
+初次用户决定(2026-10-10):先记录,后续再实施。当时追加工作状态为 **未开始**;不表示 032 已有能力未实现,也不表示已修改业务代码或完成地图验收。本轮仍不切换分支、不部署、不修改数据库、不调整 App;当前评估进度见下述追加记录。
+
+**2026-10-12 追加进度**:用户同意完成四项机车模型评估,现已核对部署文件与模型指纹、形成 Valhalla/OSRM 规则对照并执行真实小路网试验。35 项常规断言通过,1 项限制探针确认条件禁行未实现。评估报告见 [model-evaluation.md](model-evaluation.md),执行范围与状态见 [plan.md](plan.md)、[tasks.md](tasks.md)。以下 2026-10-10 实测及“先记录”口径保留为历史;四项评估已验证,生产改造和部署仍未开始。
 
 ## 一、结论与版本边界
 
@@ -54,7 +56,7 @@
 
 ## 四、后续继续工作的入口
 
-以下是待办记录,不是本次已批准执行的实施计划;用户决定继续后,再补充或更新 032 的 `plan.md`、`tasks.md` 和相应地图侧规格。
+以下是后续生产待办,不表示已批准上线实施。2026-10-12 的四项评估另见本目录 plan/tasks;生产改造获准后再扩展实施任务和相应地图侧规格。
 
 ### 机车模型复用方向(2026-10-10 会话追加)
 
@@ -70,8 +72,10 @@
 
 状态:**未开始**。本次未转换 C++、未修改 Lua、未重建路网、未部署 Valhalla,也未切换算路引擎。
 
-- [ ] 核对地图分支、服务器发布清单与实际接口契约,避免用旧 master 或未部署分支推断能力。
-- [ ] 对照 Valhalla 两种机车模型,识别可迁移至 OSRM Lua 的规则及不能等价迁移的能力,再形成最小实施范围与测试样例。
+2026-10-12 更新:上述为初次记录时的状态。本轮评估已验证;只在临时目录生成过候选 Lua 和合成小路网,未修改正式 Lua、未重建台湾路网或切换引擎。
+
+- [x] 核对地图分支、部署与容器文件指纹及实际接口契约;整体发布提交不能仅凭分支名推定,差异已记录。
+- [x] 对照 Valhalla 两种机车模型,分类规则并完成最小范围的隔离测试;报告与证据见 model-evaluation.md。
 - [ ] 核对 `/route/multi` 的车型参数支持;foodie 将 `vehicleType=1/2` 映射为 `motorcycle/car`,并覆盖两点、多站点与降级行为测试。
 - [ ] 补充台湾机车合法路线验收,并确定短距离、红绿灯和转向权重。
 - [ ] 补充台湾地址简繁归一化、地址解析和逆查结构化字段。

+ 42 - 0
specs/032-self-hosted-map-integration/plan.md

@@ -0,0 +1,42 @@
+# 地图机车模型四项评估计划
+
+> 执行方式:在当前会话直接完成用户已确认的四项评估;不创建分支,不交付生产实现。本文件是研究与隔离试验计划,不是地图全量切换实施计划。
+
+**Goal:** 确定现有 OSRM v5.27.1 是否足以承载当前配送机车规则,以及借鉴 Valhalla 的最小范围和明确限制。
+
+**Architecture:** 以已部署机车 Lua 的文件指纹和固定上游提交为基线;使用合成小路网及临时 OSRM 容器验证规则。测试候选只在临时目录生成,不覆盖地图仓库或服务器正式文件。
+
+**Tech Stack:** Python 标准库、SSH、OSRM v5.27.1 Docker、OSM XML 夹具、Lua API v4。
+
+**Spec:** [osm-requirements-assessment.md](osm-requirements-assessment.md)。用户于 2026-10-12 同意执行其中基线核对、规则对照、小样本验证和最终结论四项工作。
+
+## 约束
+
+- 不修改生产 Lua、foodie 业务源码、App 或数据库;不重建台湾全境路网、不部署 Valhalla、不切换线上流量。
+- 临时容器只绑定服务器回环地址,限 CPU/内存,测试结束停止;不操作既有服务容器生命周期。
+- 不读取或记录数据库密码、地图管理令牌等凭据。SSH 使用既有登录配置,不把私钥纳入文件。
+- 固定 Valhalla 源码提交和 OSRM 镜像版本;候选参数仅是可行性探针,不是运营批准参数。
+- 仅验证技术规则和合成样本,不宣称台湾全境合法通行、两段式转弯、真实 ETA 或生产 P99 已达标。
+
+## 核验重点
+
+1. 本地 master、双车型分支、部署源文件及容器内文件是否一致。
+2. 机车禁行优先于路线偏好,不以“降低优先级”替代禁止通行。
+3. 路线权重与行驶时间分别设置,软偏好不直接把 ETA 放大。
+4. 单行、车型转向限制、吸附失败和不可达应保持明确语义。
+5. 动态时间限制、交通、坡度/密度与两段式转弯不能仅凭 Lua 名称或常量复制就认定支持。
+
+## 任务与文件
+
+- 基线及规则评估:记录于 `model-evaluation.md`,包含固定版本、文件哈希、规则对照和结论。
+- 可复现小样本:`evaluation/run_model_probe.py`,生成隔离夹具和一次性候选 Lua,执行真实 OSRM 请求。
+- 机器证据:`evaluation/model-probe-results.json`,由脚本生成,包含验证结果、来源哈希及清理结果,不含凭据。
+- 状态追踪:[tasks.md](tasks.md),只标记本次四项评估,不把评估通过当作生产实现完成。
+
+执行顺序:先固定来源和部署指纹,再对照规则,随后运行隔离试验,最后依据实际结果写出结论和未验证项。脚本命令为:
+
+```powershell
+& 'C:/Users/99029/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/python.exe' specs/032-self-hosted-map-integration/evaluation/run_model_probe.py
+```
+
+验收:四项均有可审计证据;失败样本明确分类;原有生产文件指纹和服务容器保持不变;不自动提交或推送。

+ 2 - 0
specs/032-self-hosted-map-integration/spec.md

@@ -10,6 +10,8 @@
 
 **后续事项记录(2026-10-10)**:用户要求先记录、后续再做。台湾 OSM 需求对照、双车型测试服务实测、地址精度/性能差距及待办见 [osm-requirements-assessment.md](osm-requirements-assessment.md)。追加工作未开始,未改变上述既有实现进度;本规格的 car-only 和汽车时长折算假设需在恢复实施时重新校准。
 
+**机车模型评估(2026-10-12)**:用户确认的四项评估已验证,见 [model-evaluation.md](model-evaluation.md)。评估用 plan/tasks 已补充,只覆盖本轮研究与隔离试验,不是 US2/US3/US5/US6 的实施计划。测试服务器 GET /route 和 GET /route/multi 已支持 profile=car/motorcycle;foodie 车型透传仍未实现。35 项常规断言通过,1 项限制探针确认条件禁行未执行;生产规则、路网和部署未改变,未宣称全境导航或性能验收完成。
+
 ## User Scenarios & Testing *(mandatory)*
 
 ### User Story 1 - 闪送报价距离切换到自部署地图(Priority: P1)

+ 10 - 0
specs/032-self-hosted-map-integration/tasks.md

@@ -0,0 +1,10 @@
+# 032 本轮机车模型评估任务
+
+日期:2026-10-12。范围见 [plan.md](plan.md),不包含生产改造、部署或全量地图切换。
+
+- [x] E001 核对本地/远端分支、部署源文件和容器内文件指纹,以及实际路线接口;API 与两个分支并非整体一致,已记录文件基线而非猜测发布提交。
+- [x] E002 对照固定提交的 Valhalla 两种机车模型和 OSRM v5.27.1,分类已有、可迁移、不能等价迁移的规则,见 model-evaluation.md。
+- [x] E003 执行真实 OSRM 小路网试验:35 项常规断言通过,1 项限制探针确认条件禁行未实现;生产文件不变,隔离容器和目录已清理。
+- [x] E004 给出保留 OSRM 的静态规则适用范围、待改规则和未验证限制,更新评估记录及机器证据。
+
+当前状态:已验证(仅本轮四项评估)。详见 [model-evaluation.md](model-evaluation.md) 和 [机器证据](evaluation/model-probe-results.json)。生产实现与部署未开始,未提交或推送。

+ 367 - 0
specs/036-multi-store-checkout/plan.md

@@ -0,0 +1,367 @@
+# 同一用户多店结算与同骑手配送方案草案
+
+**Branch**: 当前 `test-202609v2`,不创建/切换分支。
+
+**Date**: 2026-10-12 | **Spec**: [spec.md](spec.md)
+
+**阶段**: specify 与方案讨论;业务实现未开始。这是可持续修改的第一版方案,不是已批准的执行计划。
+
+## 1 核心建议
+
+保留“一次结算父单、每店独立子单、各店独立收款”。父单管本次确认参与集合的准入、生效、超时和取消;配送组管同骑手履约,不能把整份购物车等同于本次结算。用户已澄清:同骑手A/B父批次不包含圈外C,也不等待C付款;普通不合并结算不创建组。
+
+付款后不改收款归属、不跨商家调拨。取消时按原支付交易使用对应商家凭证退款。OMG 正式退款已按用户最新要求纳入;环境限制是验证限制,不是砍掉功能的理由。
+
+用户已确认首版纳入**即时平台外送、预约平台外送、到付**;夜市自配送、自取沿用原流程。预约支持在线付款或到付。同一用户、同一地址仍是合并前提;新多店批次不支持线下转账、原单店转账不变已确认(Q13)。堂食暂不纳入仍为方案建议。
+
+2026-10-12用户最新决定:**预约时间处理改造后续单独进行**。不撤销预约多店支持;本輪仅按当前有效规则做兼容和批次准入,不重定义时间语义、不新增最迟接单或预约无人接单自动取消。Q12和FR-014移至后续独立事项,详见3.1;不能据此默认旧预约链路已闭环。
+
+用户指出各商家可独立设置支付方式,撤回“同批统一付款方式/渠道”的建议。改为按每家店提供可用方式、用户逐店选择、子单独立保存payType;父批次只汇总,不覆盖子单。LINE/OMG/到付混用及统一生效已确认;新批次过滤转账,不改原单店流程(Q13)。预约时间改造延期见Q12。
+
+2026-10-13最新确认:逐笔收银选择方式,所有新批次共用创建起15分钟支付发起/到付确认期限;期限内服务端已记录并实际发起的线上尝试允许在默认5分钟、平台可配置缓冲内完成付款和服务端确认。创建时快照缓冲及最终确认截止;第15分钟停止新发起/重试/方式切换/到付确认,既有渠道页面可继续完成,不再要求证明用户认证/扣款均发生在第15分钟前。最后成员合法就绪即生效,无需等满缓冲;最终确认截止仍未就绪超时取消。全到付仍须逐笔确认且不是实收,不创建即生效或免期限;旧非多店规则不变。
+
+### 首店围栏与本次结算成员(用户已确认)
+
+以用户选择的第一家店A为圆心,默认范围1公里、在平台合并配置中可调整;后续店分别与A按坐标直线距离比较,不按道路路线距离,不从B继续扩圈、不以收货地址为圆心。2026-10-13用户明确确认直线距离及范围可配置,不把1公里写死在代码。配置必须有效且大于0,缺失/非法时保守关闭合并;具体单位/精度在data-model/contracts明确,前后端一致。已确认A/B圈内、C圈外时,C不参与本次合并父批次/配送组,A/B的付款生效、金额和折扣不等待或包含C。撤回原Q2“C同批独立配送且ABC一起付款”建议;也不采用“购物车有C就禁止A/B合并”的上一条解释。
+
+接入建议:预结算返回本次可合并参与店和圈外排除原因,用户确认本次A/B后提交;C保留购物车供另行结算,不自动建C单/收费、不静默丢弃商品。最终创建仅写A/B父子单、组和快照;若请求夹带C或成员与确认报价不同,应明确拒绝并重报,不在事务内偷偷过滤。圈内参与不足2店不形成合并组;删除首店/改变成员或地址需重算及确认。
+
+已有C另行结算订单的状态、付款、取消或退款也不影响A/B父批次。统一生效检查只查询该父单真实成员,不扫描用户所有订单或购物车全部店。此边界与“用户是否选同骑手”分开;不选同骑手时各店按原运费计费的规则不因本次澄清改变。
+
+### 每店不同支付方式的建议
+
+复用StorePayMethodController和PaymentMethodGateService逐店查询平台/商家/凭证可用方式,不取跨店交集。统一创建仅校验参与店至少有一种本批次可用方式,不要求创建请求逐项携带已选方式,也不默认选到付;后续逐子单收银请求再传所选payType、重新校验并保存。旧OrderCreateInput全局paymentMethod及旧单店行为保留,不直接照搬其创建即设到付准入的逻辑。
+
+三店均参与、A选LINE/B选OMG/C选到付:C须在15分钟期限内合法确认,A/B须在该期限内由服务端记录并实际发起支付尝试,可在默认5分钟可配置缓冲内完成真实付款和核实。最后成员就绪即生效,C仅创建不就绪,不提前推送或被抢;全到付最后一单须在15分钟内确认,缓冲不允许新选择/发起/重试/切换或到付确认。两个截止在创建时固定,不因逐店付款/重试延长。
+
+取消/超时按每笔真实收款处理:A/B 已实收则原渠道退款,C 未实收只取消、不产生网关退款。支付进度分别展示线上已付/待付和到付约定,不能把到付计为已实收。骑手取餐、收款页面需逐子单显示付款方式,并单独合计到付待收金额,不能向用户再次收取线上已付餐费。线下收款路线仍沿用现行到付逻辑,不引入新资金池。
+
+混用、逐笔选方式及统一15分钟发起/到付确认期限已确认。已就绪数与实际已付款数分别展示:到付确认参加就绪进度,不参与实收金额。支付失败/未知或未选方式仍不就绪;客户端回跳/已完成标志不能代替服务端事实。旧渠道尝试未决时不能直接切到到付并宣称就绪,须先核实/明确对账补偿,不直接复用旧切换接口或假定旧交易已经关闭。
+
+### 线下转账范围(用户已确认)
+
+新多店批次只允许当前真实可用的LINE Pay(payType=3)、OMG信用卡/Apple Pay(2/5)和到付(1),无论是否选择合并配送,均不允许转账(6)。本轮不新增待核款提醒、核款期限、转账人工退款或迟到转账补偿入口;此前候选兼容方案已撤下,不继续作为待实现内容。
+
+每店可选集=该店现有闸门返回的就绪方式∩本批次支持方式;商家可不同渠道,不要求跨店交集,但每店都必须有至少一种本批次方式。过滤仅在新多店预结算/创建/换方式上下文,不关闭全局转账开关、不修改商家选择、不让现有StorePayMethodController的单店查询丢失转账。
+
+仅支持转账或没有就绪支持方式的店,预结算时明确返回不能参加多店结算的原因;用户可以另走原单店流程,不能静默移除该店、自动拆成另一次结算或替商家改到付。统一提交重新校验所有店,防止配置变化或伪造请求;在创建任何可支付子单前拒绝非法方式,避免付前两家才发现第三家不能付。多店后续切换同样不得切到6。
+
+现有单店payType=6、商家confirmTransferPayment及历史转账订单保持原流程;不因新的父批次闸门拦住非适用旧订单。回归覆盖单店转账下单、可用方式查询、商家核款和后续原履约。旧转账退款能力不足是已有单独议题,不能宣称本轮已经补齐,也不借此修改现有入口。
+
+## 2 当前基础与需要改变的入口
+
+| 现有入口/结构 | 当前事实 | 本功能拟调整 |
+|---|---|---|
+| UserOrderController.createOrder | 一个 OrderParent + 多个 PosOrder;每子单 parentDdId 相同 | 新批次标识、全量预检、服务端算价、幂等创建与支付期限 |
+| OrderParent.orderStatus | 0待支付、1已支付、2已取消;当前下单写0,尚无本批聚合机制 | 纳入到付后建议新增独立批次生效状态,不把“已支付”改成“到付已收款” |
+| PosOrder.isDisplay/displayTime | 有预约显示用途,骑手列表使用 isDisplay | 保持展示职责;不单独承担付款与操作授权 |
+| LINE Pay request | 明确拒绝多门店父单下子单支付 | 放行经过新批次校验的子单,继续取该店金额与凭证;不简单删除全部限制 |
+| OMG create | ddId != parentDdId 的多店子单被拒绝 | 同上;保留所有订单归属、状态、金额和凭证校验 |
+| 单店付款成功/到付创建通知 | 当前按子单成功直接进入新单通知,无批次就绪检查 | 本次已确认付款事实先保存,全部合法就绪才持久记录幂等开放事件;预约等旧开放时点,提交后发送并支持重试,部分付款不得提前履约通知 |
+| 骑手 acceptOrder(id) | 一次只分配一个子单,容量按 pos_order 计数 | 新配送组原子分配;单笔入口拒绝拆抢;现有任务字典按组计1单/普通单计1单,统一外卖与跨类型总量,不新增子单总量上限 |
+| 用户 cancelOrder(id) | UserOrderController.cancelOrder仅校验state=0,骑手接单更新deliveryStatus/qsId而不更新state,可能放行已承接单;LINE退款意图按单处理 | 新批次未生效只允许整批取消;已生效须校验组分配及子单qsId/deliveryStatus,已承接拒绝,未承接条件参加事务/CAS保护;不能绕过旧单笔入口。当前记录方案,不提前改普通订单 |
+| 当前 OMG refund | 测试阶段拒绝所有退款,未提供正式退款执行链 | 新增正式退款编排、事实记录、查询/重试/人工处理与环境硬边界 |
+| LINE Pay refundFull | 仅全额;每付款记录目前对应一条退款记录 | 已确认用户取消只发生在未接并全退,优先复用全额机制;异常售后如需部分退款另确认,不默认新增多次/部分退款扩展 |
+
+## 3 一次下单的流程
+
+1. **预结算**:前端传地址、按选择顺序的候选店商品、是否同骑手。服务端按首店围栏返回本次参与集合和圈外排除原因,验证参与店/商品/支付方式等,计算运费和实付;不把购物车圈外C混入A/B批次金额或付款进度。
+2. **统一提交**:以token确定用户,以真实门店确定商品/归属;校验用户确认的参与集合及资格,用报价版本/幂等号防重。本地事务只写本次成员父子单、收费规则快照和配送组,不另建圈外店单;夹带C或成员不一致明确拒绝,不静默删店,不留下部分可支付单。
+3. **逐单收银确认**:用户按子单进入各店收银台选择方式。线上独立凭证/交易且可信付款成功才就绪;到付由服务端校验用户确认、无渠道扣款且不记实收。未选/失败/未知仍待确认,进度可恢复,不由前端决定就绪。
+4. **批次生效**:各线上子单须在15分钟内由服务端记录并实际发起支付,且在最终确认截止前取得可信付款成功事实;到付须在15分钟内合法确认。全部真实成员合法就绪、未取消且未过最终确认截止时原子生效一次。不等满缓冲,不按创建或默认到付状态开放;即时提交后开放、预约沿原规则。
+5. **整批取消**:未生效用户主动取消立即关闭;用户截止停止新操作,但合法在途支付按缓冲核实,不立即取消未知结果。最终确认截止仍未就绪原子关闭,已实收逐笔落全额退款意图,未实收到付/未选只取消,未知/迟到付款继续对账。确认已无法满足全部就绪时可提前关闭;已生效不再按结算超时取消,预约无人接单另属延期规则。
+6. **配送履约**:合并组只出现一次待抢任务;骑手接组后子单共享骑手,其后各店接单/出餐、骑手取餐/送达、账单及通知沿普通子单流程独立处理。取餐顺序由骑手自行决定,不按用户选店顺序强制,不要求组内全部取齐后才能送达某一已取子单,不新增整组送达/结算入口。非合并子单各自进入原抢单流程。
+
+沿用当前普通外送要求:**批次生效且到达预约开放时间后开放抢单,骑手接组成功后商家才接单/出餐**,不是先等各店出餐再让骑手看到,否则会与现有骑手先接单规则互相等待。即时单无预约时间等待;独立取餐/送达与组汇总按已确认Q11处理。用户明确多店目的为配送费优惠,除已确认批次支付/接组/收费规则外,不额外设计新的履约流程;本次独立履约不撤回已有批次就绪/超时取消决定。
+
+预约批次共用15分钟发起/到付确认及快照缓冲,线上既有尝试可在缓冲完成和核实,不等预约当天付款;全部就绪后沿旧时点开放。本轮已批准普通平台配送和新多店预约后续扫描、重启补扫、幂等开放和通知重试,不新增预约语义/最迟接单/自动取消。创建先设置type/delryTime再判旧显示条件,普通预约不套多店期限,夜市自配送/自取不纳入扫描。
+
+2026-10-13已确认合并资格:即时单与预约单不能同组;预约单的日期、开始和结束时间必须完全一致才能同组,仅时段重叠、不同日期同钟点均不允许。预结算明确方式/时段不兼容,创建按服务端规范化的真实配送方式/日期/起止时间重验并拒绝不一致成员,不静默改时间、转即时或自动拆组。由于同组由同一骑手承接,不仅凭“都是预约”或时段交集放行。此决定不取消同一时段预约多店支持,也不提前启动下方预约时间改造。
+
+### 3.1 预约时间处理:后续独立事项
+
+**记录日期:2026-10-12;2026-10-13更新范围。新预约语义/最迟接单及无人接单自动取消:未开始,仍延期;旧时点基本开放扫描、重启恢复、幂等及通知重试已经批准纳入本轮。** 以下新算法建议不代表已批准或实现,基本扫描见7.1。
+
+1. 用户选择的18:00–18:30拟定义为期望送达时段,不是18:00才开始配送;需要统一前后端文案、时间字段及旧数据兼容,不能直接把历史delryTime重新解释。
+2. 本轮基本扫描复用原有效时间判断及sys_appointorder_handletime,不改变原时点语义;源码无配置默认60分钟,部署值未核实。18:00开始且提前60分钟时17:00开放仅为示例;新多店另须批次生效,普通预约不要求批次状态。新的开放算法后续评估。
+3. 最迟接单拟由预约结束减履约预留时长倒推,预留包含备餐、多店取餐、配送及缓冲,不只用地图行驶时间。首版是否使用保守配置、如何随门店数调整,后续确认。
+4. 到达最迟接单仍未承接的配送组/独立订单拟停止抢单并取消,线上已收原路退款、未实收到付不产生退款。生效后不连带取消已接/已取餐任务;已接超时转异常跟进,不直接自动取消。新多店不支持转账,原单店转账若涉及预约取消需在后续专项另评估,不增加到本轮。
+5. 下单时校验可履约时间和各店备餐/取餐营业时段、预约太近不开放选择等时间算法留待后续;本轮同组预约日期/起止必须完全一致已确认,不再作为延期待确认项。不同独立订单/批次的预约时间与付款衔接后续按范围另评,不擅自要求所有购物车候选统一时段;原单店转账核款与预约衔接如有需要后续另定。
+6. 用户本次已批准旧时点下持久开放扫描、重启补扫、通知幂等/重试及开放与取消/接单竞争,移入7.1及本轮验收,不再延期;新最迟接单/自动取消扫描仍后续。列表与直接接口校验时间资格,不靠App倒计时、不复用TestTask。
+7. UserOrderController在setDelryTime前调用handleIsDisplay,可能把预约当立即单;实施本轮扫描时保证平台配送预约先设置type/delryTime再判旧开放条件,不以错误显示状态掩盖扫描缺失。不重解释历史时段或批量回写历史已开放单;超过预约结束的最迟接单/自动截止改造继续延期。
+
+后续仍从本节确认新预约语义、最迟接单及自动取消;旧时点基本扫描按本次已确认范围在7.1落实。当前只更新方案,不写业务代码、执行SQL、创建或切换分支,不声称扫描或新预约功能已完成。
+
+## 4 开放履约字段和并发控制
+
+原草案建议复用父单 `orderStatus`;用户纳入到付后,**改为建议新增独立 `checkoutStatus`(最终枚举在 data-model 确认)**,不改变原支付状态含义,不用“到付”冒充渠道付款成功:
+
+| 新批次状态 | 子单真实付款事实 | 新批次履约资格 |
+|---|---|---|
+| 待生效 | 前15分钟可选方式/发起/确认到付;截止后仅既有线上尝试可继续完成付款和核实,未全部合法就绪仍待确认 | 全部禁止 |
+| 已生效 | 所有真实成员均已线上可信付款成功或明确合法确认到付;确认到付不是实收 | 满足预约开放/售后/配送归属等规则后才可以 |
+| 已取消 | 可以尚未付款、已付款待退款或已退款 | 全部禁止;后续成功付款不得重新生效 |
+
+必须有**新批次适用标识**(例如 `checkoutBatchVersion=1`,或新状态为空表示旧流程,最终在 data-model 确认),避免影响非适用的历史/单店/旧到付订单。新多店到付属于适用范围;状态字段及兼容标识方案尚待确认。现有到付子单 payStatus=1 是旧履约准入约定,不是渠道实收证据,退款及收款展示必须结合付款方式和真实交易。
+
+2026-10-13用户明确兼容底线:不影响现有非多店订单。新标识必须由服务端新批次创建流程写入,不能由客户端自由指定;不能仅靠parentDdId非空、子单挂父单或数量判断适用。标识缺失的现有普通/历史订单、自取、夜市自配送沿原流程,不被新增15分钟期限、整批就绪、价格/营业策略、转账过滤或通知延迟影响。共享入口允许加合法新批次分支,但不得全局移除LINE/OMG原多店限制或更改原创建/取消规则。
+
+- `isDisplay` 不改变预约时间含义。骑手/商家待办列表同时过滤父批次生效条件,直接操作和推送再独立检查;不是“列表看不到就无法调用接口”。
+- 用户端待付款、支付进度、退款进度仍可见,不把 isDisplay 用在用户待付款查询。
+- 同一父单的付款核销、激活、取消、超时扫描按固定锁顺序串行;数据库父行锁/CAS是最后保护,不只依赖 Redis 锁。所有路径先取得父批次锁再锁支付/子单,避免现有先锁单笔支付再锁父单与取消反向锁造成死锁。
+- 同一配送组接单顺序建议:现有骑手锁 → 组行锁 → 按 ID 顺序锁子单;全部资格、现有任务容量和组门店数校验在锁内,全部分配或全部失败,骑手锁保持到事务结束。不再检查额外子单容量;接组/取消等跨父批次与组的完整锁顺序将在 data-model/执行计划统一,不能各入口自行反向加锁。
+- 支付发起/到付确认截止=本地创建成功时间+15分钟;支付完成与确认缓冲默认5分钟、平台全局可配置;最终确认截止=前者+创建快照缓冲。配置修改只影响新批次,重试/逐店结算不续时。选择/发起/重试/换方式/到付确认检查第一个截止;既有线上尝试完成/核销/补单、生效/扫描检查发起资格、真实收款证据及最终截止。第15分钟禁止新操作但保留合法在途交易继续完成,最终截止未就绪关闭后仅记实收/退款,不复活。
+- 2026-10-13用户接受明确放宽:15分钟内发起线上支付,合法既有尝试允许在缓冲内完成用户认证、付款与服务端确认;不再严格要求第15分钟前认证/扣款完成,也不只允许后台响应延迟。服务端实际发起证据与渠道实收事实分别保存,核实原订单/交易、金额、币种、商家及签名/查询结果,不能信前端点击/回跳或事后补写发起时间。全部合法事实核实后立即生效;主动取消已关闭的批次不再使用缓冲重开。
+
+### 付款确认缓冲及渠道时间适配
+
+- 平台合并配置页维护支付完成与服务端确认缓冲(分钟),默认5,保存只接受正整数;缺失/非法/读取异常回退5而非无限。创建固定时长/两个截止,修改配置不追改旧批次。默认14:00创建→14:15支付发起/到付确认截止→14:20最终确认截止;第15分钟后禁止新发起/重试/切换/到付确认,既有合法渠道页面仍可继续完成付款。接口/前端文档须明确这项经用户确认的口径放宽,不宣称全部用户付款操作必须在15分钟内结束。
+- 缓冲仅允许第15分钟前服务端已持久记录并实际发起的真实支付尝试继续完成,包含用户渠道认证/付款、回调、查询/补单及服务端确认;前端仅点击、未实际发起、失败后新重试均不因此获得缓冲资格。未知结果不能直接认定未付;未全部核实期间不给商家/骑手履约。全部合法成员就绪立即生效,不必等20分钟;最终截止仍未就绪取消,继续追踪未知交易及迟到实收退款,不能无限等待或在取消后复活。
+- OMG官方[查询订单](https://developers.omg.com.tw/payment/aio/api/04_order_query.html)提供PaymentDate;LINE官方[付款明细](https://developers-pay.line.me/zh/online-api-v4/retrieve-payment-details)提供transactionDate。渠道交易时间、服务端发起时间和本地收款核实时刻分开保存,统一时区;第15分钟截止核对实际发起资格,而不是要求渠道付款时间早于该截止。OMG现有事实对象已有paymentDate,当前LINE补单多以本地new Date记payTime,不能直接拿它冒充真实渠道付款时间。真实收款、最终确认截止和取消终态仍须核实,只在合法新批次适配,不全局改变非多店行为。
+- LINE官方[付款请求状态](https://developers-pay.line.me/zh/online-api-v4/check-payment-request-status)区分认证完成0110与付款完成0123,[confirm](https://developers-pay.line.me/zh/online-api-v4/confirm-payment)仍是服务端步骤。采用已确认的发起截止口径,不依赖官方未提供的认证完成时间来证明用户在第15分钟前完成认证;在途合法尝试可在缓冲完成认证/确认/扣款,但认证或仅有请求号不算实收。data-model/contracts须明确实际发起时间/原尝试及渠道交易关联、证据未知时核查、真实收款核实和最终截止并发;当前业务确认不等于接口已实现或验证。
+- 不以长事务锁等待外部HTTP:渠道核查和父批次状态落库分开设计,但最终生效/取消须在同一并发保护下重验状态及截止。重启扫描、回调/查询重复或乱序都不能在第15分钟提前取消应缓冲交易、重复开放或绕过最终截止。
+- 生效事务记录幂等开放通知,预约记录旧规则可执行时点,提交后且满足开放条件才发送;合并组按组、独立单按子单去重,失败可重试,取消后不开放。逐单回调/到付确认仅记事实并聚合,不直接推履约新单;用户真实支付结果通知及后续逐子单取餐/送达通知保持原职责。
+
+### 创建校验、付款快照与旧流程隔离(用户已确认)
+
+- 新批次创建前完整校验各参与店、商品、配送资格、支付可用集合及服务端金额,成功后固化商品/价格/折后运费等快照;不能将此规则写成放松创建校验。
+- 创建后普通菜单改价、上下架、停止接新单或营业时间变化不触发重新报价/自动整批取消;用户仍可在创建起15分钟内发起支付/确认到付,既有线上尝试可在缓冲内完成。付款只基于已创建订单金额,继续验证用户归属、订单/批次未取消未过期、支付方式及凭证可用;旧尝试结果未知仍先对账,不能凭快照绕过支付保护。
+- 真实回调/查询核销仍使用对应支付尝试的原交易及凭证快照验真,不用商家后续菜单/凭证变化抹掉已收款事实。实际不能出餐按既定子单规则,不设计结算未完成时新增商家供餐审核或菜品变化监听;快照不是库存保证,本轮不新增库存预占系统。
+- 新批次规则只用于服务端明确标识的新多店;普通创建/支付/价格/优惠/取消等不全局改写,未标识不误判未就绪或进入整批取消。唯一相关补齐是本次批准普通平台配送预约按旧时点后续扫描,不套新批次期限;单店转账、自取、自配送不改。
+- 单笔取餐/送达及结算/通知仍沿普通流程,组只汇总/去重任务。优惠、促销和发票逐单沿原有效规则;本次已确认补充新批次取消优惠券释放/返还、保留原有效期及幂等,见7.1,不全局重写普通券规则,不把返券或取消状态当作渠道退款成功。
+- 回归必须覆盖新批次创建后改价/上下架/普通停业仍按快照结算、非法付款继续拒绝、旧流程无新期限/就绪拦截、普通LINE/OMG/到付/转账、自取/自配送、促销优惠券发票、取消退款及消息时机;没有实际运行回归前不宣称兼容已验证。
+
+## 5 配送组与金额快照
+
+建议新增一个轻量 `order_delivery_group` 表表示配送组,PosOrder 增加可空 `deliveryGroupId`;不新增收款账户表或跨商家资金池。不合并时不创建组。
+
+建议数据职责:
+
+- 父单:新功能标识、生效状态、幂等号、创建起15分钟用户操作截止、确认缓冲时长快照/最终确认截止、已就绪/已实收及待核实进度、预约信息、合并/锚点和收费快照;不设唯一渠道或全到付无截止例外,状态不代表退款到账。
+- 配送组:父批次、用户/固定地址、待抢/已接/履约/结束汇总状态、骑手、版本;成员关系由子单关联表达。组内可同时有待取、配送中、已送达、合法取消等子单,不用单一组状态覆盖各单事实;汇总只用于展示及任务释放,不新增整组取餐/送达/结算行为。
+- 子单:待选/逐单所选payType、线上已付/到付已确认的就绪事实、选择顺序、运费/优惠/实付快照与原履约状态。不能用创建时默认payType=1或payStatus=1判断到付已经由用户确认;字段/派生规则在data-model统一。
+- 退款:对应子单/原支付尝试/渠道交易、凭证快照、退款原因与金额、退款业务号、处理中/结果未知/重试/成功/失败/人工处理。新功能退款记录不能与“付款成功”或“父批次取消”混为一列。
+
+用户已确认首版全局规则、不做城市/区域差异配置,最大合并门店数默认5且可配置。开关、范围、最大店数、动态折扣阶梯及支付完成/确认缓冲统一在配置页面管理;支付发起/到付确认期限固定15分钟,缓冲默认5分钟可配置,创建时固定两个截止。城市/区域覆盖留后续,本轮不加归属字段。最大店数在预结算/创建/接组重验,接单须满足原任务字典,不自动拆组,不新增子单总量上限或要求最大店数小于任务上限。
+
+### 动态配送费折扣阶梯(用户已确认)
+
+平台维护可增删改的“最少店数+折扣”列表,无需为后续4店七折等档位改代码或重新部署,不使用固定两档字段。具体配置存储形式在data-model阶段沿现有配置能力确定,本轮不预先创建新表或DDL。
+
+- 报价按实际参与同骑手配送组的去重门店数N匹配,圈外店不计;选择所有门槛不超过N的档位中门槛最大的档位,不按配置行顺序或直接选最低折扣。
+- 首版初始折扣已确认,只配置两个档位:最少2家九折(系数0.9)、最少3家八折(系数0.8);没有更高档时3/4/5家均八折。4家七折不默认启用。后续平台若新增4家七折,则新报价中4/5家七折;再增5家六折,则5家命中六折。新增示例不代表上线默认值,不取消最大合并店数/骑手容量约束。
+- 同组各店原配送费分别乘该档位折扣,按HALF_UP四舍五入到整数新台币后固化,批次总运费为各店结果相加,不做跨店尾差分摊;商家给骑手折后毛运费及到付公式使用同一金额,详见下方已确认舍入口径。
+- 门槛必须为不小于2的整数且不可重复;折扣系数大于0且不大于1。保存拒绝非法配置;读取缺失/非法或无法形成合法报价时沿规格保守关闭合并报价,不影响原单店流程。
+- 创建时固化规则版本/命中门槛/折扣及每店金额;修改配置只影响新报价,不重算已创建待结算或已生效批次,允许阶段部分取消也不重算档位、不补差。预结算与提交间配置变化按现有报价重验约定明确重报价,不静默混用新旧金额。
+- 验收覆盖动态增删改、最高门槛匹配、参与店去重、圈外店排除、非法配置拒绝、旧订单快照、提交配置竞争和取消不补差。
+
+### 5.1 骑手接单容量:沿用字典、合并组算1单(用户最新确认)
+
+2026-10-13用户确认撤回此前双层容量方案:不新增骑手实际子单总量上限、新字典或页面管理项,只沿用现有接单字典,并调整计数为合并组整体算1单。组内最大门店数由本功能配置独立控制,默认5家;这是单组规模,不是骑手所有子单合计限制。
+
+- 外卖任务数 = 进行中的配送组数(按deliveryGroupId去重)+ 未分组进行中独立外卖订单数。只有真实组才去重,不按parentDdId去重;A/B组与另行结算的独立C计2任务,C不属于A/B合并父批次。
+- 跨类型总任务数 = 外卖任务数 + 进行中闪送任务数。闪送任务和加急独占规则保持不变;外卖接单、闪送接单使用统一统计,不能其中一个入口仍按组内订单行计总任务。
+- 接一个有N条有效待承接子单的组,只需要外卖任务+1、跨类型总任务+1不超过原字典上限,且N不超过合并门店上限;普通独立外卖为+1任务。不按骑手全部子单数量另设接单上限,组内订单仍分别保留履约/支付状态。
+- 全部资格、任务和门店数校验在同一个骑手锁与分配事务内完成。不把未接单预约组预占到某个骑手,不把到付当作不占任务。
+
+配置职责:
+
+| 配置 | 口径 | 本轮处理 |
+|---|---|---:|
+| sys_rider_food_active_order_limit | 同时承接外卖任务数;单独订单和配送组各计1 | 沿用现有配置,不自动改值 |
+| sys_rider_flash_active_order_limit | 同时承接闪送任务数,保持原义 | 沿用现有配置 |
+| sys_rider_total_active_order_limit | 外卖任务+闪送任务总数 | 沿用现有配置,不自动改值 |
+| 合并组最大门店数(复用本功能全局配置) | 一次合并最多多少店,可配置 | 默认5(已确认) |
+
+例:外卖/总任务上限均为1、组门店上限为5,空闲骑手可以接一个三店或五店组,均占1任务,不能再接另一普通单或闪送。若外卖/总任务均允许2且其他条件满足,两个五店组可以接,按2任务计,不因子单合计10而额外拒绝。以上是计数示例,不是修改现有字典值的授权。
+
+现有任务字典缺失、非法或读取异常均保守回退为1,即最多1个任务而不是组内最多1家店;合法五店组仍整体算1任务。合并门店数等配置缺失/非法时保守关闭合并报价,不回退为不限门店。接单按实时任务容量和门店上限再校验,不保证某位空闲骑手存在或接单;若组门店上限下调导致已创建待接组无法接,明确原因并交平台跟进,不追改收费快照,也不强制取消已接任务。不再校验每组最大店数与已撤回子单上限的大小关系。
+
+容量释放:组完成或合法取消部分子单后,只要剩余有效成员仍在履约/处置,组就继续占1任务;最后一个有效成员结束才释放整个组任务,不存在逐子单释放名额。各单独立送达不等于整组结束,某单结算/通知不等待其他单。已取餐进入售后或异常不等于货物已交付,确认实际履约结束或交接处理前仍占组任务。实现不能沿用“afterSaleStatus不为0就不计容量”的简化过滤;异常处置终态按Q5后续范围确认,不因Q11独立履约而新增特殊业务状态。
+
+验收至少覆盖:三店/五店组都算1任务、组与独立单分别计数、总任务和加急独占、普通单/闪送/接组并发。任务上限1时两组最多成功一个,任务上限2且其他条件满足时两个五店组可接;部分完成/取消不提前释放组任务、已取售后保持占用、字典异常回退1任务、组门店上限变更与历史非分组兼容。不得残留已撤回的子单数量拒绝条件。
+
+**配送费舍入(用户已确认)**:每店折后运费=该店原运费×命中折扣,在新台币元口径用十进制精确计算、HALF_UP到整数后固化;批次总运费=各店已舍入运费相加。不先对整批原运费总额打折舍入,不按比例分摊,不向任何店补齐尾差;撤下原比例分摊/稳定顺序补尾差建议。没有合并的单店仍沿原计费规则。
+
+例如41元八折32.8元按33元;45元九折40.5元按41元。三店原运费各41元、八折后各33元,总运费99元,而不是总额123元八折98.4元再舍入为98元。即使各店原运费不同,也逐店按同一规则处理。仅配送费折扣按本条舍入,不额外修改商品价/优惠券等原有金额规则。
+
+用户支付、真实原交易退款、商家给骑手的毛运费、到付总额减运费以及骑手账单计算基数统一使用已固化结果;佣金/税沿既有规则另算。允许阶段部分取消不重算剩余成员、不补差。实现前核对持久化/DTO与LINE/OMG的实际金额单位并明确转换(实体“分”的注释不能直接当成渠道单位),避免元/分错算;统一使用十进制运算,不能各端或支付渠道各自舍入造成不一致。
+
+示例:A/B/C 原运费各40,三店八折后运费96,每店实付32,各店餐费加32分别向该店支付。这不是从某店账户抽钱转给另外一店。
+
+### 5.2 折后运费与到付交付(用户已确认)
+
+各店配送毛收入按折后实付运费计算:上述每店32就是商家给骑手的运费,不仍给40,不新增平台或商家补足24的机制。配送折扣同步降低该单骑手毛配送收入;既有佣金/税另依原规则计算,不把32说成扣除佣金后的净收入。
+
+设每子单应付总额为A、折后运费为F:
+
+- 线上已付:用户给商家A,商家按现行取餐线下交付方式给骑手F;骑手不再向用户收该单A。原单店转账沿用原结算,不混入新批次。
+- 到付:现场有餐可交付时,骑手当面在交餐环节给商家A−F,再向用户代收A,配送毛收入为F;不能供餐时不先付钱、不设计取餐前垫款。A是优惠后实际应付,不能用商品原价减运费或从父批次总额直接扣。
+- A包含哪些商品优惠/包装等费用继续遵循服务端子单算价;F固定在子单收费快照中,三方展示和现有账单的freight基数相同。
+
+示例(不含其他优惠/附加项):各店餐费100、原运费40、折后运费32,每单总额132。若三店都是到付,骑手分别给商家100,向用户合计收396,商品交付款合计300,配送毛收入合计96;若A/B线上、C到付,仅C的132计入用户待代收,给C商家100,A/B商家分别给骑手32,不能把整个批次396再次代收。
+
+接口/前端接入文档逐店区分“给商家金额”“商家给骑手运费”“用户到付待收”,金额方向不能混成一个组运费字段。沿用现有到付资金路线,不新增资金池。已取消成员不继续作为待收目标;若现金已发生,按售后规则独立记录追回/退款事项,不能仅重算组总额掩盖事实。
+
+现有OrderService.setQishouBilling按子单freight计算佣金/税及账单并调用钱包入账,接入时须保持上述折后基数和原结算职责一致。现金交付、账单记录及钱包余额不得被解释为平台额外再支付一次同笔运费;钱包原结算用途及取消后的冲正范围需在实现时核对,不能仅改报价便宣称现金/账单已闭环。
+
+不在本轮方案中默认增加自动最优路线/TSP。围栏资格已确认用首店到其他店的直线距离、默认1公里可配置;配送距离/预计时长仍用道路路线,不把直线围栏判断改成道路导航/计费。取餐顺序由骑手自行决定,不强制按选店顺序执行。地图仓库现有按序多点算路可作为能力参考,尚未接通本业务,不能据此新增强制路线编排或宣称已联通;普通子单导航沿原流程。
+
+## 6 取消与退款
+
+### 生效前
+
+整批取消。每个已付款子单退**完整实付金额,包含该单实付配送费**;未付款取消,结果未知先补查。退款实际调用使用该子单对应商家凭证/交易,不能只改 payStatus。
+
+统一15分钟是支付发起/到付确认期限,不是催到付提前交现金;默认5分钟可配置缓冲仅允许合法既有线上尝试继续完成付款和核实,不给到付追加确认机会。全到付未确认不无限保留;在途未知不能按未付款抢先取消,最终截止未就绪关闭,真实线上实收原路退,未收款到付不因payStatus=1造退款。已全部合法就绪生效后不再按结算期限取消;预约无人接单截止仍属Q12延期事项。
+
+支付请求在发起截止前由服务端记录并实际发起,仅代表既有交易可在缓冲继续完成,不代表已经付款或可以开放履约;须在最终确认截止前核实对应真实收款。第15分钟后禁止新的支付发起/重试/方式切换/到付确认,合法既有渠道页面可能在该时点后完成付款,这是用户已接受的口径放宽。最终截止仍不就绪或用户已主动取消则关闭;本地关闭不能保证渠道不再扣款,迟到实收持续对账退款,不重新激活。
+
+### 生效后
+
+按阶段处理,不能全阶段套用原文“单店取消不退配送费”:
+
+- **已确认:骑手尚未接单。** 生效后可取消单个店铺子单,退该单完整实收含折后运费;例如餐费100/运费32、实收132则退132,不保留运费。未实收到付仅取消、移除待收,不产生网关退款。其他子单继续履约、原运费不变。
+- 取消与接组须事务内检查真实未分配状态并同步组成员/版本,不信前端待抢页面。取消先成功,旧接组请求须重验且不能分配已取消成员;接组先成功,用户取消拒绝,无退款或审核任务,不再走另一条“申请取消”路径。
+- 取消单退出有效配送集合,剩余成员继续形成待接任务;仅剩一单仍沿用原运费,不重报原价。所有有效成员取消时组/父单按全取消汇总结束,不再推待抢;各退款仍独立跟踪,不把任务结束当作钱已到账。
+- **已确认:骑手已接,用户不得取消。** 包括尚未取餐及商家尚未接单阶段,既不能单店取消也不能整组取消;不新增用户申请取消/商家审核,避免增加商家负担。2026-10-13用户再次明确取消必须判断骑手已接事实:现有UserOrderController.cancelOrder只判state=0不足,OrderLifecycleService.acceptDeliveryByRider仅更新deliveryStatus=1和qsId,商家未接时state仍可能为0。新批次检查配送组承接、qsId及deliveryStatus,不因某一个字段异常为空或仍为0掩盖其他承接证据;未承接条件须在事务/CAS中与取消更新同步保护,避免读后接单竞态。隐藏按钮不能代替直接接口校验;当前仅更新方案,普通非多店入口不提前改写。
+- **已确认:商家无法供餐、该店尚未取餐。** 骑手已接后,所属商家可按无法供餐原因取消自己的子单,全部实收含折后运费全退;其他店继续由原骑手履约,不补差、不重分配、不默认整组取消。此为商家异常入口,不恢复用户已接取消权限,也不新增用户申请/商家审核任务。
+- 商家取消须和该店取餐竞争校验:先取消则不能再取该单,先取餐则拒绝未取餐异常取消路径。取消同步有效成员/站点、用户到付待收及容量,通知骑手无需再到该店;全组无有效成员则结束组任务,退款仍独立跟踪。其他店已发生履约不被连带回滚。
+- 用户已明确到付现金流程:有餐可当面交付才给商家钱,无法供餐不会先垫款。因此未取餐无法供餐取消时,线上原交易实收全退、到付仅取消和移除待收,不新增骑手垫款返还或取餐前垫款状态。此前“已垫款未取餐”假设撤下,不再作为待确认问题或实施分支。交餐后不走该未取异常取消;空跑补偿仍是另一个待定事项,不默认增加。
+- **尚待确认:骑手无法配送、平台责任、取餐后的异常、商品质量等。** 本条用户主动取消限制不等于所有异常均不得退款,其权限/阶段及相关现金/骑手收入处理另定,不用通用用户取消接口绕过。
+
+用户已确认:生效后在允许单店售后的阶段部分取消,**剩余子单不补差**,保留原运费/折扣快照,不因三单变两单而换九折,也不因仅剩一单恢复原价;线上不再补款,到付剩余子单应收/给商家金额不追加差额,骑手账单仍按各单原折后运费基数。未生效仍仅整批取消;取消后重新下单按新的实际组合报价。
+
+例:三家均合法参与、原运费各40、三单八折后每单32,生效后未接时用户取消C,A/B仍各32,C完整实收含运费全退;如果骑手已接,用户不能取消C。商家/平台异常若允许退出后的其他成员仍不补差,其退款事实独立记录,不转嫁。
+
+用户取消的“已接申请取消、商家审核、退餐款保留运费”候选方案已撤下,不能列入执行任务。若后续异常售后确需部分退款,再明确金额/记录/渠道能力;未确认前不默认实现。容量释放遵守5.1,不能借异常退款立即释放已取餐仍待处置的容量。
+
+退款按实际事实记录。已确认的普通用户取消为全额;如果异常售后最终纳入部分退款,不能直接沿用payStatus=2并宣称全退,金额/类型和展示须独立表达。此扩展仍待异常范围确认,不视为本轮已定需求。
+
+### LINE Pay
+
+现有Online客户端使用v4;官方[v4 Refund](https://developers-pay.line.me/online-api-v4/refund)支持refundAmount,省略为全额。项目refundFull发空对象、每付款一条退款记录,可作为本次未接取消全退的基础,仍需接入批次取消、重复/迟到付款和未知结果对账。多次/部分退款不因旧稿默认保留运费而自动扩展;若异常售后确认需要,再追加金额、幂等业务号、累计对账和展示能力。
+
+### OMG 正式退款(用户已确认纳入)
+
+官方 [退款/取消交易](https://developers.omg.com.tw/payment/aio/api/05_refund.html) 说明 DoAction 仅正式环境可用,按交易状态选择动作,且不能把“接受请求”与银行已退回混同:
+
+- 已授权且全额取消:按可核验交易状态进行放弃授权。
+- 要关账阶段全额取消:按官方流程取消关账再放弃;部分金额按可用的退刷流程处理。
+- 已关账:按真实可退金额退刷。
+- 分期仅支持全额退刷,一般授权可支持部分退刷;商家余额不足等会导致退刷失败;自动关账开启时官方指定时段不能调用。发布前核对门店实际开通的信用卡/Apple Pay 交易类型及状态,不假设所有支付类型都能部分退款。
+- 当前 OmgPaymentRefundService 是测试环境拒绝边界。本功能新增独立的正式退款执行、交易状态查询/对账、持久记录、幂等重试/未知结果恢复/人工处理;不启用废弃蓝新代码,也不复制旧支付方案的未验证退款逻辑。
+- 网关环境必须由服务端配置和凭证决定,不能由 App 参数切到正式环境。正式退款仅在明确启用的正式部署中允许;测试环境禁止调用正式 URL,取消/测试结果不能伪装为真实到账。
+- 测试环境可验证组级取消与退款意图,但真实渠道调用标为不可执行,不无限重试。不建议在无真实退款能力的测试渠道上开放真实资金多店结算。
+- 2026-10-13用户再次确认按现有测试环境不能执行真实退款的限制推进:本轮先做 mock/单测/状态转换与网络故障测试,测试环境验证取消及退款意图,不要求真实渠道退款联调作为本轮测试环境验收前提;该限制不阻断方案确认,但不能据此省略正式退款实现或把模拟结果作为真实退款成功。正式凭证/部署及可控小额交易经用户另行批准后联调。**未真实联调前,不报告 OMG 正式退款已验证。**
+
+## 7 接口与实现落点(草案,非最终契约)
+
+建议保留旧单店创建接口,新增多店预结算/批次创建/批次详情与取消入口,统一复用商家下单规则而非复制一套旧逻辑。具体路径在 contracts 阶段确定,不让前端现在据草案编码。
+
+| 能力 | 输出/约束 |
+|---|---|
+| 多店预结算 | 首店锚点、同骑手本次参与店/圈外排除原因、即时/预约时段、参与店本批次可用方式(过滤转账)、原/折后运费、实付、报价版本;用户确认集合后提交 |
+| 多店创建 | 批次/子单号、待收银状态、15分钟用户截止/缓冲快照/最终确认截止;不要求提前确定方式,不创建即开放,预约沿原规则 |
+| 逐子单收银选择/到付确认 | 前15分钟选择本店可用方式,重验归属/批次/发起截止及凭证;线上真实收款核实才就绪,到付确认不扣款。缓冲禁止新的选择/发起/重试/换方式/到付确认,但允许合法既有线上尝试完成付款及确认;路径待contracts确定 |
+| 批次详情/进度与用户列表 | 未选/部分付款/已付待整批生效/缓冲核实中均可见并恢复合法结算,展示真实付款与到付确认区别及截止;token校验归属,不能按payType空值漏单或将payStatus=1误标可履约。App只交付文档 |
+| 子单支付 | 复用 LINE/OMG 真实支付入口,放行合法新批次成员,不接受客户端金额/商家凭证 |
+| 未生效整批取消 | 原子关闭批次,返回各退款真实进度,不承诺调用完成即到账 |
+| 合并配送接单 | 一次接组,返回所有子单;旧单笔接单不得拆组 |
+| 子单取餐/送达 | 沿用普通单笔入口及状态/权限/幂等校验,独立操作、独立结算/通知;不强制取齐,不新增整组送达/结算入口;同步组汇总但不连带修改其他子单 |
+| 平台配置 | 四语言合并配置、最大门店数、动态最少店数/折扣阶梯、服务端确认缓冲(分钟,默认5)及校验;说明原任务字典组计1单,不增加子单上限/另一套任务值,不追改已创建快照 |
+| 平台跟进退款 | 各店退款状态与人工处理入口,只能查询/执行有权限的操作,不能手工假设渠道退款成功 |
+
+业务编排和网关外呼继续放 `ruoyi-admin`;领域/Mapper/持久化放 `ruoyi-system`,依赖方向不反转。所有 DTO 明确声明,业务校验与错误国际化按 AGENTS;Java 加注释,平台与必要商家 PC 文案四语言。App 只交付接入文档,由前端人员实现。
+
+数据库变更仅在后续实现时登记 `updatesql/sql.md`,本轮不编造已定 DDL、不运行迁移、不连接业务数据库。现有无关修改保持原状。
+
+### 7.1 2026-10-13代码审查第4~9项:已确认接入
+
+以下为已确认范围,不再列为待确认业务问题;字段/Mapper条件/接口签名在data-model/contracts统一,当前未写业务代码。
+
+| 审查项 | 实施落点 | 约束与验收 |
+|---|---|---|
+| 4 通知 | LinePayFactService、LinePayOrderNotificationService、OmgPaymentNotifyService、DeliveryOrderNotificationService及新批次到付确认 | 支付事实先保存,全部合法就绪后持久通知事件;预约等旧时点,提交后发送,组/独立单去重及失败重试。用户付款结果与履约通知分开,后续单笔通知仍独立;FR-036/SC-024 |
+| 5 预约开放 | UserOrderController预约初始化、DateUtil旧时间判断、PosOrder查询/更新及新增有效扫描入口 | 普通平台配送/新多店预约旧时点补扫、重启恢复、开放/通知幂等及并发;先设预约字段,新批次额外已生效,普通不套批次期限。禁复用TestTask,不新增最迟接单/自动取消,不回写历史已开放;FR-037/SC-025 |
+| 6 用户列表 | UserOrderController.orderList/applyTabFilter、UnifiedOrderQueryService、UnifiedOrderTab及批次详情 | 未选/部分付款/已付待生效/缓冲核实可见并恢复合法结算,真实已付与到付分开,不遗漏新批次Apple Pay;只改新批次分类,App只交文档;FR-038/SC-026 |
+| 7 权威算价 | 新多店预结算/创建、OrderPromotionHelper及真实商品/规格/促销/原运费能力 | 不信客户端amount/freight/foodAmount及food价格,服务端查真实价格/归属/数量,计算优惠与折后实付;报价差异拒绝/重报不只警告,金额单位明确,支付/退款/到付/账单同快照;FR-039/SC-027 |
+| 8 优惠券 | 新批次整批/合法子单取消、PromotionUserCouponServiceImpl及Coupon Mapper条件更新 | 只返取消成员券、原有效期不延长、过期不续期;原券/原子单绑定幂等及并发核销,迟到旧取消不释放新单券,返券不等于退款到账;FR-040/SC-028 |
+| 9 归属 | 新批次/关联详情、进度、收银/取消及涉及新批次的getParentOrderDetail | token确定用户,校验父/子/组归属和关联,不能原样复用无userId过滤查询;商家/骑手限定现有授权,失败无款项/状态/返券副作用;FR-041/SC-029 |
+
+兼容边界:明确批准普通平台配送预约补齐旧时点扫描,其他新期限/就绪/算价/返券只用于服务端标识的新批次;夜市自配送、自取不纳入扫描,普通价格/付款/取消等不全局改写。App只交接入文档,SQL后续只登记updatesql/sql.md供开发者手动执行。
+
+
+## 8 必须讨论的业务确认表
+
+以下区分已确认范围与剩余建议;OMG 正式退款以及即时/预约/到付范围已确认,不重复询问。
+
+| 编号 | 问题 | 本版建议 |
+|---|---|---|
+| Q1 | 每店独立选择与混用 | 已确认逐笔收银选择LINE/OMG/到付,线上成功或明确合法确认到付才就绪,最后成员就绪激活;不能创建时判全到付或默认生效。统一期限见Q6,转账见Q13 |
+| Q2 | 首店围栏及圈外店的结算边界 | 已确认首店A为圆心、默认1KM可配置;圈内A/B参与本次合并结算,圈外C不建本批子单、不计金额/折扣、不等待其付款。预结算明确排除原因、确认成员后提交;C保留购物车另结算为接入建议,不自动建单。撤回C同批及有C整批禁合并两种建议 |
+| Q3 | 折后运费及到付交付 | 已确认每店原40八折后32,商家给骑手32,不另补原价差额;到付给商家=该子单应付总额−该单折后运费,向用户代收该子单总额,配送毛收入为折后运费。佣金/税沿用原规则;逐店整数新台币舍入/不分尾差亦已确认见Q8,不重复询问 |
+| Q4 | 合并组与骑手容量 | 2026-10-13最新确认:撤回双层/子单总量限制,只沿用现有外卖/闪送/总任务字典,合并组算1单、普通单算1单,原字典值不自动改写;组门店上限默认5可配置。资格、任务上限及门店数同锁校验,部分完成仍计1任务,最后有效成员结束才释放;原任务字典异常回退1任务,不新增子单字典/字段/页面项 |
+| Q5 | 用户取消与异常处置 | 已确认未接可取消全退实收含运费、已接禁用户取消不审核;已接未取无法供餐由有权限商家取消本店,其他成员继续不补差。到付交餐才给钱不先垫,取消券按7.1返还;其余异常沿普通有效流程,不另建多店售后或默认空跑补偿 |
+| Q6 | 支付发起期限、完成/确认缓冲与迟到结果 | 最新已确认15分钟线上支付发起/到付确认期限+默认5分钟平台可配置缓冲,创建固定两个截止/时长、重试不续时。截止前服务端已记录并实际发起的线上尝试可在缓冲完成用户认证、付款与确认,取代严格15分钟内完成付款的旧口径;缓冲禁新发起/重试/切换/到付确认。全部合法就绪立即生效,最终未就绪整批取消、真实实收全退含运费,取消后迟到实收退款不重开;不影响旧非多店,发起证据/真实收款/并发按4节适配验证 |
+| Q7 | 动态折扣阶梯、默认合并数量、城市区域配置、距离口径如何定? | 已确认最少店数+折扣动态增删改、最高满足门槛匹配、每店打折且不追溯旧单;默认最多5家可配置,首版全局规则、不分城市/区域。初始最少2家九折/最少3家八折已确认,4家七折仅后续扩展示例不默认启用;首店到后续店使用直线距离、范围默认1公里可配置亦已确认,城市/区域覆盖后续另行评估 |
+| Q8 | 部分取消是否补差?费用分摊/尾差如何舍入? | 已确认每店原运费独立打折、HALF_UP到整数新台币,再相加得总运费,不做整批舍入/比例分摊/尾差转移;支付/退款/到付/毛配送收入取同一快照。生效后允许阶段部分取消不补差、不重算剩余成员;取消单本身退款见Q5。元/分与渠道单位在实现前核对,不改变原商品价/优惠规则 |
+| Q9 | 权威算价/快照与兼容 | 已确认创建服务端全量校验及真实商品/规格价格、优惠、原/折后运费和实付计算后固化快照;付款不复查普通菜单/营业变化,仍校验归属/状态/期限/方式/凭证。新批次取消券返还、原有效期和幂等已确认,不新增库存预占;普通单原规则不改,普通预约仅按Q12补扫 |
+| Q10 | 支付工具与最终退款规则是否匹配? | 先沿用现有已支持LINE/OMG信用卡/Apple Pay工具,不新增分期等支付工具。用户普通取消现在只需未接全退,不再以“已接取消保留运费”强制要求部分退款或新增禁用;异常售后若确定部分退款再核对渠道能力。OMG正式退款仍已确认纳入 |
+| Q11 | 骑手什么时候看到配送组,取餐顺序与履约如何定? | 已确认沿原外送:生效且达到预约开放时间后可抢单,接组后各店独立接单/出餐;独立取餐、独立送达,取餐顺序骑手自行决定。单笔结算/通知沿普通订单,不要求全组取齐,不新增整组送达/结算。组状态只汇总混合子单进度,最后有效成员结束才释放1任务;撤回全取齐后整组送达建议 |
+| Q12 | 延期新预约算法与本轮基本扫描 | 即时/预约不同组,预约日期/起止完全一致,合法同段多店保留。本轮已批准普通平台配送/新多店外卖预约旧时点持久扫描、重启补扫、开放幂等及通知重试;新批次额外须生效,普通单不套新期限。新语义/最迟接单/无人接单取消仍后续,不再把基本扫描延期 |
+| Q13 | 线下转账范围 | 已确认新多店批次不支持转账,原单店/历史订单及商家设置保持不变;各店按LINE/OMG/到付就绪集合选择。仅转账门店提交前明确提示不可参与,可另走原单店;不静默删店/自动拆单/擅自转到付,不新增核款/转账人工退款 |
+
+原业务规则及本次第4~9项均已确认,不重复询问:全部合法就绪后幂等通知、普通平台配送/新多店预约按旧时点补扫、用户列表/继续结算、服务端权威算价、新批次取消券返还、详情/进度/操作归属。仅新预约语义/最迟接单/自动取消继续延期,普通其他异常沿原有效流程。下一步细化data-model/contracts及并发/测试映射供用户审阅,完整技术方案批准前不写执行tasks或业务代码,不把确认当作实现验证。
+
+## 9 实现与验收顺序(范围确认后细化)
+
+1. 确认范围/分组/费用/容量/取消规则;补齐 data-model 和 contracts,由用户审阅。
+2. 完成首店围栏/参与集合报价及确认提交、支付方式过滤、参与店全量预检、权威算价、幂等父子单和服务端新批次标识/状态;验证圈外C不建单/不等待付款、夹带拒绝、转账边界、事务回滚、快照及越权。创建后菜单/普通营业变化不自动取消/重算;保留支付必要校验,回归现有非多店创建/付款/优惠促销/发票/通知等原行为。
+3. 完成收银/支付、15分钟发起/可配置缓冲/实际交易核实及生效;接入现有回调/查询/到付的批次通知闸门、持久幂等通知及普通平台配送/新批次预约旧时点扫描。验证部分付款无履约通知、14:14发起14:17完成合法、14:16新操作拒绝、预约按时开放/重启补扫/失败重试/取消竞争及普通兼容;新语义/最迟接单/自动取消不纳入。
+4. 完成未生效整批取消/超时、生效未接单店取消全退、已接用户拒绝及已接未取商家无法供餐取消,接入LINE/OMG退款并补新批次取消成员优惠券释放/返还。验证实收/未知/迟到事实、接组/取餐竞争、重复取消幂等返券、保留原有效期、已过期不续期、旧取消不释放新单用券、其他成员不受影响;测试环境真实退款限制按6节,不新增用户审核。
+5. 完成整组抢单、原字典任务统计按组去重及组任务释放、每组门店上限校验、沿普通订单独立出餐/取餐/送达/结算/通知、折后运费与混合付款现金/账单对齐、售后任务收口及关联信息权限;验证两骑手抢同组、拆抢、跨类型/组并发、任意取餐顺序、部分已送部分待取的混合进度、单笔幂等、组任务释放、到付交付及不重复收款。不实现已撤回子单上限或全组取齐/统一送达/整组结算流程。
+6. 完成用户原列表/统一列表中新批次分类和继续结算、批次/关联详情归属、平台配置、必要PC及App接入文档,再统一业务回归/模块构建。验证未选/已付待生效/缓冲核实/Apple Pay本批子单、其他用户父/子/组ID越权及商家/骑手授权边界,App不改源码;正式退款/真实支付联调另行批准。
+
+OMG 实现按项目纪律将每个接口的代码、测试源码、规格与 SQL 作为完整批次;不反复确认已确定事项。此处并非启动新的 OMG 重做测试禁令;实际测试时机在执行任务阶段按用户要求确定。
+
+## Technical Context 与 Constitution Check
+
+- Java 21、Spring、MyBatis/MySQL、Redis/Redisson;前端 Vue/Element UI。
+- `.specify` 工作流有 specify/plan 审阅闸门。本轮按用户要求先输出规格与方案草稿;不运行会创建分支或自动提交的扩展脚本,不修改当前 `.specify/feature.json`(仍指向032,待进入本功能执行阶段再显式切换)。
+- constitution.md 仍为占位模板,不凭空填入项目治理规则;AGENTS 和用户最新确认优先。
+- 无新技术栈/资金池/独立收款平台/自动派单优化;新表和字段只表达当前批次、配送组、规则快照与退款恢复所需状态。
+- tasks.md、data-model.md、contracts 和 App 接入文档将在确认后逐步产出;本轮未创建它们,避免把未定细节伪装为可直接执行的任务。
+
+## 本轮记录
+
+- 2026-10-12:现有代码与原文档已核对,新增036规格/方案草案;业务实现未开始。
+- 2026-10-12:用户明确 OMG 正式退款纳入,保留测试环境不可真实退款的验证限制。
+- 2026-10-12:完成一次只读可实施性检查,补充 Q9~Q11;其中“先骑手接组再商家接单/出餐”来自现有真实入口,保留不变,库存占用、工具限制和取餐次序仍待用户确认。
+- 2026-10-12:用户确认预约配送、到付纳入,夜市自配送、自取沿用原流程。调整为独立批次生效状态建议,补充线上/到付准入与预约开放双条件,新增 Q12;业务实现仍未开始。
+- 2026-10-12:用户指出商家支付方式独立配置;撤回统一渠道建议,补充逐店 payType 与混合付款生效、退款和到付待收金额建议;混用细则待确认,未改业务代码。
+- 2026-10-12:用户确认 LINE/OMG/到付按店混用,新增 Q13 转账兼容建议;核对现有单店商家核款入口及管理端退款类型限制,补充生效前核款待办、人工退款和迟到转账补偿,待用户确认后进入实现。
+- 2026-10-12(历史方案,已被2026-10-13简化规则取代):用户当时确认双层容量方案;记录组占1任务/成员逐单计实际容量。额外子单总量上限/新字典及其验收已撤回,任务计数/同锁/组任务释放等仍有效;仅改文档,未写业务代码。
+- 2026-10-12:用户确认Q3:各店给骑手折后运费32,到付给商家为子单总额减折后运费;补充金额方向、混合付款只代收到付单、账单运费基数和原钱包职责核对,更新规格及验收。业务实现未开始。
+- 2026-10-12:用户要求预约时间调整后续单独处理;新增3.1后续记录,标记Q12/FR-014延期,调整本轮新增实施/验收范围,仍保留预约多店的原规则兼容。未写业务代码、未变更分支。
+- 2026-10-12:用户确认Q13新多店批次不支持线下转账,原单店转账保持不变;撤下候选核款/人工退款扩展,更新支付上下文过滤、全店提交前校验、仅转账门店提示及旧流程回归。当前仅调整036规格和方案,业务实现未开始。
+- 2026-10-12:用户确认Q2:圈外C不属于本次合并结算,不等待其支付。修正旧C同批及整批禁合并建议,补参与集合确认、创建防夹带、金额/折扣/付款隔离;C保留购物车另结算为接入建议。仅更新规格/方案,未改业务代码。
+- 2026-10-12:用户确认生效后部分取消不补差,剩余子单保持原折后运费,线上/到付及账单不追加差额;Q5取消单自身退费及Q8分摊舍入仍待确认,不修改业务代码。
+- 2026-10-12:用户确认Q5第一阶段:生效后骑手未接允许单店取消,完整实收含折后运费全退,到付未收仅取消;补取消与接组竞争、有效成员/组结束验收。已接/取餐后及责任例外仍待确认,未改业务代码。
+- 2026-10-12:用户确认骑手已接不允许用户取消,不新增商家审核;同步Q5、入口拒绝及并发验收。撤下用户已接取消保留运费及由此默认必需的部分退款扩展,Q10改按最终异常范围评估;商家/平台异常仍待定,仅更新文档。
+- 2026-10-12:用户确认商家无法供餐、该店未取时可取消本店并全退实收含运费,其他单继续不补差;补取餐竞争、有效站点/待收/容量/通知和全组收口,垫款/已付骑手运费/空跑费用另确认。仅更新规格/方案。
+- 2026-10-12:用户澄清到付只有当面可交餐才给商家钱,无法供餐不先垫款;撤下此前资金异常假设及返款建议,不新增垫款状态/接口。更新现金流程及Q5,空跑补偿不因本次澄清视为已确定;未改业务代码。
+- 2026-10-13:用户澄清逐笔收银才确定方式,确认所有新批次创建起15分钟内完成线上支付/到付确认才生效;撤回全到付创建开放/无截止前提。同步创建/收银职责、就绪与实收区分、统一期限/扫描、迟到补偿和接口/验收。仅更新036文档,未改代码/SQL/App、未建或切分支。
+- 2026-10-13:用户确认动态折扣阶梯,新增最高满足门槛匹配、每店分别打折、配置校验、报价/订单快照和配置变更验收。未将4店七折示例或默认最多5店/城市覆盖视为已确认部署值;仅更新036规格/方案,未改业务代码。
+- 2026-10-13:用户进一步确认首版默认最多合并5家、可配置,使用全局规则、暂不区分城市/区域;更新Q7及上限/容量验收,城市覆盖后续另评。骑手任务/实际子单容量部署值和初始折扣数值未随之批准;仅更新036文档,业务实现未开始。
+- 2026-10-13:用户确认首版初始最少2家九折、最少3家八折两个动态档位;未增高档时3/4/5家八折,4家七折由平台后续自行新增、不默认启用。同步Q7及规格验收,仅更新文档,未改代码/SQL/App。
+- 2026-10-13:用户确认每店原运费独立打折、四舍五入到整数新台币后加总,不另分摊尾差;确定HALF_UP,撤下先整批打折再比例分摊/补尾差建议,明确支付/退款/到付/骑手毛运费同一快照及实现前单位核对。新增规格验收,仅更新036文档,未改业务代码。
+- 2026-10-13:用户确认简化容量,只用现有任务字典,合并组算1单、普通单算1单,每组默认最多5家;撤掉额外骑手子单总量上限/新字典/配置页面项及其大小关系校验。统一更新Q4、实现落点与验收,保留组任务释放、同锁并发、闪送加急独占和异常回退,不自动改字典值。仅改036规格/方案,未改业务代码/SQL/App。
+- 2026-10-13:用户明确独立取餐、独立送达、取餐顺序骑手自行决定,进一步强调多店下单为配送费优惠,其余沿普通订单;撤回全部取齐后整组送达建议,Q11改为已确认。各单结算/通知不等待他单,组只汇总混合进度及任务释放;保留既定批次准入/超时取消/同骑手接组,不额外引入新履约/整组结算。仅更新036文档,未改业务代码。
+- 2026-10-13:用户确认围栏按首店到各店直线距离、范围默认1公里可配置;即时与预约不能合并,新增预结算/创建的混组拒绝及配置变更验收。纯预约多店范围保留,不同预约时段同组及既定延期改造未因本次答复批准;仅更新036规格/方案,未改代码/SQL/App。
+- 2026-10-13:用户确认创建时完整校验、创建后保留商品/价格/运费快照、付款不复查普通上下架和营业变化,明确要求不得影响现有非多店订单。Q9收口并撤回菜品/营业变化自动整批取消及库存预占候选,补服务端标识/旧流程分支隔离/必要支付保护/单店及历史兼容回归;实际无法供餐仍按既定子单规则。仅更新036文档,未改业务代码或SQL,兼容测试尚未执行。
+- 2026-10-13:用户确认不同预约时段不得合并;同一骑手配送组的预约日期/开始/结束必须完全一致,不以时段重叠或同钟点跨日期判断可合并。更新Q12当前资格与拒绝验收,不变更既定延期的预约时间算法,也不改变非多店原流程;仅更新036规格/方案。
+- 2026-10-13:用户明确15分钟内已付款需服务端响应缓冲,指定默认5分钟且可设置。统一更新用户截止/可配置缓冲/最终确认截止快照、待核实阶段、缓冲禁新操作、生效/取消/补偿及平台字段;撤回第15分钟立即关闭未知交易/按本地结果时间认定付款的建议。补官方时间字段及LINE分阶段证据/现有本地payTime差异,须后续定向适配,不影响旧非多店。仅更新036文档,未改代码/SQL/App、未执行支付测试。
+- 2026-10-13:用户接受第2项建议,明确放宽为15分钟内发起线上支付/确认到付,服务端已记录并实际发起的线上尝试可在默认5分钟可配置缓冲内完成用户认证、付款及服务端确认;第15分钟后禁新发起/重试/切换/到付确认。同步正文、Q6和验收,撤回严格第15分钟前完成付款及仅后台响应缓冲口径;实收才就绪、最终未就绪取消退款、取消后不重开及非多店兼容不变。仅更新036文档,未改代码/SQL/App、未执行测试、未切换分支或提交推送。
+- 2026-10-13:用户确认审查第4~9项,纳入全部就绪后幂等履约通知、普通平台配送/多店预约旧时点扫描与重启补扫、用户列表/继续结算、服务端权威算价、新批次取消券返还(原有效期/幂等)及详情/进度/操作归属。新增FR-036~041、SC-024~029及7.1映射,基本扫描不再延期;新预约语义/最迟接单/无人接单取消仍后续,其他普通规则不套新批次限制。仅更新036文档,未改代码/SQL/App、未执行测试、未创建/切换分支或提交推送。
+- 未写业务代码、DDL,未运行 Maven、未修改 App,未创建/切换分支、未提交/推送。

+ 294 - 0
specs/036-multi-store-checkout/spec.md

@@ -0,0 +1,294 @@
+# 同一用户多店结算与同骑手配送需求规格
+
+**Created**: 2026-10-12
+
+**Feature Branch**: 不创建新分支,沿用当前 `test-202609v2`;本轮不切换分支。
+
+**Status**: Draft,供用户逐轮讨论;业务代码未开始。
+
+**Input**: 用户提供《多店下单与骑手合并需求变更 (2).docx》,确认各店独立收款、在线批次全部子单付款完成后才开放履约,并要求纳入 OMG 正式退款、预约配送和到付;夜市自配送、自取沿用原流程。先给方案、确认细节后实现,可持续调整。
+
+## 已确认与待确认的边界
+
+已确认:
+
+- 一次多店结算按店生成独立子订单,款项分别付给对应商家,不改为平台代收。
+- 新批次创建时各子单待收银确认,不预先认定付款方式;线上须服务端确认支付成功,到付须用户选择并由服务端校验确认。全部成员就绪前,商家/骑手不能提前履约。
+- 首版纳入即时配送、预约配送和到付;夜市自配送、自取沿用原流程,不加入本次多店批次规则。
+- 新预约时间语义、最迟接单及无人接单自动取消仍留后续。2026-10-13用户确认本轮补充普通平台配送外卖和新多店外卖预约单的后续开放扫描,沿原有效开放时点,支持重启补扫、幂等开放与通知重试;新批次额外要求已生效,普通预约不套用多店期限。夜市自配送、自取不纳入。后续边界见plan.md「预约时间处理:后续独立事项」。
+- 各商家可独立设置支付方式,多店不要求共同渠道;本功能仅开放各店已可用的LINE Pay、OMG和到付,不擅自改变商家设置。
+- 同批可按店混用LINE Pay、OMG和到付,逐笔收银时选择;即使最终全部到付,也须逐单完成到付确认,不能创建即生效或因旧payStatus=1默认就绪。
+- 全部新批次共用创建起15分钟支付发起/到付确认期限,支付下一店、重试/回页面不延长;支付完成与服务端确认缓冲默认5分钟、平台可配置,创建时固化时长和最终确认截止。第15分钟停止新的支付发起、重试、切换方式及到付确认;截止前服务端已记录并实际发起的线上支付尝试,允许在缓冲期继续完成用户认证、渠道付款和服务端确认。此口径经用户确认,取代严格要求第15分钟前完成付款的旧口径;并非第15分钟后仍可发起新交易。全部真实成员经服务端确认合法就绪立即生效,无需等满缓冲;最终确认截止仍未就绪整批取消,真实实收全退含运费、未收款到付只取消。已取消后迟到实收退款、不重开。
+- 新多店批次不支持线下转账(payType=6),无论是否选择同骑手;原单店转账订单、入口和商家配置保持不变。本轮不新增转账核款/人工退款流程,不关闭全系统转账。
+- 同骑手资格以用户选择的第一家店为圆心,按门店坐标计算首店到后续各店的直线距离,不按道路路线距离;范围默认1公里、在平台合并配置中可调整,不能在代码写死1公里。圈外店不进入本次合并结算父单/配送组,不创建其子单、不等待其支付、不计入合并折扣或本批次金额;A/B合并不受圈外C付款影响。
+- 即时单与预约单不能进入同一合并配送组;预约单仅预约日期、开始时间和结束时间完全一致时才能同组,不因同用户/地址/围栏而放宽,仅时段重叠也不能合并。不同日期的同一钟点亦不合并;仍支持符合原有效规则的同一预约时段多店,预约时间处理改造仍按既定范围延期。
+- 新多店批次创建时完整校验门店、商品、配送资格及金额,成功后固化商品/价格/折后运费快照;付款不因普通改价、上下架或停止接新单/营业时间变化重新算价、自动取消批次,仍可在15分钟内发起支付/确认到付,既有线上交易可在缓冲内完成。付款继续检查归属、订单/批次未取消未过期、方式及凭证可用;快照不是库存保证,实际不能出餐按此前确认的子单规则处理,不新增库存预占体系。
+- 新批次准入/期限、权威算价快照及取消返券仅用于服务端明确标识的新多店批次,不全局改变普通单/历史单的付款、取消、优惠促销及发票规则。本次明确批准普通平台配送预约按原时点补充后续开放扫描,不引入新预约语义、批次15分钟限制或无人接单取消。夜市自配送、自取及其他旧流程不变,新标识缺失不能误判为未生效批次,共享入口不能全局移除原校验。
+- 圈外店处理建议:预结算明确列出未纳入店及原因,用户确认本次参与集合后再提交;C保留购物车供另行结算,不自动另建订单、不静默丢掉购物车商品。购物车保留方案属于接入建议,App由前端人员实现。
+- 骑手接单数量沿用现有外卖/闪送/总接单数字典:普通独立外卖单算1单,合并配送组整体算1单,不新增骑手实际子单总量上限、相关字典或配置项。同父单的独立配送订单不能合计为1单;闪送原计数与加急独占不变。此前双层容量/子单上限方案已撤回。
+- 接组须在同一骑手锁内检查账号资格、现有任务上限和组内门店上限,再原子分配;部分完成/取消不按子单释放任务名额,组内全部有效子单履约结束才释放组任务。已取餐异常未处理结束时不得仅因进入售后而提前释放组任务。
+- 多店下单目的为用户提供配送费优惠;除已确认的结算批次准入、同骑手整组接单/计数及收费规则外,其余履约沿用普通订单。各子单独立出餐、独立取餐、独立确认送达,取餐顺序由骑手自行决定,不强制选店顺序,不要求全部取齐才允许已取子单送达,不新增整组送达/结算流程。单笔履约仅作用于自身,不联动改变其他店订单;组状态只作汇总及任务释放,不能取代子单事实。
+- 配送收入按每店折后实付运费计算:三店原运费各40、八折后每店32,商家不再另付原运费40、不另补折扣差额。到付子单给商家金额=子单应付总额−子单折后运费;向用户代收该子单总额,差额为该单配送费。佣金/税等既有规则仍按项目原流程,不把配送毛收入当作净收入。
+- 配送费折扣采用平台可动态增删改的“最少店数+折扣”阶梯,不写死固定档位;按实际参与合并的去重门店数,命中门槛不超过该店数的最高门槛档。首版初始配置已确认:最少2家九折(系数0.9)、最少3家八折(系数0.8),未配置更高档时3家及以上均八折。4家七折仅为以后可新增的示例,不在初始配置中启用。各店原配送费分别乘命中折扣;配置变更不改已创建订单,允许阶段的部分取消仍不补差。
+- 首版最大合并门店数默认5家、可配置,使用全局规则,不按城市/区域设置差异配置。用户提交及骑手接组仍检查最大店数,接单同时受原任务字典控制;两者单位不同,不要求每组最大店数小于任务上限。城市/区域覆盖留待后续另行评估,不属于本轮实施。
+- 各店原配送费独立乘命中折扣,按整数新台币四舍五入(HALF_UP)后固化;批次总运费为各店舍入后运费之和,不先把原运费总和打折舍入再分摊,不补齐尾差。示例41元八折为32.8元,实收配送费/商家给骑手毛运费均为33元;佣金/税仍按原规则另算。支付、退款、到付及账单统一使用该快照。
+- 合并批次生效后,允许的部分取消不向剩余成员补差,即使仅剩一单也不恢复原价。用户主动取消仅允许骑手接单前,完整实收含运费全退;已接单后用户不能取消单店或整组,包括未取餐阶段,不新增商家取消审核。商家/平台异常另定;未生效仅整批取消,重下按新报价。
+- 生效后骑手未接单的单店取消只作用于该子单,其他成员继续履约、不补差;未实收到付仅取消并移除待收目标,不生成网关退款。与整组接单并发时以事务内实际状态为准,取消成功的成员不能随后分配给骑手。
+- 骑手已接、该店尚未取餐时,商家因缺货/无法供餐可取消本店子单,退该单全部实收含折后运费;其他店继续履约、不补差。到付现场能交餐才给商家钱,不能供餐不发生垫款:该取消只移除用户待收,不产生用户网关退款或骑手垫款返还流程;空跑是否补偿仍单独待定。
+- 本业务不设计“无法供餐、尚未取餐但骑手已垫款”的常规场景,不新增取餐前垫款状态/返款入口。到付款项当面在交餐环节交付,已取餐不走未取餐无法供餐取消路径;用户已接禁取消规则不变。
+- 可使用现有或新增字段控制开放;具体建议见 plan.md,尚未把字段选择当作用户已确认的决定。
+- OMG 正式退款纳入本功能。2026-10-13用户再次确认按现有测试环境无法执行真实退款的边界处理:测试环境验证取消、退款意图及模拟流程,不要求完成真实渠道退款联调;不能据此省略正式退款实现,也不能在测试环境调用正式端点或把模拟结果标为真实退款成功。正式退款未联调的验证限制单独记录,不阻断方案确认和本地/测试环境流程验证。
+- 本轮只编写规格和方案,不修改业务代码、不执行 SQL、不创建分支、不提交/推送。
+- 不修改用户/骑手/商家 App 源码,提供前端接入文档;平台配置与必要 PC 页面属于后续拟议实现范围。
+
+原需求文档提出、需要落实并讨论边界:
+
+- 第一家店为围栏圆心,默认半径 1KM,可配置。
+- 用户可选择同骑手;不合并或围栏外订单独立配送、独立计费。按用户最新澄清,圈外店不挂在本次合并结算批次内,另行结算,不等待其支付。
+- 配送费按参与合并的各店原配送费之和打折;示例两单九折、三单以上八折,阶梯可配置。
+- 原文合并数量建议值5~8、示例最多5单;2026-10-13用户确认首版默认最多5家且可配置,取代原未定默认值;使用全局规则,暂不做城市/区域覆盖。
+- 分笔支付并显示进度,未生效时只能取消整批;最新规则为15分钟支付发起/到付确认期限,已有线上支付可在默认5分钟可配置缓冲内完成和核实。全到付不免期限;第15分钟后不能新发起支付,但已打开的合法渠道页面可继续完成付款。全部合法成员未就绪前不开放履约。
+- 超时整批取消,已付款的逐笔原路退款;付清并生效后按正常售后处理。
+- 原文提出单店售后取消不退配送费;最新确认覆盖用户主动取消规则:未接单实收全退含运费,已接单用户不得取消,不采用申请取消/商家审核,也不采用退餐款保留运费的用户取消路径。商家/平台异常仍待确认。
+- 全部门店和商品提交前校验;订单详情显示关联订单。
+
+## User Scenarios & Testing
+
+### US1 同一用户多店预结算与创建(P1)
+
+用户把不同门店商品加入同一次结算,服务端计算哪些店可合并、折扣和各子单金额,再统一提交。
+
+**Independent Test**: 三家均符合资格的店一次提交,形成一个结算父单及三条子单;任一参与门店关闭、未审核、商品不可售或坐标无效时,不留下部分可支付订单。A/B圈内、C圈外时,本次确认提交仅A/B,C无子单/金额/付款等待。
+
+**Acceptance Scenarios**:
+
+1. 第一家店 A 为锚点,其他店分别与 A 比较,而不是相邻店两两比较或不断延伸围栏。
+2. 重复门店不增加合并店数,也不能制造阶梯优惠;同店商品汇总为一条子单。
+3. 用户不选同骑手时,各店按原运费计费;选同骑手时,仅参与配送组的店铺享合并优惠。
+4. 服务端重新校验报价、商品/门店归属、地址归属和金额,不信任客户端传入的运费/折扣/合并资格。
+5. 相同用户、相同请求号的网络重试返回原批次,不重复建单或重复占用优惠券/库存。
+6. 创建前校验每个参与店至少有一种本批次支持且就绪的方式,不要求用户已选定。逐单收银选择时重验具体方式;到付只要求该店允许到付,不要求具备线上凭证;创建阶段不得默认选择到付。
+7. 预约配送校验所有参与门店在所选时段可营业、可配送;即时与预约不能同组,预约日期和起止时间不同也不能同组。预结算明确不兼容、创建拒绝混合,不静默改时间或自动拆组;同一日期/时段的合法预约多店仍支持。
+8. 按每店查询平台开关、商家选择和凭证就绪的可用方式,不取跨店交集;子单在逐笔收银确认时保存独立payType,创建时保留待确认语义,不能由父单唯一方式覆盖。
+9. 新批次可选方式为各店当前真实可用方式与本功能支持的LINE/OMG/到付的交集。仅支持转账或没有本功能就绪方式的店明确提示不能参加多店结算,用户可另走原单店流程;预结算和提交前均校验,不静默移除门店、不自动拆单、不擅自改成到付,不在已付部分后才发现无法支付。
+10. 预结算区分购物车候选集合、本次合并参与集合和不符合店铺清单。首店A/B圈内、C圈外时,仅A/B参与本批;创建接口若夹带C或成员与确认报价不一致,明确拒绝,不在提交事务里静默删C或自动另建C单。
+11. 删除首店或改变参与店、地址等会重算围栏资格和报价,重新确认成员;圈内可参与店不足2家时不形成多店同骑手组。
+12. A/B新批次创建后,A已付款而B普通改价/下架/停止接新单,B在期限内仍按已创建快照结算,不重新取当前菜单价格或自动取消A/B。已取消、过期、非本人或支付方式/凭证不可用的请求仍拒绝;商家真实不能供餐按既定子单操作处理。
+
+### US2 各店独立支付与批次统一生效(P1)
+
+用户逐笔进入各店收银台,选择并完成线上支付或到付确认;就绪进度由服务端事实汇总,不需要创建时提前知道最终是否都是到付。
+
+**Independent Test**: A/B为本次合并批次、C圈外未纳入:A付款后父批次仍未就绪;B也满足付款条件后仅生效一次,C未支付甚至未建单不阻断A/B。三家都参与的批次才等待三家条件全部满足。
+
+**Acceptance Scenarios**:
+
+1. 支付单金额、门店凭证与该子单精确对应,不使用父单总额向任意一家商家收款。
+2. 已付款子单保留真实付款事实;父单未生效不等于所有子单未支付。
+3. App 关闭、用户跳过某笔或重复进入收银台不会丢失进度;前端回跳不能替代支付回调/查询验真。
+4. 父批次生效与取消互斥;重复回调、查询补单、服务重启不重复生效或重复通知。
+5. 支付发起截止后、最终确认截止前,仍未取消的批次允许截止前服务端已记录并实际发起的线上尝试继续完成和核实,全部合法成员就绪才生效;不得在该缓冲内新发起支付、重试、切换方式或确认到付。批次已关闭或最终确认截止后发现实收,只记录收款事实并退款,不重新开放履约。
+6. 未付齐时,用户仍可看到订单并继续支付,不能把订单从用户待付款列表隐藏。
+7. 每子单就绪条件为:已选择线上且可信付款成功,或已选择到付且服务端确认该选择合法;未选、支付失败/结果未知、仅客户端回跳均不就绪。到付确认不是实收,已付款数量和已就绪数量分别表达。
+8. 三单依次确认到付时,前两单就绪后仍不开放,第三单确认后才生效;用户离开而未确认最后一单,15分钟到期整批取消且无网关退款。创建时不自动生效。
+
+### US3 未付齐时整批取消与真实退款(P1)
+
+未生效用户主动取消立即关闭整批;超时取消须区分15分钟支付发起/到付确认截止及最终确认截止。第15分钟停止用户新操作,既有线上尝试允许按默认5分钟可配置缓冲完成和核实;不能把结果未知当作未付款立即取消。最终确认截止仍未就绪关闭整批,已实收按对应商家原交易全额退款,到付只取消;确认交易已不可能满足全部就绪时可提前关闭,不把未收款全到付无限保留。
+
+**Independent Test**: 三单中两单已付款,整批取消后两条退款分别进入各自渠道,一条未付款单取消;一个渠道退款失败不会影响另一笔退款完成,也不能把整批标为全部已退款。
+
+**Acceptance Scenarios**:
+
+1. 未生效批次不支持单独删掉已提交子单后继续付其余单;修改购物车需先取消原批次。
+2. 取消与退款是两个事实:订单可以已取消而退款处理中;仅真实渠道证据允许标记已退款。
+3. 退款请求持久化,事务提交后由后台处理;退款超时、响应丢失先查询核实,不盲目重复退款。
+4. 持久扫描全部未生效批次的支付发起/到付确认截止及最终确认截止,包含尚未选择/待确认到付及支付待核实,重启/Redis事件丢失仍补处理。第15分钟只截止新操作,保留已记录并实际发起的线上尝试继续完成和核实;最终截止未就绪才取消并继续追踪迟到实收退款,不依赖App倒计时。
+5. 取消时逐子单对账;迟到付款、迟到回调、旧支付尝试仍有明确补偿,不把本地未支付当成绝不会扣款。
+6. LINE Pay 与 OMG 正式退款均纳入;OMG 测试环境明确返回不能执行,不伪造退款事实、不外呼正式 API。
+
+### US4 同一骑手一次承接配送组(P1)
+
+批次生效且达到预约开放时间(即时单无时间等待)后,参与合并的子单形成一个不可拆抢的配送组,由同一骑手一次接下。
+
+**Independent Test**: 两骑手并发抢组三单,只能一个骑手成功,三单全部归属该骑手;任一子单已变化则整体失败,不能只抢到一部分。
+
+**Acceptance Scenarios**:
+
+1. 配送组必须属于同一用户、同一批次、同一收货地址;不能仅按 userId 拼接历史订单。
+2. 单笔接单接口不得绕过配送组规则,把组内单抢给另一个骑手。
+3. 服务端在现有骑手锁内检查账号、配送类型、急送独占、现有任务上限及合并门店数;接一个有N条有效待承接子单的配送组,仅增加1个外卖任务和1个跨类型总任务。N须满足组门店上限,不另外检查骑手子单总量上限;任一既有接单条件不满足则整组拒绝。
+4. 商家各自接单/出餐,骑手按自己的顺序逐单独立取餐、独立送达,沿用普通订单的对应阶段校验。A已取可送达,不因B仍未出餐/未取餐而阻断A;A未取不能因B已取而确认A送达。撤回“全部有效子单取齐后整组确认送达”建议。
+5. 各子单保留自己的履约、售后、账单及通知事实;A取餐或送达只更新A,不修改B/C,不把整组标为全部已取/已送达。重复单笔操作沿用现有幂等规则,不重复入账/收款/通知;组汇总随子单事实更新,不新增整组送达或结算操作。
+6. A/B合并组与另行结算的独立C是两个任务,C不在A/B父批次内;一般容量统计仍按组/独立单而非父号去重。未接单预约组不预占具体骑手容量,接组时才占用。
+7. 三单组完成或合法取消一条后,仍有有效成员需履约时组任务占1;最后一条有效成员履约结束才释放任务。已取餐异常在确认实际履约结束或交接处理前,组仍计入任务,不能因售后提前释放。
+8. 现有外卖/闪送/总任务字典缺失/非法均保守回退为1,指最多1个任务而非组内最多1家;每组门店上限由独立合并配置校验,合并配置缺失/非法时保守关闭合并,不增加子单上限兜底。
+9. 并发接组/普通外卖/闪送使用统一任务统计,避免闪送总容量仍按组内订单行数重复计数。外卖/总任务上限为1时并发抢两个三店组最多成功一个;均允许2任务且没有其他冲突时两个五店组可同时承接,不因子单合计10而另拒绝。
+10. 用户、商家和骑手读取同一份每店折后运费快照;到付给商家金额按该子单优惠后的应付总额减折后运费计算,不用商品原价或父批次总额代替。
+
+### US6 预约配送与到付批次(P1)
+
+用户可以预约多店平台配送,也可以选择到付;到付不是在线付款成功,预约不是提交后立即抢单。
+
+**Independent Test**: 预约批次在15分钟内发起各线上支付/确认到付,既有线上交易可在缓冲内完成和核实,全部就绪后仍等待原预约开放时间;即时全到付第三笔确认前不能被抢、全部确认后仅生效一次,缓冲不放宽到付确认期限,不造退款。
+
+**Acceptance Scenarios**:
+
+1. 即时/预约、线上/到付均共用创建起15分钟支付发起/到付确认期限,不延长至预约日期;截止前已记录并实际发起的线上尝试可在默认5分钟可配置缓冲内完成及核实,缓冲不开放新的支付发起、重试、方式切换或到付确认。全部就绪生效后等待原预约开放,不再因结算倒计时取消。
+2. 到付子单须用户逐店选择且服务端合法确认,不在创建时默认就绪。所有到付确认完成才统一生效,不在第一条创建或首次确认时提前推送。
+3. 接单、商家处理、列表和通知共同检查批次生效及预约开放条件;仅改 isDisplay 不能防止直接接口提前接单。
+4. 本轮按用户本次确认补充旧时点下普通平台配送/新多店预约后续开放扫描、重启补扫、开放幂等及通知重试;新批次额外检查已生效,普通预约不套用新期限,通知不得提前或重复。基本扫描不再延期,不复用废弃TestTask;新预约语义、最迟接单及无人接单自动取消仍单独留后续。
+5. 到付取消不产生未实收款项的在线退款;实际线下收款、取餐和售后仍按现行到付流程处理,不把旧 payStatus=1 当作渠道实收证据。
+6. 已生效预约单无人接单的截止和自动取消延期单独处理;本轮不复用“未付齐付款超时”冒充预约到期处理,不声称已有此功能。
+7. 三店均参与、A选LINE/B选OMG/C选到付时,C须在15分钟内明确合法确认,A/B须在15分钟内由服务端记录并实际发起支付尝试,且在最终截止前完成真实付款核实;最后成员就绪才生效。C仅创建不算就绪,缓冲不放宽C确认期限;超时取消只退真实线上实收。
+
+### US5 平台规则与付清后售后(P2)
+
+平台配置开关、范围、折扣阶梯、最大合并数量及支付完成/确认缓冲(分钟,默认5);支付发起/到付确认期限仍为15分钟,能看到付款/待确认进度与退款状态。
+
+**Independent Test**: 配置调整影响下一次报价,不追溯改已提交批次的收费快照;售后退款金额与实际退回商品款/配送费分项一致。
+
+**Acceptance Scenarios**:
+
+1. 配置非法、缺失或不能形成合法报价时不得默认无限合并/随意优惠,合并功能保守关闭,单店正常下单不受影响。
+2. 已确认商家给骑手折后运费、到付给商家为子单总额减折后运费;首版全局配置、默认最多5家,超过上限明确拒绝,不静默拆组。各店运费独立打折并四舍五入到整数新台币,批次加总、不分摊尾差;城市/区域覆盖本轮不做。
+3. 付清后单店售后按确认的原因/阶段计算可退金额,不能把渠道全额退款作为保留运费的实现;退款取已固化的实际收款及运费,不重新用未舍入金额计算。
+4. 整组最终无法配送时不得无条件套用“单店取消不退运费”。
+5. 用户、骑手、商家只能查看自己有权访问的关联内容;商家不能看到其他店商品或支付凭证。
+6. 三家均参与合并、原运费各40、三单八折每家32;生效后C在允许的售后阶段取消,A/B仍各32,不转两单九折36、不各补4。线上不再拉起补款,到付剩余单待收金额及给商家金额不补差,账单运费基数不重算;取消C退多少另依Q5。
+7. 已生效组三单骑手未接时允许取消C,已线上实收132(示例餐费100、运费32)退132;未收款到付只取消。A/B继续待接且运费不变,组有效成员及任务展示同步更新;全部有效子单取消则结束组/父单汇总、停止抢单,不重复退款。
+8. 单店取消与整组接单并发只允许明确胜出的结果:取消先成功,接组不能包含被取消成员并须重验版本;接组先成功,用户取消请求拒绝,不修改订单、不建取消退款、不产生商家审核任务。
+9. 骑手接组后,无论商家尚未接单、已备餐或骑手未取/已取,用户均不能通过单笔、整组或普通取消申请入口绕过限制;商家不能供餐等异常和商品质量售后另定,不把用户禁止取消解释为平台不得处理任何异常。
+10. 已接组三单中C尚未被取餐且无法供餐,所属商家可按明确异常原因取消C,线上实收132退132含运费32,A/B继续由原骑手履约且各运费不变。同步通知骑手无需再去C取餐、更新有效站点/到付待收/容量;不重分配其他店、不默认整组取消。全部有效成员取消则组任务结束。
+11. 商家无法供餐取消与取餐并发重新检查该店取餐事实;取餐先成功不能继续按未取餐异常取消路径操作,转后续异常处置。重复取消不重复退款;商家仅能处理本店子单,不能修改其他店状态/付款。
+12. 平台无需改代码或重新部署即可增删改折扣阶梯;配置2家九折、3家八折后新增4家七折,2家命中九折、3家八折、4/5家七折(仍须满足最大合并店数)。重复店数门槛、非整数或小于2的门槛、非法折扣不能保存;圈外店不计数。配置修改只作用于新报价,已创建批次保持原快照,部分取消不重算档位。
+
+## Functional Requirements
+
+- **FR-001** 使用现有父子订单表达一次结算;各子单各商家独立收款。
+- **FR-002** 结算批次和配送组职责必须区分:普通不合并结算不创建组;同骑手结算父单仅含本次确认参与合并的店。购物车圈外店不挂入该父单,不允许“A/B组加C独立单”仍共用付款生效闸门。
+- **FR-003** 同骑手资格、范围、参与店数、报价和总额均由服务端重新计算校验;首店到后续各店使用坐标直线距离,范围默认1公里、平台可配置,不按道路距离或写死1公里。范围须有效且大于0,缺失/非法沿合并配置保守关闭规则处理;提交重验规则/金额并固化快照。
+- **FR-004** 所有本批成员共用创建起15分钟支付发起/到付确认期限;期限内服务端已记录并实际发起的线上尝试允许在已快照的缓冲内完成付款及核实,缓冲默认5分钟且平台可配置,不开放新的交易。全部合法成员就绪即生效,不预先区分全线上/全到付,不按创建或默认payStatus开放。认证或支付请求创建不等于实收,待核实阶段仍禁止履约,预约另满足原时间规则,列表/通知/操作共同校验。
+- **FR-005** 保留子单付款事实;父批次是否生效不覆盖子单支付、退款事实。
+- **FR-006** 父批次未生效时,主动取消/超时整批取消,已付款交易分别真实退款并独立跟踪。
+- **FR-007** 付款完成、取消、到期、补单和迟到付款处理必须幂等,取消不得被后续付款重新激活。
+- **FR-008** 同骑手组一次原子接单,不能拆抢;接单后商家各自接单/出餐,子单独立取餐和送达,取餐顺序骑手自行决定,其余履约沿用普通订单,不增加整组取齐/送达/结算流程。
+- **FR-009** 支持 LINE Pay 与 OMG;OMG 正式退款必须实现且显式区分环境,未经批准不得用正式交易验证。
+- **FR-010** 用户主动取消在未接单阶段按真实交易全额退款含运费,已接单不走用户取消退款;不以旧稿“商品款退、运费保留”默认要求部分退款扩展。异常售后如需部分退款须另确认,任何退款累计不得超实收、部分退款不得标成全额退款。
+- **FR-011** 非适用单店/历史订单及夜市自配送、自取保持原流程;新多店到付和预约属于本功能,标识明确区分适用范围。唯一相关补齐为本次批准的普通平台配送预约按旧时点补扫,不套新批次期限。
+- **FR-012** 按项目规范补注释、四语言、SQL 登记、测试和 App 接入文档;本轮仅讨论规格,不直接实现。
+- **FR-013** 创建、选择方式、子单就绪、真实实收、批次生效和预约开放分别表达;到付确认参加结算期限但不算线上实收,未收款取消不产生虚构退款。旧单店到付创建约定不直接应用新批次。
+- **FR-014(后续独立事项)** 新预约送达时段语义、重算开放/最迟接单及无人接单自动取消仍后续独立处理。本轮已确认补充旧开放时点下普通平台配送/新多店外卖预约的持久扫描、重启恢复、幂等开放与通知重试,见FR-037;不能继续将基本扫描列为延期,不复用废弃TestTask。
+- **FR-015** 每店独立查询、选择、校验并保存支付方式;批次不要求共同渠道,允许LINE/OMG/到付按店混用,受当前平台/商家/凭证闸门共同约束。只在新批次上下文过滤转账,不改现有单店支付方式查询和商家设置。
+- **FR-016** 新多店预结算、创建及后续换方式拒绝payType=6,原单店和历史转账仍走原流程;本轮不新增核款时限、待核款提醒、人工转账退款或补偿入口。仅转账门店提交前明确拒绝其参与本批,不擅自转到付/自动拆单/静默移除,也不关闭平台全局转账开关。
+- **FR-017** 外卖任务按进行中的配送组去重加未分组独立订单计数,跨类型总任务为外卖任务加闪送任务;合并组整体算1单,不新增实际子单总量限制。不得按父批次去重或仅排除售后状态来释放已取餐、仍待履约/处置的组任务。
+- **FR-018** 复用现有外卖/闪送/总任务字典及合并组最大门店配置。接组只检查外卖任务+1、总任务+1及组门店数;普通外卖增加1任务,闪送保持原计数和加急独占。撤下新子单上限字典、字段、页面管理及相关校验,不把门店上限当作骑手子单总量上限。
+- **FR-019** 现有任务字典缺失/非法/读取异常回退1;合并配置缺失/非法时保守关闭合并报价。预结算/创建检查每组门店上限,接单在骑手锁内使用实时任务容量和门店上限再校验。配置降低不能强制取消骑手已接任务;待接组超出调整后的门店上限时须有明确不可接原因及平台跟进,不改收费快照。
+- **FR-020** 每店折后实付运费同时作为商家给骑手的配送毛收入基数和现有骑手账单计算基数,不再按原运费向商家索取、不新增折扣补差。线上已收款子单由商家按现行方式给骑手折后运费;到付子单骑手给商家该单总额减折后运费,向用户代收该单总额。佣金和税沿用原规则,不能额外支付重复配送收入;原单店转账结算不在本功能中改写。
+- **FR-021** 混合付款配送组分别列出各店给商家金额、商家给骑手运费、到付待收总额;只合计真实到付有效子单的待收金额,不将线上单加入代收。取消后的金额及已发生线下款项另跟踪,不仅靠组总额展示。
+- **FR-022** 合并报价明确首店锚点、参与店及排除原因,提交校验用户已确认成员且各参与店位于首店圈内。付款进度、批次总额、折扣店数、生效和取消仅作用于该父单成员,圈外C另行结算,不自动创建或等待C付款。
+- **FR-023** 生效后允许阶段的部分取消不追补剩余成员运费折扣,不重算阶梯、收费快照、剩余单商家给骑手运费或到付交付公式,不额外发起补款;仅更新有效履约/待收汇总和取消成员自身的退款/费用事实,不把取消单损失分摊给其他单。是否允许取消、取消单是否退运费与退款金额仍按另行确认的售后规则。
+- **FR-024** 已生效批次骑手尚未接单时允许取消单个子单,完整实收金额含折后运费按原交易退款;未实收到付不虚构退款。剩余成员继续履约且不补差,取消同步组成员/版本、待抢可用性与待收汇总;全组取消结束任务。取消与接组串行/CAS校验实际分配状态,不能以过期列表当作未接单证据。
+- **FR-025** 本功能用户取消按服务端骑手承接事实校验,已接组或其子单拒绝用户主动取消单店/整组,不因尚未取餐或商家尚未接单而放行,不新增申请取消或商家审核流程。现有UserOrderController.cancelOrder不能仅以state=0判定可取消;新批次须同时检查配送组分配事实、子单qsId和deliveryStatus,任一已有承接证据即拒绝。组/子单未承接条件必须参加事务内更新/CAS校验,不能只在读取后判断,避免检查后骑手接单仍被取消。拒绝请求无订单/组/容量/退款副作用;商家/平台异常处置不是此用户入口的绕过方式,其权限、原因和退款另行确认。当前仅确认和记录方案,不提前修改普通非多店订单行为。
+- **FR-026** 骑手已接且该店未取餐时,有权限商家因无法供餐可取消本店子单,线上原交易全额退实收含运费;到付不能供餐时尚无当面垫款,仅移除待收,不新增返款分支。其他单继续不补差,取消同步组成员/站点、待收/容量及骑手通知;与取餐串行/CAS校验,已取不得走此路径。空跑是否补偿另定,不默认追加支付。
+- **FR-027** 新批次创建只确认参与集合/报价和各店至少有一种就绪可用方式,不强制携带已选payType;逐单收银入口以用户、子单、所选方式及服务端闸门确认,不接受客户端已支付/已就绪标志。具体字段在data-model/contracts定义,保留旧单店接口兼容。
+- **FR-028** 创建时固化支付发起/到付确认截止=创建起15分钟、缓冲时长及最终确认截止=前者+缓冲,配置改动/重试/逐店付款不延长既有批次。第15分钟停止新的支付发起/重试/换方式/到付确认,但允许合法既有线上尝试继续完成付款和核实,默认缓冲到第20分钟;最后合法成员就绪可立即生效。最终截止仍未就绪取消整批,未知支付继续对账/补偿,实收全退且取消后不可重开。核销、补单、生效和取消采用同一父批次并发规则。
+- **FR-029** 平台动态维护最少店数与折扣阶梯,服务端按实际合并去重店数选择满足条件的最高门槛档,各店原配送费分别按该折扣计算,舍入按FR-031。门槛为不小于2的整数且不重复,折扣系数须大于0且不大于1;缺失/非法配置沿US5保守关闭合并报价。规则及命中档位随创建固化,配置修改或允许的部分取消不追溯重算。
+- **FR-030** 首版规则使用全局配置,最大合并门店数默认5家且可配置,初始折扣档为最少2家0.9、最少3家0.8,不默认启用4家七折,不新增城市/区域覆盖规则。预结算、创建及接组检查有效店数上限,接单同时满足现有任务字典,不新增子单容量。不自动拆组或放宽缺失/非法配置;调整上限不直接取消已承接任务,待接组按FR-019处理。
+- **FR-031** 折后配送费按每店原配送费乘折扣后HALF_UP到整数新台币,批次总运费直接相加各店已舍入运费,不做整批舍入/比例分摊/尾差转移。创建固化后支付、全额退款、到付交付、商家给骑手的毛运费及账单基数使用同一金额;佣金/税不改变此毛运费口径。本条只定义配送费折扣舍入,不额外改变商品/优惠金额或原单店规则;持久化/渠道金额单位在实现前明确转换,不盲按实体“分”注释换算。
+- **FR-032** 多店履约以普通子单流程为准:独立出餐、取餐、送达、结算及通知,既有单笔入口仍逐单校验归属/状态/阶段/幂等;仅整组接单不得被单笔入口拆抢。骑手自行决定取餐顺序,不等待组内其他未取子单才允许已取子单送达;单笔操作不批量改变成员或重复结算。组/父单只汇总进度,不替代子单送达事实;全部有效履约结束才释放组任务。此前已确认的付款就绪/到期取消规则不因独立履约而撤回。
+- **FR-033** 合并组不能混入即时单与预约单,预约成员的日期、开始时间和结束时间必须完全一致,不允许仅重叠或跨日期同钟点合并。预结算提示方式/时段不兼容,创建重新校验并拒绝不同成员时段,不静默改时间或拆组。比较服务端规范化的实际预约时间,不仅比显示文字;仍支持符合原有效规则的同一预约时段多店,不新增时间语义/最迟接单等延期功能。
+- **FR-034** 新多店创建全量校验并固化商品/价格/运费等快照,后续付款不因普通菜单/营业变化重算或自动整批取消,仍校验所有必要付款条件、15分钟发起/到付确认截止及最终确认截止;不新增库存预占。必须以服务端新批次标识限定适用范围,现有非多店订单和其他非适用旧流程行为不变;共享支付/回调/查询/通知/取消扫描/履约入口只定向处理新批次,不全局放开多店支付或强加新期限/就绪条件。具体标识字段在data-model确定,未标识订单按原流程。
+- **FR-035** 平台合并配置页面维护支付完成与服务端确认缓冲时长,单位分钟,默认5,保存只接受正整数;缺失/非法/读取异常按5分钟安全回退,不能回退为无限等待。创建快照后不因改配置改变旧批次最终截止。发起资格以服务端持久记录的原支付尝试、实际发起事实及时间为准,不能用前端点击/回跳或事后补写时间证明期限内发起;收款须核实渠道真实交易、金额、币种、商家及签名/查询事实。LINE认证/确认/扣款按实际分阶段流程处理,认证成功不算实收;不再要求证明用户认证/扣款均在第15分钟前完成,不以本地记账时间冒充渠道付款时间。
+
+- **FR-036** 新批次付款事实先落库,逐单成功/到付确认不直接发履约新单通知;全部合法就绪后生效并持久记录开放事件。满足预约条件且提交后发送,合并组按组、独立单按子单幂等,失败可重试,取消终态不复活。用户支付结果通知保持真实事实,后续取餐/送达逐子单沿原流程。
+- **FR-037** 补充普通平台配送外卖及新多店预约的持久开放扫描,沿原时间判断/sys_appointorder_handletime,不新增最迟接单或自动取消。创建先设预约信息再判显示;扫描校验原开放时点、未取消未分配和原付款/履约资格,新批次额外已生效,普通单不检查批次期限。重启数据库补扫,开放状态与通知意图原子幂等并支持重试;直接接单不可绕过。自取/夜市自配送不纳入,不批量改写历史已开放单。
+- **FR-038** 用户原订单列表与统一列表识别新批次:未选、部分付款、全部未就绪或缓冲核实中保留可见进度/继续结算信息,不因payType空值漏单或payStatus=1误标可履约。真实已付与到付确认分开,生效后逐子单普通进度不被覆盖;覆盖本批LINE、OMG信用卡/Apple Pay及到付,旧非多店分类不全局改写,App只交付接入文档。
+- **FR-039** 新多店预结算/创建服务端读取真实门店商品/规格价格、数量与优惠规则,计算餐费、原/折后运费及实付并固化。拒绝跨店商品/规格、非法数量和报价/金额不一致,不能直接保存客户端amount/freight/foodAmount或仅警告放行。明确金额单位与渠道转换,逐店HALF_UP及支付/退款/到付/账单同快照不变,原普通算价不改。
+- **FR-040** 新批次整批或合法子单取消释放/返还对应取消成员占用/核销的券,不返仍履约成员的券。保留原有效期,仍有效恢复可用、过期不续期;原券/原子单绑定及状态参加并发幂等,重复取消/退款/扫描不重复返券,旧取消不得释放已由新单使用的券。创建同一券不能并发重复使用,返券不是渠道退款成功;普通非多店券规则不改。
+- **FR-041** 新批次详情、付款进度、关联订单、支付/取消按token确定用户并校验父/子/组归属及关联,不能仅凭传入ID返回信息或操作;现有getParentOrderDetail涉及新批次不得原样复用无用户过滤查询。商家仅访问授权子单,骑手沿现有待抢/本人承接权限,不放宽其他商家或用户敏感信息。未授权无款项/状态/退款/返券副作用。
+
+## Key Entities
+
+- 结算批次:复用OrderParent;成员、创建起15分钟用户操作截止、确认缓冲时长快照/最终确认截止、逐单已就绪/实收及待核实进度、预约信息、收费快照及生效/取消状态。新批次均有用户截止,不设全到付例外;缓冲到期不是已生效预约任务取消时间。
+- 门店子单:复用PosOrder;门店、待选/逐单所选payType、收银就绪事实、商品/运费快照、实际交易与独立履约状态。就绪的到付仍不是实收。
+- 配送组:参与同骑手配送的子单集合;不是收款账户或跨商家支付交易。
+- 骑手接单容量:任务按配送组/独立外卖单及闪送任务统计,沿用现有三个接单字典,异常回退1;合并门店数独立校验。不新增骑手子单上限字典、字段或容量实体。
+- 支付与退款事实:各子单自己的门店凭证/交易/退款金额及状态;复用或扩展当前有效 LINE、OMG 机制,不启用废弃支付代码。
+
+## Edge Cases
+
+- 第一家店被删除:重算锚点和全部资格,不保留旧折扣。
+- 围栏内外混合:圈外店不参与本次合并结算,不自动创建独立单;每组默认最多5家且可配置,超额拒绝、不静默拆组。即时与预约不得同组,预约日期/起止不一致亦不得同组,不按时段交集放行。LINE/OMG/到付混用已确认,夜市自配送、自取不混入新批次。
+- 最后一笔在第15分钟前由服务端记录并实际发起、缓冲期内完成付款/核实:全部成员合法就绪且批次未取消时统一生效,第15分钟不立即关闭合法在途批次;仅有请求/LINE认证不等于实收。第15分钟后新发起或重试拒绝。超过最终截止或已主动取消后发现成功款记事实并退款,不复活;缓冲配置后改不影响该批次。
+- 新多店在部分结算后普通改价/下架/停止接新单:保留已创建快照,不按菜单/营业变化自动取消,付款仍核实合法状态/期限/方式/凭证。实际不能出餐按既定子单规则,不借快照保证不存在的库存或新建库存预占;非多店订单保留原行为。
+- 一店拒单、取件前后单店取消、整组仅剩一店、整组无可配送单:费用、容量释放与骑手通知需闭环。
+- 退款受渠道状态、商家余额、禁止调用时段或结果不明影响:持续显示真实处理中/人工处理,不承诺已到账。
+
+## Success Criteria
+
+- **SC-001** 在并发线上回调/查询、到付确认、取消/扫描下,尚有子单未就绪、已取消或预约尚未开放的批次无一条子单提前履约,不把默认到付当作已确认。
+- **SC-002** 重试同一提交、付款回调和接单请求不产生重复批次、重复支付核销或分裂骑手归属。
+- **SC-003** 批次收费等于子单收费合计;总运费等于各店独立打折并舍入后的运费之和,不再按总额打折分摊尾差,所有退款累计不超过实收。
+- **SC-004** 服务重启后到期取消和退款可以恢复,失败可追踪且不影响其他子单退款。
+- **SC-005** 普通及非适用历史订单回归覆盖创建、原营业/价格/促销/优惠券/发票、LINE/OMG/到付/转账、取消及履约/通知;新标识为空不被新批次期限/就绪条件拦截或取消,自取/自配送不变。普通平台配送预约仅按SC-025补齐旧时点后续开放,不引入新时间语义或自动取消。
+- **SC-006** OMG 正式退款发布前完成批准的小额交易联调;模拟测试通过不能代替真实联调通过。
+- **SC-007** 即时到付、预约到付及预约线上验证批次生效与旧预约开放时点兼容,未实收到付不造退款。本轮包括基本开放扫描/重启恢复,详见SC-025;新预约语义、最迟接单与无人接单自动取消仍留后续,不宣称本轮完成。
+- **SC-008** 验证三店/五店合并组均只算1任务、独立单各算1任务、外卖闪送总任务及加急独占,普通单/组/闪送并发不超过原字典任务上限。上限1时两组仅一组成功,上限2且其他条件满足时两个五店组可接,不另按子单合计拒绝;部分完成/取消不提前释放组任务,已取售后继续占用,字典异常回退1任务而非1家店。
+- **SC-009** 餐费100/折后运费32的到付子单应收用户132、给商家100、配送毛收入32;三单均到付合计396/300/96,混合付款只累计到付单待收。用户收费、现金交付、账单运费基数均取同一快照,不能重复收取线上已付款或重复支付配送收入。
+- **SC-010** 新批次对仅转账门店明确提示,对伪造payType=6的创建/换方式请求拒绝;提交前全店检查,不留下部分已创建可支付子单。原单店转账下单、商家核款、现有查询及历史订单回归不受影响,不改变商家配置。
+- **SC-011** A/B圈内、C圈外时仅确认A/B创建父单和配送组;C无本批子单、金额、折扣计数、付款等待或退款。A/B条件满足即生效,夹带C提交拒绝,删除锚点后重算,不用相邻店链式扩圈。
+- **SC-012** 三单均参与且已生效、每单折后运费32时,允许售后取消一单后剩余两单仍32,再只剩一单也仍32;线上无补款、到付剩余单不追加差额、账单基数不恢复原价。取消单自身退款独立,不以不补差冒充允许取消或免除该单应付费用。
+- **SC-013** 生效后未接组三单取消C:线上实收132原交易退款132含32运费,到付未收无退款;A/B继续待接不补差,C不能被分配。并发接组先胜时拒绝未接单取消路径;取消全部成员时任务结束,退款幂等并保持真实进度。
+- **SC-014** 已接组在未备餐、备餐、未取/已取阶段,用户单笔/整组及直接接口取消均拒绝,无状态更新、容量释放、退款或审核任务;特别覆盖state=0但deliveryStatus=1且qsId已分配的商家未接场景,以及组已承接但子单字段异常不一致时保守拒绝。接组与用户取消并发仍无“已承接却按未接全退”,校验后发生承接必须使取消CAS失败。旧非适用单据原流程保持不变。
+- **SC-015** 已接组C无法供餐且未取,商家只取消C、真实线上132全退含32运费;A/B仍由原骑手履约不补差,待收/站点/容量及通知一致;到付未收不造网关退款。取餐先成功拒绝此取消路径,重复请求不重复退款、商家无越权,全组有效成员皆取消则任务结束。
+- **SC-016** 全到付/全线上/混合/未选共用15分钟发起/到付确认截止;创建/部分到付确认不生效,缓冲不开放新支付发起/重试/换方式/到付确认。默认5分钟缓冲允许截止前已记录并实际发起的线上尝试完成和核实,最后合法成员就绪只激活一次;最终截止未就绪实收全退,无收款到付不造退款。主动取消后/最终截止后发现线上成功退款不重开;生效后不再按结算未完成取消。
+- **SC-017** 验证动态增加/修改/删除档位、最高门槛匹配、不同原运费分别打折、去重/圈外店不计数、重复门槛及非法系数拒绝。新增4家七折仅影响新报价;已有4家八折订单不重算,部分取消不补差;提交重验报价防止混用配置版本。
+- **SC-018** 首版不同城市使用同一套全局规则,初始2家九折、3/4/5家八折且无4家七折档;合法配置默认5家时5家可进入后续资格/任务容量校验、6家不能合并,不静默拆组。修改最大店数后预结算/创建/接组重验,上限调整不强制取消已接组;现有字典缺失回退1任务仍可承接一个合法五店组,不误计为5个任务,不新增子单容量配置。
+- **SC-019** 验证每店舍入方向和单位:41元八折32.8元取33元,45元九折40.5元取41元;三店原运费均41元、八折时各33元,总运费99元,不能按总额123元八折98.4元另舍入为98元。支付/退款/到付/骑手毛运费及账单基数一致,配置变化或允许取消不重算剩余金额,不产生跨店尾差。
+- **SC-020** 验证骑手不按选店顺序取餐、A送达时B仍待取餐、A已送/B配送中/C合法取消等混合进度;每次操作只影响对应子单,未取子单不能送达,不强制全部取齐/整组送达。A完成触发自己的普通账单/通知且重试不重复,其他单继续原流程;剩余有效成员未结束时组任务仍占1,最后有效成员结束才释放。普通非合并订单流程不被改变。
+- **SC-021** 验证范围配置及距离口径:默认1公里时首店直线0.9公里可进入其他资格校验、1.1公里排除,不能因道路绕行超过1公里而排除直线圈内店;改为2公里后新报价按2公里,不写死1公里,已有收费快照不追改。预结算/创建均拒绝即时与预约混组、同日18:00–18:30与19:00–19:30、同日18:00–18:30与18:15–18:45、不同日期同一时段,不静默改时间/拆组;合法纯即时组及同一日期/起止时间的合法预约组不被误禁用。
+- **SC-022** 验证新批次创建后改价/上下架/停止接新单/普通营业时间变化时,期限内支付仍取原商品及金额快照,已付其他店不被自动取消;归属不符、取消、超时、支付方式/凭证不可用仍拦截。旧非多店订单的价格、支付、取消等规则保持不变;普通平台配送预约仅按SC-025补齐旧时点开放扫描,不能因同样挂父单而套用新批次规则;旧支付限制不被统一删除,只有合法新批次成员可定向放行。
+- **SC-023** 14:00创建、默认发起/到付确认截止14:15及最终确认截止14:20;14:14由服务端记录并实际发起、14:17真实付款并核实且其余成员合法就绪可生效。14:16新发起/重试/切换方式/确认到付拒绝;仅前端声称14:14已发起、伪造时间、只有请求或认证成功均不能当作实收就绪。最终截止仍未知则取消并持续核查迟到实收退款。验证缓冲配置5改8只影响新批次、非法回退5、回调/查询/扫描竞争、重复核销、LINE分阶段及非多店无新增期限。
+
+- **SC-024** A已付/B未付或未选不推履约新单且不可抢,全部合法就绪即时开放一次、预约未到不推。重复回调/查询/到付不重复事件,事务回滚不发送;失败可重试、取消后不复活,单笔送达通知不等其他单。
+- **SC-025** 旧配置提前60分钟、预约18:00–18:30时17:00前不开放、17:00后原普通合法平台配送预约可开放;新批次未生效仍不开放,随后生效才开放。停机跨时点后补扫,重复扫描不重复开放/生成通知,取消/接单竞争不复活;普通单不套批次15分钟条件,自取/自配送不纳入,新语义/最迟接单/自动取消不视为已实现。
+- **SC-026** 新批次未选、A已付/B未付、混合到付、Apple Pay未付及缓冲核实中可查进度并恢复合法结算;批次未就绪时已付子单不能误标可履约,到付不标实收,生效后逐单普通进度不被组覆盖;旧非多店分类回归不变。
+- **SC-027** 篡改amount/freight/foodAmount、跨店商品/规格、非法数量不能改变收费;提交报价差异拒绝/重报价而非仅警告。服务端快照与渠道/退款/到付/账单一致,创建后改价仍按旧快照,原单店算价不改。
+- **SC-028** 未生效整批取消返对应券、生效取消C只返C券;重复取消/退款/扫描只返一次,仍有效可用、过期不续期。并发创建同一券不能重复核销,旧取消回调不释放新单所用券;返券不伪造退款成功,普通单券规则不改。
+- **SC-029** 用户A传B父单号/子单ID/组ID或伪造关联/标识,不能看B详情/进度或支付/取消,失败无状态/款项/返券副作用;本人合法查询正常,商家/骑手仅现有授权范围,不因关联组暴露其他敏感数据。
+
+## 待讨论事项
+
+见 [plan.md](plan.md) 的Q1~Q13确认表。范围、付款/15分钟支付发起及到付确认期限、既有线上交易可在默认5分钟可配置缓冲内完成和核实、直线围栏/可配置范围、费用、原字典按组计1单且不增子单上限、全局/初始折扣、逐店整数新台币舍入、不补差、取消/快照/非多店兼容均已确认;即时预约不混合、同组预约日期/起止一致、独立取餐/送达及骑手自行决定顺序亦已确认。其他普通优惠/履约/异常沿原有效流程,原能力缺口另反馈,不额外设计多店规则;预约时间算法延期,原任务字典不自动改写。支付尝试发起证据、真实收款核实、分阶段与并发适配仍须在data-model/contracts落实并验证,不能把业务参数确认描述成接口已实现;完整方案批准前不写执行tasks或业务代码。
+
+2026-10-13最新补充:用户接受Q6口径放宽为15分钟内发起线上支付/确认到付,截止前已记录并实际发起的线上交易可在默认5分钟可配置缓冲内完成付款和服务端确认;取代严格要求第15分钟前完成付款/认证的旧建议。缓冲期禁止新发起、重试、方式切换及新的到付确认,既有渠道页面可能在第15分钟后完成付款,不再描述成仅后台响应缓冲。全部合法就绪立即生效,最终截止未就绪取消及实收退款;全到付不免期限,取消后不重开。已确认业务参数不重复询问,后续整理完整技术方案供审阅。
+
+## 变更记录
+
+- 2026-10-12:用户明确预约配送、到付纳入首版;夜市自配送、自取沿用原流程。补充到付生效与预约开放的独立规则,尚未实现。
+- 2026-10-12:用户指出各商家支付方式可能不同;取消共同支付方式的前提,改为逐店选择/校验,补充线上渠道及到付混合的生效建议,混用细则待确认。
+- 2026-10-12:用户确认 LINE/OMG/到付按店混用并询问转账兼容;记录已确认混用,补充待确认的转账核款、待办例外、人工退款及迟到收款方案,尚未实现。
+- 2026-10-12(历史方案,已被2026-10-13简化规则取代):用户当时确认双层骑手容量,替换原“合并三单占三个任务”建议;一组占1任务、组内子单独立计数。额外子单上限及其验收现已撤回,仅保留任务计数/释放等仍有效规则。
+- 2026-10-12:用户确认三店原运费各40八折后每店32,商家给骑手折后运费;到付给商家为子单应付总额减运费。更新Q3及现金/账单一致验收,未改业务代码。
+- 2026-10-12:用户要求预约时间调整后续单独处理;标记FR-014/Q12为后续事项,保留本轮预约兼容,不把送达语义、倒推时刻、开放补偿和预约无人接单自动取消列为本轮新增功能。仅记录,未改业务代码。
+- 2026-10-12:用户确认Q13采用“新多店批次不支持线下转账,原单店转账保持不变”;撤下候选核款/人工转账退款扩展,补充上下文支付过滤、仅转账门店提示、提交前校验和旧流程回归。业务实现未开始。
+- 2026-10-12:用户明确圈外C不参与本次合并结算,也不等待C付款;撤回“A/B合并+C同批独立配送”和“有C整批禁止合并”建议。记录首店围栏、明确参与集合及提交防夹带、付款/金额隔离验收;建议C保留购物车另结算,未改App或业务代码。
+- 2026-10-12:用户确认生效后部分取消不补差价;固化剩余子单原运费/折扣,不向线上或到付单追加差额。取消单本身能否取消/退运费仍待Q5,Q8金额舍入仍待定;仅更新规格/方案。
+- 2026-10-12:用户确认已生效批次骑手未接单时允许单店取消,完整实收含折后运费全退;未实收到付仅取消。补组成员收口和取消/接组竞争验收,已接单规则仍未确认;仅更新文档。
+- 2026-10-12:用户明确骑手已接后不给用户取消、不做商家审核。新增已接拒绝及无副作用验收,撤下用户主动取消保留运费/部分退款的默认前提;异常售后范围仍待确认。未改业务代码。
+- 2026-10-12:用户确认已接但该店未取、商家无法供餐可取消本店,完整实收含运费全退;其他单继续不补差。补商家归属、与取餐竞争、有效站点/待收/容量/通知及全组收口验收,骑手已发生款项和空跑费用另定。仅更新文档。
+- 2026-10-12:用户明确到付当面能交餐才给商家钱,无法供餐不会先垫款;撤下“未取餐但已垫款”假设及返款分支,普通取餐流程不新增垫款状态/退款入口。保留线上全退、到付仅取消、其他成员继续及既有接单/取餐限制。仅更新文档。
+- 2026-10-13:用户明确最后逐笔收银才知道方式组合,并确认全部新批次创建起15分钟内完成线上支付或到付确认才生效;超时未完成整批取消,实收全退,到付不造退款。撤回全到付创建生效/无期限假设,更新创建/确认职责、并发和验收;未改业务代码。
+- 2026-10-13:用户确认动态“最少店数+折扣”阶梯,按最高满足门槛匹配、各店分别打折,配置不追溯已创建订单且取消不补差。4店七折为扩展示例,不视为上线默认值;最大合并店数/城市覆盖仍待确认。仅更新规格/方案,业务实现未开始。
+- 2026-10-13:用户进一步确认首版默认最多合并5家且可配置,规则全局生效、暂不按城市/区域区分;同步边界、上限与容量共同校验及验收,取代此前待确认项。未确认其他骑手容量默认数值及初始折扣,未改业务代码。
+- 2026-10-13:用户确认初始折扣为2家九折、3家及以上八折;记录两个动态档位的门槛/系数,4家七折仅后续扩展示例、不默认启用。更新初始规则与验收,业务实现未开始。
+- 2026-10-13:用户确认每家运费独立打折、HALF_UP到整数新台币后加总,不另分摊尾差;支付、退款、到付及商家给骑手毛运费取同一快照。撤下整批打折后比例分摊/补齐尾差建议,新增单位/舍入/对账验收;仅更新文档,未改业务代码。
+- 2026-10-13:用户确认简化骑手容量:现有外卖/闪送/总任务字典继续控制接单数,合并组算1单、普通单算1单,每组最多5家由合并配置控制;撤回额外子单总量上限/新字典/页面及相关验收。部分完成仍不提前释放组任务,保留同锁并发/急送独占/异常回退。仅更新036文档,未改业务代码或数据库配置。
+- 2026-10-13:用户明确独立取餐/独立送达、取餐顺序骑手自行决定,进一步明确多店下单目的为配送费优惠,其余沿普通订单流程。撤回全部取齐后整组送达建议,不新增整组送达/结算操作;保留此前确认的批次支付准入、超时取消、同骑手接组及组计1任务。同步独立履约/混合进度/单笔幂等验收,仅更新文档。
+- 2026-10-13:用户确认合并范围使用直线距离、范围可配置(默认1公里),即时单与预约单不能合并;同步服务端校验、参数变更/道路绕行及混组拒绝验收。未把此答复扩展为取消预约多店或批准不同预约时段混合;其他未定事项保留。仅更新036文档,未改业务代码。
+- 2026-10-13:用户确认新多店创建完整校验/创建后保留快照/付款不复查普通上下架及营业变化,要求不影响现有非多店订单。撤回按普通菜单/营业变化取消未生效整批的候选建议,保留必要支付校验及既定子单无法供餐处理;明确服务端适用标识、旧流程隔离和完整兼容回归,不新增库存体系。仅更新036文档,未改业务代码。
+- 2026-10-13:用户明确不同预约时段不能合并,因订单交给同一骑手;收口为预约日期及起止时间完全一致才同组,仅重叠/跨日期同钟点不合并。同步FR-033及预结算/创建验收,当前时段资格已确认,不启动既定延期预约时间改造;仅更新036文档。
+- 2026-10-13:用户要求15分钟内已付款应留服务端确认缓冲,并指定默认5分钟、平台可配置;拆分用户操作截止/缓冲/最终确认截止快照,缓冲只核实既有交易,全部就绪即生效、最终未就绪取消退款。替换第15分钟立即取消的旧建议,补渠道付款时间、LINE分阶段、配置变更与非多店隔离验收。仅更新036文档,未改代码/SQL/App。
+- 2026-10-13:用户接受第2项建议,明确放宽为15分钟内发起线上支付/确认到付,服务端已记录并实际发起的线上尝试可在默认5分钟可配置缓冲内完成用户认证、付款及服务端确认;第15分钟后禁新发起/重试/切换/到付确认。同步正文、Q6和验收,撤回严格第15分钟前完成付款及仅后台响应缓冲口径;实收才就绪、最终未就绪取消退款、取消后不重开及非多店兼容不变。仅更新036文档,未改代码/SQL/App、未执行测试、未切换分支或提交推送。
+- 2026-10-13:用户确认审查第4~9项,纳入全部就绪后幂等履约通知、普通平台配送/多店预约旧时点扫描与重启补扫、用户列表/继续结算、服务端权威算价、新批次取消券返还(原有效期/幂等)及详情/进度/操作归属。新增FR-036~041、SC-024~029及7.1映射,基本扫描不再延期;新预约语义/最迟接单/无人接单取消仍后续,其他普通规则不套新批次限制。仅更新036文档,未改代码/SQL/App、未执行测试、未创建/切换分支或提交推送。