Просмотр исходного кода

Merge branch 'test' into test-202609v2

# Conflicts:
#	.claude/homunculus/observations.jsonl
#	updatesql/sql.md
qmj 1 день назад
Родитель
Сommit
c276be83a8

+ 0 - 13
.idea/modules.xml

@@ -1,13 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project version="4">
-  <component name="ProjectModuleManager">
-    <modules>
-      <module fileurl="file://$PROJECT_DIR$/ruoyi-admin/ruoyi-admin.iml" filepath="$PROJECT_DIR$/ruoyi-admin/ruoyi-admin.iml" />
-      <module fileurl="file://$PROJECT_DIR$/ruoyi-common/ruoyi-common.iml" filepath="$PROJECT_DIR$/ruoyi-common/ruoyi-common.iml" />
-      <module fileurl="file://$PROJECT_DIR$/ruoyi-framework/ruoyi-framework.iml" filepath="$PROJECT_DIR$/ruoyi-framework/ruoyi-framework.iml" />
-      <module fileurl="file://$PROJECT_DIR$/ruoyi-generator/ruoyi-generator.iml" filepath="$PROJECT_DIR$/ruoyi-generator/ruoyi-generator.iml" />
-      <module fileurl="file://$PROJECT_DIR$/ruoyi-quartz/ruoyi-quartz.iml" filepath="$PROJECT_DIR$/ruoyi-quartz/ruoyi-quartz.iml" />
-      <module fileurl="file://$PROJECT_DIR$/ruoyi-system/ruoyi-system.iml" filepath="$PROJECT_DIR$/ruoyi-system/ruoyi-system.iml" />
-    </modules>
-  </component>
-</project>

+ 17 - 13
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -280,6 +280,17 @@ public class FlashDeliveryApplicationService {
         order.setDistanceMeters(quote.getDistanceMeters());
         order.setDistanceSource(quote.getDistanceSource());
         order.setEstimatedDurationSeconds(quote.getEstimatedDurationSeconds());
+        // 运价快照与金额同事务刷新为本次计价依据(快照=金额的最后一次计算依据,而非最初依据)
+        order.setPricingId(quote.getPricingId());
+        order.setPricingVersion(quote.getPricingVersion());
+        order.setPricingStartTime(quote.getStartTime());
+        order.setPricingEndTime(quote.getEndTime());
+        order.setStartingDistance(quote.getStartingDistance());
+        order.setStartingFare(quote.getStartingFare());
+        order.setDistance(quote.getDistance());
+        order.setFreight(quote.getFreight());
+        order.setUrgentRate(quote.getUrgentRate());
+        order.setMinimumUrgentFee(quote.getMinimumUrgentFee());
         order.setDistanceFee(quote.getDistanceFee());
         order.setBaseDeliveryFee(quote.getBaseDeliveryFee());
         order.setUrgentFee(quote.getUrgentFee());
@@ -353,18 +364,11 @@ public class FlashDeliveryApplicationService {
                 new GeoPoint(request.getPickup().getLatitude(), request.getPickup().getLongitude()),
                 new GeoPoint(request.getDelivery().getLatitude(), request.getDelivery().getLongitude()));
         if (route.distanceMeters() > MAX_DISTANCE_METERS) throw fail("flash.delivery.distance.too.far");
-        // 沿用订单快照,避免地址编辑顺带切换运价或重验已经到时的预约单。
-        FlashDeliveryPricing pricing = new FlashDeliveryPricing();
-        pricing.setId(order.getPricingId());
-        pricing.setConfigVersion(order.getPricingVersion());
-        pricing.setStartTime(order.getPricingStartTime());
-        pricing.setEndTime(order.getPricingEndTime());
-        pricing.setStartingDistance(order.getStartingDistance());
-        pricing.setStartingFare(order.getStartingFare());
-        pricing.setDistance(order.getDistance());
-        pricing.setFreight(order.getFreight());
-        pricing.setUrgentRate(order.getUrgentRate());
-        pricing.setMinimumUrgentFee(order.getMinimumUrgentFee());
+        // 待接单尚未成交,改址按"订单定价时点"的最新运价重算(预约单取预约取件时刻、
+        // 立即单取当前),管理员调价对未接单的改址立即生效;时段语义保住,预约单不会用到错误时段的价格。
+        Date pricingDate = "SCHEDULED".equals(order.getDeliveryMode())
+                ? order.getScheduledPickupStartAt() : new Date();
+        FlashDeliveryPricing pricing = findPricingAt(pricingTargetTime(pricingDate));
         FlashDeliveryPriceBreakdown breakdown;
         try {
             breakdown = pricingCalculator.calculateBreakdown(pricing, route.distanceMeters(),
@@ -582,7 +586,7 @@ public class FlashDeliveryApplicationService {
                                                  String serviceType, String orderNo,
                                                  Long userId, Long riderId) {
         validateOptionalServiceType(serviceType);
-        QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<FlashDeliveryOrder>().orderByDesc("create_time");
+        QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<FlashDeliveryOrder>().orderByDesc("id");
         optionalEq(query, "status", status);
         optionalEq(query, "service_type", serviceType);
         if (hasText(orderNo)) query.like("order_no", orderNo.trim());

+ 1 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/InfoAddressController.java

@@ -41,7 +41,7 @@ public class InfoAddressController extends BaseController {
     @GetMapping("/getGoogleMapKey")
     public AjaxResult getGoogleMapKey() {
         List<SysDictData> data = DictUtils.getDictCache("sys_googlemap_key");
-        return data != null && !data.isEmpty() ? success(data.get(0).getDictValue())
+        return data != null && !data.isEmpty() ? success("",data.get(0).getDictValue())
                 : error(MessageUtils.message("no.map.exception"));
     }
 

+ 6 - 3
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java

@@ -1156,10 +1156,10 @@ class FlashDeliveryApplicationServiceTest {
     }
 
     @Test
-    void addressQuoteUsesOrderPricingSnapshotAndDoesNotWrite() {
+    void addressQuoteUsesLatestPricingForSlotTimeAndDoesNotWrite() {
         Fixture fixture = new Fixture();
         FlashDeliveryOrder order = editableOrder(fixture);
-        // 已到预约时间也允许待接单订单改地址,不重新验证创建时的预约窗口
+        // 已到预约时间也允许待接单订单改地址;报价读取该时点的最新运价而非订单旧快照
         order.setDeliveryMode("SCHEDULED");
         order.setScheduledPickupStartAt(new Date(1));
         order.setScheduledPickupEndAt(new Date(1800001));
@@ -1173,7 +1173,8 @@ class FlashDeliveryApplicationServiceTest {
         assertEquals(2, quote.getOrderVersion());
         assertEquals(1, quote.getPricingVersion());
         assertEquals("pickup road", order.getPickupAddress());
-        verify(fixture.pricingMapper, never()).selectAtTime(anyString());
+        // 改址报价必须查询运价表(按定价时点取最新),不再沿用订单快照
+        verify(fixture.pricingMapper).selectAtTime(anyString());
         verify(fixture.orderMapper, never()).updateWaitingAddress(any(), any());
         verify(fixture.logMapper, never()).insert(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderLog.class));
     }
@@ -1404,6 +1405,8 @@ class FlashDeliveryApplicationServiceTest {
         order.setFreight(15L);
         when(fixture.orderMapper.selectById(10L)).thenReturn(order);
         when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(4500, 900, "GOOGLE"));
+        // 031 后改址按订单定价时点读最新运价:stub 与订单快照同值,金额断言维持不变
+        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
         return order;
     }
 

+ 1 - 1
ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java

@@ -75,7 +75,7 @@ public class FlashDeliveryOrder {
     private String distanceSource;
     private Integer estimatedDurationSeconds;
 
-    /** 下单时的金额和计价配置快照,后续后台调价不会修改这些字段。 */
+    /** 最近一次定价(下单或待接单改址)的金额与计价配置快照;快照+距离恒可复算出金额,后台调价只影响之后的定价。 */
     private Long amount;
     private String currency;
     /** 支付方式与主订单系统同域:4=现金、6=线下转账;闪送款项直达骑手,仅允许线下方式,下单时锁定。 */

+ 5 - 0
ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml

@@ -101,6 +101,11 @@
             receiver_user_id = #{order.receiverUserId},
             distance_meters = #{order.distanceMeters}, distance_source = #{order.distanceSource},
             estimated_duration_seconds = #{order.estimatedDurationSeconds},
+            pricing_id = #{order.pricingId}, pricing_version = #{order.pricingVersion},
+            pricing_start_time = #{order.pricingStartTime}, pricing_end_time = #{order.pricingEndTime},
+            starting_distance = #{order.startingDistance}, starting_fare = #{order.startingFare},
+            distance = #{order.distance}, freight = #{order.freight},
+            urgent_rate = #{order.urgentRate}, minimum_urgent_fee = #{order.minimumUrgentFee},
             distance_fee = #{order.distanceFee}, base_delivery_fee = #{order.baseDeliveryFee},
             urgent_fee = #{order.urgentFee}, amount = #{order.amount},
             update_time = #{order.updateTime}, version = version + 1

+ 16 - 0
specs/024-flash-delivery/plan.md

@@ -701,3 +701,19 @@ The staged list must not contain `.claude/homunculus/observations.jsonl`、`.tmp
 ### 2026-09-09:编辑操作订单 ID 改为请求体
 
 按用户要求,仅调整确认修改地址和追加小费:在 FlashDeliveryAddressConfirmRequest、FlashDeliveryTipAddRequest 添加 orderId;Controller 两个方法只接 token 与 RequestBody DTO,通过 DTO 将订单 ID 传给既有 Service。requireEditableOrder 在查询前拒绝空/非正订单 ID。同步端点契约、JSON 绑定/身份传递测试、App 文档及定向验证,原有费用和并发业务规则保持不变。
+## 2026-09-14:多取货点实施计划
+
+**目标**:每站独立联系人及物品,骑手自主取件顺序,单点可省略 stopId/多点必传,重复取件幂等,任意站取货后禁止用户取消。本节替换原多点增量方案,生产代码仍待实施。
+**实现**:新订单统一写站点表,订单表维护首点地址/物品摘要与 pickup_stop_count;不做历史迁移、无站点回退或测试数据清理。用户顺序用于整线报价展示,实际取件顺序由骑手决定且不变价;追加小费不重算路线。
+**约束**:JDK 21;ruoyi-admin -> ruoyi-system;明确 DTO、token 请求头、Service 国际化校验;SQL 只记录 updatesql/sql.md;保留原编码换行。本轮仅更新现有 spec.md、plan.md、tasks.md,不执行生产实现、SQL、测试或提交。
+
+1. **数据层(T089、T090)**:在 ruoyi-system 的 domain/flash 新增 FlashDeliveryOrderStop,mapper/flash 新增 Mapper 与 XML;存每站联系人、地址、packageType/quantity/weightRange/specification、状态和确认时间,关联站点凭证。订单实体与 FlashDeliveryOrderMapper.xml 的 resultMap、select/insert/update 同步 pickup_stop_count、首点地址及物品摘要;站点按 order_id 查询并建立同单 stop_order 唯一约束。updatesql/sql.md 记录建表、订单加列、凭证关联所需 SQL 及三个字典项,上限初始化为 1;不执行 SQL。
+2. **配置及路线(T090、T091)**:RouteDistanceProvider 新增多经点方法,GoogleRoutesDistanceProvider 以首取点为 origin、其余 intermediates、收货点 destination,不开启 optimizeWaypointOrder;FlashDeliveryRouteService 降级逐段直线求和。数量有效范围 1..26,字典缺失/非法回退 1,超限在地图调用前拒绝;路线与降级均校验整线 40 公里。FlashDeliveryHomeView 下发 pickupStopLimit、showMultiPickup、showUrgentOption。
+3. **请求、报价及创建(T091)**:在 ruoyi-admin 的 flashdelivery/dto 新增含完整地址和物品字段的取货点 DTO,QuoteRequest/CreateRequest 扩展 pickups 数组;保留旧 pickup + 订单级物品单点适配。两种地址输入同时出现、数组与旧订单级物品同时提交均拒绝;逐站校验物品、数量、重量范围、规格、地址/坐标及取收同址,并统一校验开关和上限。FlashDeliveryApplicationService 沿用创建幂等、路线及费用复算,创建站点、摘要与图片处于同一事务。
+4. **待接单编辑(T091)**:AddressChangeRequest/AddressConfirmRequest 同步完整 pickups + delivery;多点不得以 pickup 覆盖站点集合,旧单点编辑保留物品。沿用原运价快照及 orderVersion,确认时复算整线;先执行校验创建人/待接单/无骑手/版本的订单条件 UPDATE,成功后同事务保存站点、首点摘要、数量和收件人绑定,失败整体回滚。addTip 保留只增加小费及 amount,不查路线、不重算其他费用,不重验开关或数量上限。
+5. **取件及取消(T092、T093)**:FlashDeliveryProofRequest 增加可选 stopId;原 pickup 接口单点可省略、多点必传,另提供路径 stopId 接口;两处 ID 同传必须一致,共用取件业务实现,不自动选择下一未取站。FlashDeliveryOrderMapper 新增按订单 ID SELECT ... FOR UPDATE;取件、用户/平台取消均在事务内先锁订单行,再读最新站点状态,不能使用锁前读到的状态校验。校验订单、骑手、PICKUP 类型,允许任意未取站;已确认站点重试无新写入。新取件必须处于 ACCEPTED,站点和凭证保存后检查是否全部取完,递增订单版本并记录站点日志;仅全部取完更新 PICKED_UP/pickedUpAt。用户取消锁内检查任意已取站点并拒绝;平台沿用介入权限。图片、日志或其他更新失败必须全部回滚。
+6. **视图及契约(T093、T094)**:列表增加 pickupStopCount 并保留首点摘要,各角色详情增加带 ID、独立物品及凭证的 stops,pickupImageUrls 仅作聚合展示;接单前逐站隐藏联系人、电话、坐标、PIN 及图片。平台详情同步展示各站供介入核对。实现阶段同步 contracts/api.md、data-model.md、quickstart.md、docs/flash-delivery-app-api.md 和现有全部后端语言资源;平台改动同步四语言。错误 key 覆盖缺少 stopId、越权、输入冲突、点数超限、开关关闭;不增加乱序错误。导航使用 travelmode=two-wheeler,目标为骑手选定单站,全部取完后为收货点。
+7. **测试(T088、T095)**:扩展 ApplicationService、ControllerContract、RouteService、OrderView、RiderOrderListView、MapperXml、I18nContract 测试。覆盖独立物品、旧单点适配/冲突、完整站点编辑/物品保持/版本竞争;单点省略 ID、多点缺 ID 拒绝、B 先于 A 成功、跨单/收货站/他人骑手拒绝;响应丢失后重试及并发重复请求不追加凭证或推进别站,最后两站并发完成只推进一次,凭证失败整体回滚。取消与取件并发验证两种先后结果,不能仅用顺序调用宣称并发通过。数量覆盖 0、1、26、27 及字典缺失/非法,26 点对应 25 intermediates;覆盖整线 40 公里、降级、脱敏和追加小费不调用路线。
+8. **验证交付(T095)**:实现后临时设置本机 JDK 21,运行受影响闪送定向测试及 mvn -pl ruoyi-admin -am -DskipTests package,再执行 git diff --check、变更范围和编码换行检查。需要 MySQL 的事务/并发检查独立执行记录,单元和 Mapper 文本检查不能替代数据库验证。真实地图、数据库、App 联调及未执行回归明确列出;核对暂存仅含本功能文件后中文提交。
+
+外部约束:Google Routes 单次最多 25 个中途点,Maps URL 使用 two-wheeler 且途经点数量受平台限制;本期采用字典有效上限最多 26 个取货点与单站导航,不做无限站点分段请求。

+ 45 - 1
specs/024-flash-delivery/spec.md

@@ -4,7 +4,7 @@
 
 **创建日期**:2026-08-31
 
-**状态**:既有功能已验证;2026-09-07 设计稿费用与物品信息对齐增量已确认,待实施
+**状态**:既有功能已验证;2026-09-07 设计稿费用与物品信息对齐增量已确认,待实施;2026-09-14 多取货点增量已确认,待实施
 
 **输入**:基于蓝湖“闪送”分组 7 个设计页面、2026-08-24 用户端/骑手端补充原型及 2026-09-03 更新的 19 张用户端设计稿,实现帮送/帮取业务场景、普通/1 对 1 加急配送、共享地址簿、路线报价、完整物品信息、立即/预约配送、费用明细与报价校验、可选 PIN 交付、创建订单、寄件人/收件人订单查询、骑手主动抢单、急送独占、取件、送达、用户签收、平台配置与介入的非支付业务闭环。
 
@@ -295,3 +295,47 @@
 - 本次只新增以上能力;沿用现有取消规则、非支付边界。复用现有字段与 version,无数据库迁移。
 
 - FR-078(2026-09-09 入参调整):确认修改地址与追加小费使用 POST /orders/address 和 POST /orders/tip;订单 ID 通过 JSON 请求体 orderId 传入,必须是有效正整数,token 仍放请求头。
+## 2026-09-14:闪送多取货点(多取一送)
+
+本节按本轮审核及用户确认更新,替换此前多点增量规则;生产功能仍待实施。单笔订单包含 1 至 N 个取货点和 1 个收货点,每个取货点独立关联联系人和物品信息。用户填写顺序用于初始路线报价和展示,骑手自行决定实际取件顺序。按整条路线距离计价,不新增每取货点附加费;点间不设单独距离限制,整条报价路线仍不得超过 40 公里。
+
+### 用户故事 8:用户发布多取货点订单并由骑手逐点取件(优先级:P1)
+
+用户逐站填写联系人、地址和物品,填写唯一收货点后获取整条路线报价;骑手接单后自主选择未取货站点,逐站确认并上传凭证,全部取货点完成后订单进入已取件,送达流程不变。
+
+**优先级原因**:每站独立物品和凭证用于履约核对与纠纷定位。
+
+**独立测试**:验证每站物品独立、报价按用户顺序而取件顺序自由、单点可省略 stopId、多点必传 stopId、重试不推进其他站点、取件与用户取消互斥,以及全部取货点完成时仅推进一次主状态。
+
+**验收场景**:
+
+1. **假如** 多地点开关开启,**当** 用户报价或创建多点订单,**那么** 各站分别校验联系人和物品,按用户填写顺序计算整条路线距离、时长和费用。
+2. **假如** 数量超过字典有效上限,**当** 报价、创建或修改取货点,**那么** 返回国际化错误;字典缺失或非法时有效上限为 1。
+3. **假如** 多地点开关关闭,**当** 报价、创建或编辑提交多于一个取货点,**那么** 拒绝;既有订单履约及仅追加小费不受开关和上限调整影响。
+4. **假如** 订单只有一个取货点,**当** 骑手不传 stopId,**那么** 定位唯一取货点;多点订单未传 stopId 必须拒绝,不自动选择未取货站点。
+5. **假如** B 点在用户排序中位于 A 点之后,**当** 本单骑手先确认 B 点且凭证有效,**那么** 允许;主状态保持 ACCEPTED,全部取货点完成后才进入 PICKED_UP。
+6. **假如** 请求指定非本单站点、收货站点或他人订单站点,**当** 取件确认,**那么** 拒绝;同一骑手重试已确认站点只返回已确认结果,不重复写凭证、时间、日志或推进其他站点。
+7. **假如** 任意站点已经取货,**当** 用户自助取消,**那么** 拒绝并要求平台介入;与取件并发时,取件先成功则用户取消失败,取消先成功则取件失败。
+8. **假如** 用户选择加急和多个取货点,**当** 报价或创建,**那么** 允许并沿用整条路线计价及骑手独占规则。
+9. **假如** 待接单订单修改地址,**当** 报价或确认保存,**那么** 使用完整站点序列和原运价快照重算整线;仅追加小费时只增加小费和总金额,不请求路线或重算其他费用。
+10. **假如** 整线超过 40 公里或地图服务失败,**当** 报价、创建或地址编辑重报价,**那么** 按整线执行距离上限和逐段直线降级;点数超限在地图调用前拒绝。
+11. **假如** 旧单点请求提交 pickup 与订单级物品字段,**当** 报价或创建,**那么** 适配为一个含独立物品的取货点;pickup 与 pickups 同时提交时拒绝。
+12. **假如** 多点地址保存与抢单或其他编辑并发,**当** 保存,**那么** 站点、摘要、收件人绑定、路线和金额必须同一事务提交或回滚,不出现两套地址或报价。
+
+- FR-079:通用站点表 flash_delivery_order_stop 保存订单 ID、类型(PICKUP/DELIVERY)、用户填写顺序、快照和履约信息,作为新订单站点数据唯一真相;所有新订单(含单点)统一写入取货和收货站点。订单表 pickup_* 维护首取货点摘要,新增 pickup_stop_count。当前为测试阶段,不做历史订单迁移、无站点履约回退或历史详情适配,不自动清理测试数据。
+- FR-080:每个取货点独立保存联系人、电话、地址、详细地址、市/区、交接方式、经纬度和物品字段 packageType、quantity、weightRange、specification;逐站按既有物品规则校验并供骑手核对。唯一收货点保存收件信息,收件账号绑定和交付 PIN 维持订单级一套。既有订单级物品字段仅保留首取货点兼容摘要,不代表多点订单全部物品,不作为站点物品来源。
+- FR-081:数据字典 flash_pickup_stop_limit 配置取货点上限,不含收货点;仅接受 1 至 26 的整数,缺失、空值或非法配置时有效上限为 1。报价、创建、修改取货点均校验非空且不超过有效上限,超限返回国际化错误;首页下发有效 pickupStopLimit。26 个取货点对应 25 个中途点,超点数在地图调用前拒绝,不作为地图故障降级。
+- FR-082:flash_show_multi_pickup 控制多点能力;关闭时多于一个取货点的报价、创建和地址编辑均拒绝。开关或上限调整不影响已创建订单取件、送达及仅追加小费。
+- FR-083:flash_show_urgent_option 经首页配置下发,仅控制 App 加急选项展示;后端仍按 deliveryType 执行既有校验,不受该展示开关影响。
+- FR-084:初始及地址编辑报价按用户填写顺序计算:首取货点为起点,其余取货点为中途点,收货点为终点,不自动优化顺序;地图失败逐段直线距离求和,40 公里上限按整线执行。stop_order 仅用于报价和展示,骑手改变实际取件顺序不重排用户站点、不重算订单金额。
+- FR-085:沿用 FR-004 至 FR-006、FR-070 至 FR-072 的运价、报价回传校验和整数 TWD 取整,以整线距离替代原单段距离,不新增站点附加费或按物品计价项;保留 FR-076 小费只增加小费及总金额,不重算路线、基础配送费或加急费。
+- FR-086:deliveryType=URGENT 允许多个取货点,FR-065、FR-066 骑手独占规则不变。
+- FR-087:骑手可选择本单任一未取货站点,每站至少一张取件图片。保留 POST /system/flashDelivery/rider/orders/{id}/pickup,请求 DTO 增加可选 stopId;单点可省略,多点必传。另提供 POST /system/flashDelivery/rider/orders/{id}/stops/{stopId}/pickup,路径 ID 为操作目标,同时传请求体 stopId 时必须一致。缺失必需 ID、站点不属本单或不是 PICKUP、骑手不是接单人时拒绝;同一骑手重试已确认站点幂等返回,不重复写凭证、日志或确认时间。保存每站状态、时间和凭证;全部取货点完成才进入 PICKED_UP,pickedUpAt 记录最后一个实际完成取件的站点时间,不按 stop_order 判断末站。
+- FR-088:任意取货点已确认即禁止用户自助取消,不限定用户排序第一站;全部未取时维持现有取消规则,平台保留介入能力。取件与用户/平台取消共用订单行级并发保护:同一事务先锁订单行,再读取最新订单及站点状态并校验、更新。站点状态、凭证、主状态、版本和日志一并提交或回滚,不能只在取消前单独查询一次。取消成功后禁止新增取件;任一站取件成功后用户取消必须失败。
+- FR-089:列表保留现有字段和首取货点地址/物品摘要,新增 pickupStopCount;用户、骑手及平台详情新增 stops,包含站点 ID、类型、用户顺序、联系人、电话、地址、交接方式、坐标、独立物品、状态、确认时间及凭证。凭证按站关联,既有 pickupImageUrls 可聚合展示,不能用聚合列表覆盖各站凭证。骑手接单前逐站隐藏联系人、电话、精确坐标、PIN 及凭证图片,保留文字地址及履约判断所需物品信息。
+- FR-090:客户端按骑手选择的未取货站点拼装 Google Maps URL,“导航到下一站”的目标由骑手选择,全部取完后导航到唯一收货点。使用 api=1、travelmode=two-wheeler、dir_action=navigate,正确编码参数,起点默认设备当前位置;不依赖携带全部途经点的 URL 履约。后端按 FR-029 权限返回坐标,不生成导航 URL,不新增导航接口。
+- FR-091:站点表、订单表加列、站点凭证关联所需变更及三个字典项 SQL 只记录在 updatesql/sql.md,不直接执行数据库操作。
+- FR-092:报价和创建新增 pickups 数组,每项含完整取货地址及 FR-080 物品字段;保留旧 pickup 与订单级物品作为单点输入,统一适配后校验。pickup 与 pickups 不得同时提交;使用 pickups 时不允许同时提交旧订单级物品字段,冲突返回国际化错误。AddressChange/Confirm DTO 同步支持完整 pickups 与唯一 delivery;旧 pickup 编辑仅适用于单点并保留该站物品,多点编辑缺少完整 pickups 必须拒绝,不能丢弃其余站点。
+- FR-093:地址编辑沿用 FR-073 至 FR-075、FR-077 的权限、状态、版本及原运价快照,重算整线;订单原子条件更新成功后,在同一事务保存完整站点、首点摘要、数量、收件人绑定及费用,失败全部回滚。完整站点输入保留并校验逐站物品,不因编辑联系人或地址清空物品。
+
+地图约束依据:[Google Routes 中途点限制](https://developers.google.com/maps/documentation/routes/intermed_waypoints)、[Google Maps URL 参数及平台途经点限制](https://developers.google.com/maps/documentation/urls/get-started)。

+ 14 - 0
specs/024-flash-delivery/tasks.md

@@ -160,3 +160,17 @@
 - JDK 21 执行 `mvn -B -pl ruoyi-admin -am -Dtest=FlashDeliveryControllerContractTest,FlashDeliveryApplicationServiceTest -Dsurefire.failIfNoSpecifiedTests=false test`:72 项通过,所有参与模块编译成功。
 - `docs/flash-delivery-app-api.md` 已同步,JSON 示例解析及差异检查通过,保留 CRLF。
 - 本轮 URL/入参调整后未重跑完整回归,前一阶段完整回归为 474 项通过;真实地图、数据库及 App 联调仍未执行。
+## Phase 18:多取货点(多取一送)
+
+本阶段按 2026-09-14 审核后用户确认更新,T088 至 T095 仍待实施;本轮仅调整文档,未运行代码测试或构建。
+
+- [x] T087 记录多取货点规格、计划与任务:多取一送、通用站点表、整线距离计价、每站独立联系人及物品、骑手自主取件顺序、用户顺序用于报价展示、加急允许多点、点间无独立距离限制、双开关与数量上限走数据字典
+- [ ] T088 编写并运行多点定向测试确认旧实现缺失:独立物品、输入兼容/冲突、单点可省略及多点必传 stopId、自由顺序、重试/并发幂等、取消与取件竞争、末站并发及事务回滚、字典 1..26/非法回退;数据库并发验证与模拟测试分开记录
+- [ ] T089 在 updatesql/sql.md 记录站点表(独立联系人、地址、物品、履约信息及索引)、订单 pickup_stop_count、站点凭证关联及三个字典项 SQL;上限初始值 1,只记录不执行,不做历史迁移/回退或测试数据清理
+- [ ] T090 新增 FlashDeliveryOrderStop 实体/Mapper/XML,扩展订单映射、首点地址/物品摘要及订单行锁查询;实现多经点请求和逐段直线降级,最多 26 取点/25 中途点及整线 40 公里校验
+- [ ] T091 实现逐站物品 pickups[] 报价/创建、旧 pickup 单点适配及冲突拒绝;统一开关/上限/地址/物品校验、站点写入和复算;地址编辑同步完整站点、物品、摘要及收件人绑定,同事务版本保护;小费仅增金额、不查路线
+- [ ] T092 实现原 pickup 与路径 stopId 接口:单点可省略、多点必传、两处 ID 一致性、自由选择本单未取站、每站凭证、重试无副作用、全部取完才推进 PICKED_UP;不得动态选择下一未取站
+- [ ] T093 实现取件与用户/平台取消共用订单行锁及事务,任意站已取禁止用户自助取消,取消后禁止新取件;补 stops 独立物品/凭证、列表首点摘要及 pickupStopCount、逐站脱敏、平台详情及首页有效 pickupStopLimit/双开关
+- [ ] T094 更新 contracts/api.md、data-model.md、quickstart.md、docs/flash-delivery-app-api.md 和现有全部后端语言资源,平台改动同步四语言;明确新旧输入、站点物品、地址编辑、幂等及取消,导航到骑手所选单站并使用 travelmode=two-wheeler
+- [ ] T095 JDK 21 运行闪送定向测试、模块构建、git diff --check,检查变更范围及编码换行;实际验证取消/取件与末站并发事务,明确未执行的数据库/地图/App 联调及回归,核对暂存范围后中文提交
+- [x] T096 按用户逐项确认修订本三件套:每站独立物品、单点可省略/多点必传 stopId、取件自由顺序、任意站取货后禁止用户取消及并发保护、不考虑历史订单、字典限制 1..26/非法回退 1;其余采用审核建议,保留旧单点输入、小费不重算和修正导航参数

+ 0 - 136
updatesql/sql.md

@@ -463,41 +463,6 @@ CREATE TABLE info_user_oauth (
 ) COMMENT='三方账号绑定(Apple/Google/LINE)';
 ```
 
-## 2026-09-04 LINE 三端登录数据迁移(017-oauth-login)
-
-> 用途:原普通用户 LINE 绑定迁移为 `line_user`;历史业务账号手机号补入统一的 `tel_phone` 字段。只记录脚本,由开发者手动执行;不清除原 `phone` 字段。先执行两项冲突预检,任一查询返回记录时必须人工消除冲突后再执行更新。
-
-```sql
--- 预检 1:旧 line 绑定不能与已有 line_user 绑定形成唯一键冲突;必须返回 0 行
-SELECT legacy.id AS legacy_id, current.id AS current_id, legacy.provider_uid
-FROM info_user_oauth legacy
-JOIN info_user_oauth current
-  ON current.provider = 'line_user'
- AND current.provider_uid = legacy.provider_uid
-WHERE legacy.provider = 'line';
-
--- 预检 2:有效业务账号迁移后的 tel_phone 必须唯一;必须返回 0 行
-SELECT COALESCE(NULLIF(tel_phone, ''), NULLIF(phone, '')) AS planned_tel_phone,
-       COUNT(*) AS account_count
-FROM info_user
-WHERE user_type IN ('1', '2', '3', '4', '5')
-  AND del_flag = '0'
-  AND COALESCE(NULLIF(tel_phone, ''), NULLIF(phone, '')) IS NOT NULL
-GROUP BY COALESCE(NULLIF(tel_phone, ''), NULLIF(phone, ''))
-HAVING COUNT(*) > 1;
-
-UPDATE info_user_oauth
-SET provider = 'line_user'
-WHERE provider = 'line';
-
-UPDATE info_user
-SET tel_phone = phone
-WHERE user_type IN ('1', '2', '3', '4', '5')
-  AND (tel_phone IS NULL OR tel_phone = '')
-  AND phone IS NOT NULL
-  AND phone <> '';
-```
-
 ## 2026-08-03 骑手 newTask 距离上限字典
 
 ```sql
@@ -1463,48 +1428,6 @@ VALUES (1,'臺灣銀行','臺灣銀行','taiwan_bank_list',NULL,'default','N','0
  (28,'星展(台灣)銀行','星展(台灣)銀行','taiwan_bank_list',NULL,'default','N','0','admin',NOW(),NULL),
  (29,'中華郵政(郵局)','中華郵政(郵局)','taiwan_bank_list',NULL,'default','N','0','admin',NOW(),NULL);
 ```
-# 2026-09-08:017 骑手/商家 Apple、Google 登录与旧用户 LINE 兼容
-
-本增量无 DDL,无需改写既有用户 Apple / Google / LINE 绑定。新增 provider 为
-`apple_rider`、`apple_merchant`、`google_rider`、`google_merchant`,长度均适配原 `VARCHAR(16)`。
-后端兼容 `line` 和 `line_user`,不要为了本次发布强制执行历史 LINE provider 迁移。
-`info_user_oauth` 表和业务账号 `tel_phone` 若尚未按此前脚本准备,由开发者按原预检流程处理。
-
-以下只读检查用于发现曾同时建立旧/新 LINE 绑定的重复数据;有结果时先人工核实,不自动删除、合并或改绑:
-
-```sql
-SELECT provider_uid, COUNT(*) AS binding_count,
-       COUNT(DISTINCT user_id) AS account_count
-FROM info_user_oauth
-WHERE provider IN ('line', 'line_user')
-GROUP BY provider_uid
-HAVING COUNT(*) > 1;
-```
-
-# 2026-09-08:029 商家自配送
-
-门店自配送:门店勾选自配送并设置时段(全天/按星期),命中时段的外送单不进骑手池。
-夜市场景配置存于**夜市主自己的门店行**(is_night_market=1),摊位经 night_market_id 解析读取;
-订单在下单时判定并固化快照列 self_delivery(不随后续配置变化)。
-
-```sql
--- 2026-09-08 029 商家自配送:门店配置两列
-ALTER TABLE pos_store ADD COLUMN self_delivery_enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '自配送开关(夜市主门店行开启=旗下摊位统一生效)';
-ALTER TABLE pos_store ADD COLUMN self_delivery_mode TINYINT(1) DEFAULT NULL COMMENT '自配送时段模式:1=全天,2=自定义按星期';
-
--- 2026-09-08 029 商家自配送:时段表(按星期,镜像 operating_hours 模式)
-CREATE TABLE pos_store_self_delivery_hours (
-  id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
-  md_id INT NOT NULL COMMENT '配置主体门店id(夜市场景为夜市主门店行)',
-  day_of_week TINYINT NOT NULL COMMENT 'ISO星期:1=周一..7=周日',
-  start_time VARCHAR(5) NOT NULL COMMENT '开始时刻 HH:mm(含)',
-  end_time VARCHAR(5) NOT NULL COMMENT '结束时刻 HH:mm(不含)',
-  PRIMARY KEY (id),
-  UNIQUE KEY uk_sd_hours (md_id, day_of_week, start_time)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='门店自配送时段(按星期)';
-
--- 2026-09-08 029 商家自配送:订单判定快照列
-ALTER TABLE pos_order ADD COLUMN self_delivery TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=商家自配送(下单时快照,不进骑手池,运费归sh_id)';
 
 ## 2026-09-04 闪送收件账号绑定(024-flash-delivery)
 
@@ -1607,62 +1530,3 @@ ALTER TABLE flash_delivery_order
 ALTER TABLE flash_delivery_order
   ADD COLUMN pay_type VARCHAR(4) NOT NULL DEFAULT '4' COMMENT '支付方式:4=现金,6=线下转账(下单时锁定)' AFTER currency;
 ```
-
-## 2026-09-11 可配置支付方式(031-pay-method-config)
-
-> 用途:平台支付方式开关(方式x维度)、商家/骑手收款方式选择、银行卡列表化、闪送收款状态与 027 存量迁移;缺行=开(payment_method_config 无数据时系统行为与现状一致);脚本只记录,由开发者手动执行,US5 上线前必须先执行本节全部 SQL。
-
-```sql
--- 平台支付方式开关表(缺行=开;CARD_OMG=信用卡+ApplePay同组;现金4不纳入)
-CREATE TABLE payment_method_config (
-  id          BIGINT AUTO_INCREMENT PRIMARY KEY,
-  method_code VARCHAR(32) NOT NULL COMMENT '支付方式组代码:COD/CARD_OMG/LINE_PAY/OFFLINE_TRANSFER',
-  scope       VARCHAR(32) NOT NULL COMMENT '维度:MERCHANT=商家餐饮单,RIDER_FLASH=骑手闪送',
-  enabled     TINYINT     NOT NULL DEFAULT 1 COMMENT '1=开 0=关',
-  sort        INT         NOT NULL DEFAULT 0 COMMENT '平台设置页展示排序',
-  update_by   BIGINT               DEFAULT NULL COMMENT '最后操作管理员用户ID',
-  create_time DATETIME             DEFAULT NULL COMMENT '创建时间',
-  update_time DATETIME             DEFAULT NULL COMMENT '更新时间',
-  UNIQUE KEY uk_method_scope (method_code, scope)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台支付方式开关';
-
--- 商家/骑手收款方式选择(NULL=全部,存量零行为变化)
-ALTER TABLE info_user
-  ADD COLUMN pay_methods       VARCHAR(128) DEFAULT NULL COMMENT '商家接受的支付方式组代码CSV,NULL=全部启用',
-  ADD COLUMN flash_pay_methods VARCHAR(128) DEFAULT NULL COMMENT '骑手接受的闪送收款方式组代码CSV,NULL=平台开放全部';
-
--- 收款银行卡(多卡同时仅一张启用,商家/骑手通用)
-CREATE TABLE info_bank_card (
-  id           BIGINT AUTO_INCREMENT PRIMARY KEY,
-  user_id      BIGINT      NOT NULL COMMENT '归属用户(商家主账号/骑手)',
-  bank_name    VARCHAR(64) NOT NULL COMMENT '银行名称(取字典taiwan_bank_list)',
-  account_no   VARCHAR(64) NOT NULL COMMENT '银行账号',
-  account_name VARCHAR(64) NOT NULL COMMENT '户名',
-  is_active    TINYINT     NOT NULL DEFAULT 0 COMMENT '1=启用 0=停用,同一user同时最多一张启用',
-  create_time  DATETIME             DEFAULT NULL COMMENT '创建时间',
-  update_time  DATETIME             DEFAULT NULL COMMENT '更新时间',
-  KEY idx_user_id (user_id)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收款银行卡';
-
--- 闪送骑手确认收款状态
-ALTER TABLE flash_delivery_order
-  ADD COLUMN payment_status TINYINT NOT NULL DEFAULT 0 COMMENT '收款状态:0=未收款,1=骑手已确认收款';
-
--- 027 存量迁移:银行三字段齐全的商家转成一张启用卡(迁移后 bankInfo 改读 info_bank_card)
-INSERT INTO info_bank_card (user_id, bank_name, account_no, account_name, is_active, create_time)
-SELECT user_id, bank_name, bank_account_no, bank_account_name, 1, NOW()
-FROM info_user
-WHERE user_id IS NOT NULL
-  AND bank_name IS NOT NULL AND bank_name <> ''
-  AND bank_account_no IS NOT NULL AND bank_account_no <> ''
-  AND bank_account_name IS NOT NULL AND bank_account_name <> '';
-```
-
-### 031 平台端菜单(foodie-admin-vue 支付方式设置页)
-
-```sql
--- 支付方式设置菜单(挂"系统管理"下)+ 页面权限
-SET @payCfgParent = (SELECT menu_id FROM sys_menu WHERE menu_name = '系统管理' AND parent_id = 0 LIMIT 1);
-INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, menu_type, visible, status, perms, icon, create_by, create_time, remark)
-VALUES ('支付方式设置', @payCfgParent, 8, 'payMethodConfig', 'pay/methodConfig/index', 'C', '0', '0', 'pay:method:config', 'money', 'admin', NOW(), '031 平台支付方式开关(方式x维度)');
-```