生产更新:同步已提交的海外仓分析及业务修复(2026-10-16) #27

Merged
admin merged 138 commits from test into main 2026-10-16 18:02:59 +08:00
84 changed files with 4879 additions and 136 deletions
Showing only changes of commit 30f48bd926 - Show all commits
+4 -2
View File
@@ -38,12 +38,14 @@
## 唯一入口与区域边界
V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving.sql` 提供四表:独立收货预约、当前/历史客户成员、待确认变化和不可变覆盖版本;同步 Lastleg 与海外空库基线。有效外部约号唯一性归收货预约的目的仓与约号,批次头表不再独立占用约号。部署前必须核对四表及旧头表唯一索引;本批只准备基线,不提供测试存量回填或自动重置。旧 `reset-test-fba-appointments.sql` 已拒绝存在这些新表的环境,不能用于重放 V13 初始化。实际 SQL 与部署须逐项授权。
两个统一 SQL 文件仍只用于空库初始化,名称中的“增量”不表示可向已有业务库重放。已有生产库的受控结构补齐单独放在 `migrations/`,只允许明确审阅过的增量变更;不提供存量数据回填、清理或兼容入口。
| 区域 | 完整入口 | 服务 schema | 表数 |
|---|---|---|---:|
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 227 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 208 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 213 |
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
- 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。
@@ -65,7 +67,7 @@
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/199`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=227/213`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
## 平台管理员密码
+3 -3
View File
@@ -10,7 +10,8 @@ CREATE PROCEDURE assert_test_appointment_reset_preconditions()
BEGIN
IF EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE()
AND table_name IN ('ll_fba_appointment_command','ll_appointment_cargo_verification',
'll_appointment_work','ll_dispatch_appointment_allocation')) THEN
'll_appointment_work','ll_dispatch_appointment_allocation','ll_fba_receiving_agreement',
'll_fba_receiving_member','ll_fba_receiving_change','ll_fba_receiving_revision','ll_fba_receiving_plan')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Stop: appointment history or work/allocation schema already exists; inspect before any reset';
END IF;
IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE()
@@ -49,7 +50,6 @@ CREATE TABLE ll_inbound_fba_appointment (
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '更新UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_fba_appointment_no (appointment_no),
UNIQUE KEY uk_ll_fba_external_confirmation (fba_warehouse_code,confirmation_no),
KEY idx_ll_fba_appointment_list (appointment_at,appointment_status,fba_warehouse_code),
KEY idx_ll_fba_appointment_planned (planned_at,appointment_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='FBA预约安排与正式确认唯一主记录';
@@ -66,7 +66,7 @@ CREATE TABLE ll_inbound_fba_appointment_item (
package_count INT NULL COMMENT '本批分配箱数,未知保持空;同一原来源总量守恒',
cargo_verification_version INT NULL COMMENT '采用的预约货物核实版本,未核实时为空',
shipment_references_json LONGTEXT NULL COMMENT '本次采用的货件或追踪号核实快照',
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '取消或失败后释放货源占用',
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前批次数量分配有效;取消、未约成和完成均保留,仅替代时释放',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_fba_item_source (appointment_id,source_key),
+53 -3
View File
@@ -849,6 +849,57 @@ CREATE TABLE ll_finance_fee_source (
CONSTRAINT chk_ll_finance_fee_status CHECK ((source_status in (_utf8mb4'ACTIVE',_utf8mb4'SUPERSEDED',_utf8mb4'VOIDED')))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务不可变费用来源版本;发布后经跨区事件进入Finance';
-- V13 receiving coverage and pending changes; baseline only, no test data migration.
CREATE TABLE IF NOT EXISTS ll_fba_receiving_agreement (
agreement_key VARCHAR(64) NOT NULL COMMENT '收货预约独立业务键',
version INT NOT NULL COMMENT '预约覆盖范围与确认资料版本',
status VARCHAR(24) NOT NULL COMMENT '有效、取消或已替代状态',
destination_key VARCHAR(128) NOT NULL COMMENT '权威目的仓关联键',
confirmation_no VARCHAR(128) NULL COMMENT '当前有效外部约号,失效后清空以释放覆盖',
snapshot_json LONGTEXT NOT NULL COMMENT '客户逐来源覆盖、确认时区及仓库依据快照',
PRIMARY KEY (agreement_key),
UNIQUE KEY uk_fba_receiving_confirmation (destination_key, confirmation_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='独立于送货批次的收货预约';
CREATE TABLE IF NOT EXISTS ll_fba_receiving_member (
agreement_key VARCHAR(64) NOT NULL COMMENT '所属收货预约业务键',
batch_key VARCHAR(128) NOT NULL COMMENT '稳定送货批次键',
customer_id VARCHAR(128) NOT NULL COMMENT '货物所属客户标识',
active_ref VARCHAR(260) NULL COMMENT '有效批次与客户唯一引用,退出后清空且历史保留',
PRIMARY KEY (agreement_key,batch_key,customer_id),
UNIQUE KEY uk_fba_receiving_active_member (active_ref),
KEY idx_fba_receiving_batch (batch_key,customer_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收货预约当前覆盖关联及退出历史';
CREATE TABLE IF NOT EXISTS ll_fba_receiving_change (
change_key VARCHAR(64) NOT NULL COMMENT '客户预约变化业务键',
version INT NOT NULL COMMENT '变化续办版本',
status VARCHAR(24) NOT NULL COMMENT '待仓库确认、已应用或已撤回状态',
batch_key VARCHAR(128) NOT NULL COMMENT '受影响原送货批次键',
customer_id VARCHAR(128) NOT NULL COMMENT '本次变化所属客户',
snapshot_json LONGTEXT NOT NULL COMMENT '原覆盖快照、逐来源拟保留量、负责人及跟进资料',
result_json LONGTEXT NULL COMMENT '确认后的实际批次去向及逐步骤结果',
active_ref VARCHAR(260) NULL COMMENT '同批同客户唯一未解决变化引用',
PRIMARY KEY (change_key),
UNIQUE KEY uk_fba_receiving_pending (active_ref),
KEY idx_fba_receiving_pending_batch (batch_key,status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='保留原安排且阻止误发车的客户变化';
CREATE TABLE IF NOT EXISTS ll_fba_receiving_revision (
agreement_key VARCHAR(64) NOT NULL COMMENT '收货预约业务键',
version INT NOT NULL COMMENT '不可变覆盖与确认版本',
snapshot_json LONGTEXT NOT NULL COMMENT '该版本完整客户覆盖、确认时段及回复依据',
PRIMARY KEY (agreement_key,version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收货预约覆盖与确认不可变历史';
CREATE TABLE IF NOT EXISTS ll_fba_receiving_plan (
batch_key VARCHAR(128) NOT NULL COMMENT '稳定送货批次键',
customer_id VARCHAR(128) NOT NULL COMMENT '计划所属客户标识',
snapshot_json LONGTEXT NOT NULL COMMENT '本客户未确认计划、目标窗口及业务时区,不含有效预约确认',
PRIMARY KEY (batch_key,customer_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='按批次及客户保存的未确认收货计划';
CREATE TABLE ll_inbound_fba_appointment (
id BIGINT NOT NULL COMMENT '主键ID',
appointment_no VARCHAR(64) NOT NULL COMMENT '系统预约业务单号,不是外部约号',
@@ -875,7 +926,6 @@ CREATE TABLE ll_inbound_fba_appointment (
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '更新UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_fba_appointment_no (appointment_no),
UNIQUE KEY uk_ll_fba_external_confirmation (fba_warehouse_code,confirmation_no),
KEY idx_ll_fba_appointment_list (appointment_at,appointment_status,fba_warehouse_code),
KEY idx_ll_fba_appointment_planned (planned_at,appointment_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='FBA预约安排与正式确认唯一主记录';
@@ -892,7 +942,7 @@ CREATE TABLE ll_inbound_fba_appointment_item (
package_count INT NULL COMMENT '本批分配箱数,未知保持空;同一原来源总量守恒',
cargo_verification_version INT NULL COMMENT '采用的预约货物核实版本,未核实时为空',
shipment_references_json LONGTEXT NULL COMMENT '本次采用的货件或追踪号核实快照',
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '取消或失败后释放货源占用',
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前批次数量分配有效;取消、未约成和完成均保留,仅替代时释放',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_fba_item_source (appointment_id,source_key),
@@ -4564,4 +4614,4 @@ CREATE TABLE ll_appointment_work_history (
KEY idx_appointment_work_history (work_item_key,occurred_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 208 AS initialized_tables;
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 213 AS initialized_tables;
@@ -63,7 +63,9 @@ public class AppointmentWorkCommandService {
fields(payload,Set.of("reviewVersion","note"),Set.of("reviewVersion","note"));
int version=integer(payload,"reviewVersion");
if(!state.isReviewRequired() || state.getReviewVersion()!=version) throw conflict("影响版本已变化,请重新核对");
if(changed(state,fact)) throw conflict("原业务已有更新,须先同步最新影响");
if(changed(state,fact) || fact!=null && state.getObservedImpactToken()!=null
&& !Objects.equals(state.getObservedImpactToken(),fact.impactToken())) throw conflict("原业务已有更新,须先同步最新影响");
requireCurrentImpacts(state);
if(fact!=null && !fact.blockReasons().isEmpty()) throw conflict("原业务仍有阻断,请先处理原安排");
summary=text(payload,"note",true,1000); state.setReviewRequired(false); state.setReviewReason(null);
}
@@ -114,6 +116,20 @@ public class AppointmentWorkCommandService {
store.history(state,actor,request.command().name(),summary,request.idempotencyKey(),hash,result);
return result;
}
private void requireCurrentImpacts(AppointmentWorkState state) {
store.requireInitialized();
var prerequisites=AppointmentWorkSyncService.impactPrerequisites(state,store.all());
if(prerequisites.isEmpty()) return;
var current=gateway.facts(prerequisites);
for(var prior:prerequisites) {
if(prior.getType()==Type.ROUTE) continue;
var fact=current.get(prior.getWorkItemKey());
if(fact==null) throw unavailable("关联安排权威事实缺失,不能完成复核");
if(prior.isObservedArranged() && prior.getObservedDependentImpactToken()!=null
&& !Objects.equals(prior.getObservedDependentImpactToken(),fact.dependentImpactToken()))
throw conflict("关联安排已有新变化,须先同步最新影响再复核");
}
}
private List<String> markDownstream(AppointmentWorkState source,String reason,Actor actor) {
var states=store.all(); var keys=new LinkedHashSet<String>(); AppointmentWorkSyncService.expandDependents(source.getWorkItemKey(),states,keys); keys.remove(source.getWorkItemKey());
var candidates=states.stream().filter(s->keys.contains(s.getWorkItemKey()) && !s.isRetired()).toList(); var facts=gateway.facts(candidates); var affected=new ArrayList<String>();
@@ -33,6 +33,10 @@ public class AppointmentWorkController {
@RequestParam(required=false) String containerBookingId,@RequestParam(required=false) Bucket bucket,@RequestParam(required=false) String keyword) {
return ApiResponse.success(queries.items(view,page,size,date,flow,types,containerBookingId,bucket,keyword,actor(user)));
}
@GetMapping("/items/resolve")
public ApiResponse<BatchWorkResolution> resolve(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String batchKey,@RequestParam(required=false) String customerId) {
return ApiResponse.success(queries.resolveBatch(batchKey,customerId,actor(user)));
}
@GetMapping("/items/{workItemKey}")
public ApiResponse<WorkDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String workItemKey) {
return ApiResponse.success(queries.detail(workItemKey,actor(user)));
@@ -36,7 +36,7 @@ public class AppointmentWorkDirectTransportProvider implements BusinessProvider
done?ExecutionStatus.COMPLETED:carrier.executionStatus()==ExecutionStatus.COMPLETED?ExecutionStatus.IN_PROGRESS:ExecutionStatus.PENDING,
carrier.target(),carrier.confirmed(),done?delivery.actual():null,done?delivery.timezone():carrier.timezone(),delivery.businessNo(),null,
List.copyOf(read),action==null?List.of():carrier.executionStatus()==ExecutionStatus.COMPLETED?delivery.actionPermissions():carrier.actionPermissions(),action,
done?List.of():carrier.executionStatus()==ExecutionStatus.COMPLETED?delivery.blockReasons():carrier.blockReasons(),List.copyOf(missing),List.copyOf(refs),carrier.responsibility());
done?List.of():carrier.executionStatus()==ExecutionStatus.COMPLETED?delivery.blockReasons():carrier.blockReasons(),List.copyOf(missing),List.copyOf(refs),carrier.responsibility(),null);
}).toList();
}
private FactRequest request(FactRequest r,Type type,String suffix) { return new FactRequest(r.workItemKey()+suffix,type,r.flow(),r.containerBookingId(),r.requirementKey(),r.batchKey(),null,r.batchKeys(),r.containers(),r.sources()); }
@@ -8,9 +8,15 @@ public interface AppointmentWorkDispatchPort {
record Allocation(@NotNull Long planPalletId,@NotBlank String batchKey,@NotBlank String customerId,
@NotBlank String sourceKey,@NotBlank String destinationKey,@Positive int packageCount) { }
record PhysicalItem(Long itemId,Long planPalletId,String sourceStage,Integer packages) { }
record BatchSource(String sourceKey,String destinationKey,int packageCount) { }
record BatchReference(String batchKey,String batchNo,String customerId,List<BatchSource> sources) { }
/** Current original allocations, grouped for authorized dispatches; historical copies are read-only evidence. */
java.util.Map<Long,List<BatchReference>> references(List<Long> dispatchIds);
void bind(String customerId,long dispatchId,List<PhysicalItem> items,List<Allocation> allocations);
void requireReplay(String customerId,long dispatchId,List<Allocation> allocations);
void requireDeparture(String dispatchNo);
/** Read-only explanation; departure must repeat the same authority checks while holding batch locks. */
String departureBlockReason(String dispatchNo);
void removeItem(long dispatchId,long itemId);
void requireTripBinding(String dispatchNo,long tripId);
}
@@ -77,12 +77,16 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway {
}
static void append(Map<String,Fact> target,List<FactRequest> requests,List<Fact> facts) {
if(facts==null || facts.size()!=requests.size()) throw unavailable("工作事实响应不完整");
var expected=new HashSet<>(requests.stream().map(FactRequest::workItemKey).toList());
var requestByKey=new HashMap<String,FactRequest>();requests.forEach(r->requestByKey.put(r.workItemKey(),r));
var expected=new HashSet<>(requestByKey.keySet());
for(var fact:facts) {
if(fact==null || !expected.remove(fact.workItemKey()) || fact.fingerprint()==null || fact.fingerprint().isBlank() || fact.impactToken()==null || fact.impactToken().isBlank() || fact.planStatus()==null || fact.executionStatus()==null
|| fact.readPermissions()==null || fact.readPermissions().isEmpty() || fact.actionPermissions()==null || fact.blockReasons()==null || fact.missingFields()==null || fact.references()==null)
throw unavailable("工作事实缺少身份、版本或资格");
if(fact.executionStatus()==ExecutionStatus.COMPLETED && fact.actual()==null) throw unavailable("完成事实缺少实际时间");
var request=requestByKey.get(fact.workItemKey());
if(request.type()==Type.TRANSPORT && request.flow()==Flow.TRANSFER && (fact.transportPlanImpactToken()==null || fact.transportPlanImpactToken().isBlank()))
throw unavailable("共享车次事实缺少独立计划影响版本");
if(fact.actual()!=null) validateActual(fact.actual());
if(!fact.planExists() && (fact.target()!=null || fact.confirmed()!=null || fact.planStatus()==PlanStatus.CONFIRMED)) throw unavailable("原计划存在性与计划结果冲突");
validateSourceSlot(fact.target()); validateSourceSlot(fact.confirmed());
@@ -20,8 +20,10 @@ public interface AppointmentWorkFactGateway {
ExecutionStatus executionStatus, Slot target, Slot confirmed, Actual actual, String timezone,
String businessNo, String tripKey, List<String> readPermissions, List<String> actionPermissions,
Action action, List<String> blockReasons, List<String> missingFields, List<BusinessReference> references,
Responsibility responsibility) {
public boolean arranged() { return planStatus == PlanStatus.CONFIRMED || planStatus == PlanStatus.NOT_REQUIRED || target != null; }
Responsibility responsibility, String transportPlanImpactToken) {
/** Shared fleet transport separates its own cargo impact from changes affecting receiving appointments. */
public String dependentImpactToken() { return transportPlanImpactToken==null?impactToken:transportPlanImpactToken; }
public boolean arranged() { return planStatus == PlanStatus.CONFIRMED || planStatus == PlanStatus.NOT_REQUIRED || target != null || confirmed != null; }
}
SourcePage discover(String after, int size);
ContainerSource container(String containerBookingId);
@@ -0,0 +1,72 @@
package com.baiyue.lastleg.appointmentwork;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
/** A future work key may receive an audited follow-up seed before source discovery creates its work row. */
@Component
public class AppointmentWorkFollowupInheritance {
public record Member(String batchKey,String customerId) { }
private final AppointmentWorkStore store;
private final AppointmentWorkFactGateway.OwnerDirectory directory;
public AppointmentWorkFollowupInheritance(AppointmentWorkStore store,AppointmentWorkFactGateway.OwnerDirectory directory) {
this.store=store;this.directory=directory;
}
public AppointmentWorkStore.Followup resolve(String ownerId,LocalDate nextFollowupDate) {
if(ownerId==null || ownerId.isBlank()) throw bad("请选择有效平台负责人");
var owner=directory.activePlatformOwner(ownerId);
if(owner==null || !ownerId.equals(owner.id()) || owner.name()==null || owner.name().isBlank()) throw bad("负责人不是有效平台操作人员");
return new AppointmentWorkStore.Followup(owner,nextFollowupDate);
}
@Transactional(propagation=Propagation.MANDATORY)
public void seed(Member original,Member later,AppointmentWorkStore.Followup followup,String operatorId) {
key(original);key(later);
if(!original.customerId().equals(later.customerId()) || original.batchKey().equals(later.batchKey())) throw bad("跟进继承必须来自同一客户的原批次和新批次");
if(followup==null || followup.owner()==null || followup.owner().id()==null || followup.owner().id().isBlank() || followup.owner().name()==null || followup.owner().name().isBlank()) throw bad("后送跟进缺少真实负责人");
store.lock();write(later,followup,operatorId);
}
@Transactional(propagation=Propagation.MANDATORY)
public void inherit(List<Member> originals,List<Member> successors,String operatorId) {
validate(originals);validate(successors);store.lock();
var values=new HashMap<String,Set<AppointmentWorkStore.Followup>>();
for(var original:originals) {
var key=key(original);var state=store.find(key);
var current=state==null?store.inheritedFollowup(key):new AppointmentWorkStore.Followup(state.getOwner(),state.getFollowupDate());
if(current!=null) values.computeIfAbsent(original.customerId(),ignored->new HashSet<>()).add(current);
}
// Validate every target before writing; caller transaction still supplies rollback for all original-domain writes.
for(var successor:successors) if(values.getOrDefault(successor.customerId(),Set.of()).size()>1)
throw conflict("合批来源的负责人或跟进日期不同,请先在原工作事项统一后再合批");
for(var successor:successors) {
var current=values.getOrDefault(successor.customerId(),Set.of());
if(!current.isEmpty()) write(successor,current.iterator().next(),operatorId);
}
}
private void write(Member target,AppointmentWorkStore.Followup followup,String operatorId) {
if(operatorId==null || operatorId.isBlank()) throw bad("跟进继承缺少真实操作人");
var key=key(target);var state=store.find(key);var previous=store.inheritedFollowup(key);
if(state!=null) {
if(Objects.equals(state.getOwner(),followup.owner()) && Objects.equals(state.getFollowupDate(),followup.nextFollowupDate())) return;
throw conflict("目标工作已存在独立跟进,不能用继承覆盖");
}
if(previous!=null) {
if(previous.equals(followup)) return;
throw conflict("目标批次已有不同的跟进继承记录");
}
var future=new AppointmentWorkState();future.setWorkItemKey(key);
store.history(future,new Actor(operatorId,null,Set.of()),"FOLLOWUP_INHERITED","继承原送仓事项的负责人及跟进日期",null,null,followup);
}
private static String key(Member member) {
if(member==null || member.batchKey()==null || !member.batchKey().matches("FBA_APPOINTMENT:[1-9][0-9]*") || member.customerId()==null || member.customerId().isBlank()) throw bad("跟进继承缺少真实批次客户范围");
return AppointmentWorkPolicy.key("BATCH:"+member.batchKey(),Type.DELIVERY,customerRequirement(member.batchKey(),member.customerId()));
}
private static void validate(List<Member> members) {
if(members==null || members.isEmpty() || members.size()>2000 || new HashSet<>(members).size()!=members.size()) throw bad("跟进继承范围须为1至2000个不重复客户批次");
members.forEach(AppointmentWorkFollowupInheritance::key);
}
}
@@ -66,4 +66,6 @@ public interface AppointmentWorkMapper {
VALUES(#{id},#{workItemKey},#{operatorId},#{operatorName},#{action},#{summary},#{commandKey},#{requestHash},#{responseJson},#{occurredAt})
""") int insertHistory(History row);
@Select("SELECT * FROM ll_appointment_work_history WHERE work_item_key=#{key} ORDER BY occurred_at DESC,id DESC LIMIT 1001") List<History> selectHistory(@Param("key") String key);
@Select("SELECT * FROM ll_appointment_work_history WHERE work_item_key=#{key} AND action='FOLLOWUP_INHERITED' ORDER BY occurred_at DESC,id DESC LIMIT 1")
History inheritedFollowup(String key);
}
@@ -51,6 +51,8 @@ public final class AppointmentWorkModels {
public record HistoryEntry(Instant occurredAt, String operatorName, String action, String summary) { }
public record WorkDetail(WorkItem item, List<WorkItem> relatedItems,
List<BusinessReference> references, List<HistoryEntry> history, Review review, RequirementDetails requirement, ReturnHandoff returnHandoff) { }
public record BatchWorkCandidate(String customerId,String workItemKey,String batchNo) { }
public record BatchWorkResolution(String batchKey,String customerId,WorkDetail detail,List<BatchWorkCandidate> candidates) { }
public record Review(int version,List<String> reasons) { }
public record RequirementDetails(boolean bookingRequired,String receivingRequirements,String reason) { }
public enum ResponsibilityKind { USER, SUPPLIER, EXTERNAL }
@@ -14,6 +14,15 @@ public final class AppointmentWorkPolicy {
public static final ZoneId REMINDER_ZONE=ZoneId.of("America/Los_Angeles");
public static final String ORDER_READ="platform:overseas-warehouse:last-mile-order:list";
private AppointmentWorkPolicy() { }
public static String customerRequirement(String batchKey,String customerId) {
if(batchKey==null || customerId==null || customerId.isBlank()) throw unavailable("送仓需求缺少批次或客户身份");
return batchKey+"|CUSTOMER:"+customerId;
}
public static String deliveryCustomer(List<SourceReference> sources) {
var customers=sources.stream().map(SourceReference::customerId).distinct().toList();
if(customers.size()!=1 || customers.get(0)==null || customers.get(0).isBlank()) throw unavailable("送仓事项须明确唯一客户货物范围");
return customers.get(0);
}
public static String key(String containerId,Type type,String requirementKey) {
return "AW-"+type.name()+"-"+digest(containerId+"\n"+Objects.toString(requirementKey,"")).substring(0,32);
}
@@ -89,6 +98,7 @@ public final class AppointmentWorkPolicy {
var today=LocalDate.now(clock.withZone(timezone==null?REMINDER_ZONE:zone(timezone)));
if(timezone==null) missing.add("跟进日期按业务默认时区America/Los_Angeles判断;作业时区待核实");
if(state.getOwner()==null && execution!=ExecutionStatus.COMPLETED) missing.add("负责人待分配");
if(state.getFollowupDate()==null && execution!=ExecutionStatus.COMPLETED && !state.isRetired()) missing.add("下次跟进日期待安排");
var overdue=new ArrayList<String>();
if(execution!=ExecutionStatus.COMPLETED && !state.isRetired()) {
if(state.getFollowupDate()!=null && state.getFollowupDate().isBefore(today)) overdue.add("跟进日期已过");
@@ -49,7 +49,21 @@ public class AppointmentWorkQueryService {
.map(i->includeNext?withNext(i,next(i,byKey,stateByKey,new HashSet<>())):i).toList());
}
private Action next(WorkItem item,Map<String,WorkItem> items,Map<String,AppointmentWorkState> states,Set<String> visited) {
if(!visited.add(item.workItemKey()) || item.blockReasons().isEmpty() || item.reviewRequired()) return item.nextAction();
if(!visited.add(item.workItemKey()) || item.reviewRequired()) return item.nextAction();
if(item.type()==Type.DELIVERY && item.executionStatus()!=ExecutionStatus.COMPLETED && item.blockReasons().isEmpty()
&& (item.planStatus()==PlanStatus.CONFIRMED || item.planStatus()==PlanStatus.NOT_REQUIRED)) {
for(var key:effectiveKeys(states.get(item.workItemKey()).getPrerequisites(),states)) {
var transport=items.get(key);
if(transport==null || transport.type()!=Type.TRANSPORT || transport.executionStatus()==ExecutionStatus.COMPLETED) continue;
var action=transport.nextAction();
if(action==null) continue;
if(action.code()==ActionCode.RESOLVE_REVIEW)
return new Action(action.code(),"先复核车辆安排",transport.workItemKey(),action.target());
if(action.code()==ActionCode.ARRANGE_TRANSPORT && action.target() instanceof TripTarget target)
return new Action(action.code(),target.tripId()==null?"继续安排本批车辆":"核对本车发车安排",transport.workItemKey(),target);
}
}
if(item.blockReasons().isEmpty()) return item.nextAction();
for(var key:effectiveKeys(states.get(item.workItemKey()).getPrerequisites(),states)) {
var prior=items.get(key);
if(prior!=null && prior.executionStatus()!=ExecutionStatus.COMPLETED && (prior.reviewRequired() || prior.planStatus()==PlanStatus.UNPLANNED || prior.planStatus()==PlanStatus.AWAITING_REPLY)) {
@@ -87,6 +101,22 @@ public class AppointmentWorkQueryService {
long offset=(long)(page-1)*size; int start=(int)Math.min(offset,items.size());
return new PageResult<>(items.subList(start,Math.min(start+size,items.size())),page,size,items.size(),(items.size()+size-1L)/size);
}
public BatchWorkResolution resolveBatch(String batchKey,String customerId,Actor actor) {
if(batchKey==null || !batchKey.matches("(?:FBA_APPOINTMENT|DISPATCH):[1-9][0-9]*")) throw bad("请提供原业务返回的完整批次键");
if(customerId!=null && customerId.isBlank()) throw bad("客户身份不能为空");
if(!Objects.equals(store.requireInitialized(),facts.discover(null,1).scopeVersion())) throw conflict("已开船工作范围变化,请先完成服务端同步");
var matches=store.all().stream().filter(s->!s.isRetired() && s.getType()==Type.DELIVERY && batchKey.equals(s.getBatchKey()))
.filter(s->customerId==null || customerId.equals(deliveryCustomer(s.getSources()))).toList();
if(matches.isEmpty()) throw missing("当前批次的送仓事项尚不存在,请先同步当前批次");
var current=facts.facts(matches);
var visible=matches.stream().filter(s->canRead(s,current.get(s.getWorkItemKey()),actor)).toList();
if(visible.isEmpty()) throw forbidden("无权读取关联来源");
var candidates=visible.stream().map(s->new BatchWorkCandidate(deliveryCustomer(s.getSources()),s.getWorkItemKey(),s.getBatchNo())).sorted(Comparator.comparing(BatchWorkCandidate::customerId)).toList();
if(candidates.stream().map(BatchWorkCandidate::customerId).distinct().count()!=candidates.size()) throw conflict("同一批次客户存在重复送仓事项");
if(candidates.size()>1) return new BatchWorkResolution(batchKey,null,null,candidates);
var selected=candidates.get(0);
return new BatchWorkResolution(batchKey,selected.customerId(),detail(selected.workItemKey(),actor),candidates);
}
public WorkDetail detail(String key,Actor actor) {
var snapshot=snapshot(actor); var state=store.get(key);
var fact=snapshot.facts().get(key);
@@ -36,6 +36,7 @@ public class AppointmentWorkState {
private String reviewReason;
private String observedFingerprint;
private String observedImpactToken;
private String observedDependentImpactToken;
private boolean observedArranged;
private Responsibility responsibility;
private ReturnHandoff returnHandoff;
@@ -32,6 +32,11 @@ public class AppointmentWorkStore {
public AppointmentWorkState get(String key) {
var row=mapper.get(key); if(row==null) throw missing("事项不存在"); return decode(row);
}
public AppointmentWorkState find(String key) { var row=mapper.get(key);return row==null?null:decode(row); }
public record Followup(Owner owner,LocalDate nextFollowupDate) { }
public Followup inheritedFollowup(String key) {
var row=mapper.inheritedFollowup(key);return row==null?null:read(row.getResponseJson(),Followup.class);
}
public void insert(AppointmentWorkState state) { mapper.insert(row(state)); }
public void save(AppointmentWorkState state) {
state.setVersion(state.getVersion()+1);
@@ -69,7 +69,10 @@ public class AppointmentWorkSyncService {
for(var requirement:source.requirements()) {
if(requirement==null || requirement.requirementKey()==null || requirement.requirementKey().isBlank() || !seen.add(requirement.requirementKey()) || requirement.references()==null || requirement.replacesRequirementKeys()==null || requirement.sources()==null)
throw unavailable("送货需求缺少稳定身份或重复");
if(requirement.batchKey()!=null && !batches.add(requirement.batchKey())) throw unavailable("同柜批次需求重复,必须由原域归并来源");
if(requirement.batchKey()!=null) {
var identity=requirement.batchKey().startsWith("FBA_APPOINTMENT:")?customerRequirement(requirement.batchKey(),deliveryCustomer(requirement.sources())):requirement.batchKey();
if(!batches.add(identity)) throw unavailable("同柜批次客户需求重复,必须由原域归并来源");
}
if(requirement.timezone()!=null) zone(requirement.timezone());
}
}
@@ -91,10 +94,17 @@ public class AppointmentWorkSyncService {
for(var state:created) {
state.setOwner(actor.id().equals(SYSTEM.id())?null:new Owner(actor.id(),actor.name()));
state.setFollowupDate(LocalDate.now(state.getTimezone()==null?REMINDER_ZONE:zone(state.getTimezone())));
var inherited=store.inheritedFollowup(state.getWorkItemKey());
if(inherited!=null) { state.setOwner(inherited.owner());state.setFollowupDate(inherited.nextFollowupDate()); }
String pickup=key(source.containerBookingId(),Type.PICKUP,null),receipt=key(source.containerBookingId(),Type.WAREHOUSE_RECEIPT,null);
if(state.getType()==Type.WAREHOUSE_RECEIPT) state.setPrerequisites(List.of(pickup));
if(state.getType()==Type.DELIVERY) state.setPrerequisites(List.of(flow==Flow.TRANSFER?receipt:pickup,requirementKey(source.containerBookingId(),Type.TRANSPORT,state.getRequirementKey(),state.getBatchKey())));
if(state.getType()==Type.TRANSPORT) state.setPrerequisites(List.of(flow==Flow.TRANSFER?receipt:pickup,requirementKey(source.containerBookingId(),Type.DELIVERY,state.getRequirementKey(),state.getBatchKey())));
if(state.getType()==Type.TRANSPORT) {
var prerequisites=new LinkedHashSet<String>();prerequisites.add(flow==Flow.TRANSFER?receipt:pickup);
for(var r:requirements) if(Objects.equals(state.getBatchKey(),r.batchKey()) && (r.batchKey()!=null || Objects.equals(state.getRequirementKey(),r.requirementKey())))
prerequisites.add(requirementKey(source.containerBookingId(),Type.DELIVERY,r.requirementKey(),r.batchKey()));
state.setPrerequisites(List.copyOf(prerequisites));
}
if(state.getType()==Type.EMPTY_RETURN) {
String containerId=source.containerBookingId();
state.setPrerequisites(flow==Flow.TRANSFER?List.of(receipt):source.requirements().stream().map(r->requirementKey(containerId,Type.DELIVERY,r.requirementKey(),r.batchKey())).toList());
@@ -102,6 +112,18 @@ public class AppointmentWorkSyncService {
store.insert(state); store.history(state,actor,"GENERATED","根据已确认路线建立工作需求",null,null,null);
}
String generationContainerId=source.containerBookingId();
// Rebuild each batch vehicle's customer edges from the complete current manifest.
// A shared vehicle retains every customer delivery; no last customer wins the edge.
for(var batch:requirements.stream().map(Requirement::batchKey).filter(Objects::nonNull).distinct().toList()) {
var vehicle=known.get(requirementKey(generationContainerId,Type.TRANSPORT,null,batch));
var dependencies=new LinkedHashSet<>(vehicle.getPrerequisites());
dependencies.removeIf(k->{var s=known.get(k);return s!=null && s.getType()==Type.DELIVERY && batch.equals(s.getBatchKey());});
requirements.stream().filter(r->batch.equals(r.batchKey())).map(r->requirementKey(generationContainerId,Type.DELIVERY,r.requirementKey(),batch)).forEach(dependencies::add);
var sources=requirements.stream().filter(r->batch.equals(r.batchKey())).flatMap(r->r.sources().stream()).distinct().toList();
if(!vehicle.getPrerequisites().equals(List.copyOf(dependencies)) || !vehicle.getSources().equals(sources)) {
vehicle.setPrerequisites(List.copyOf(dependencies));vehicle.setSources(sources);store.save(vehicle);
}
}
var activeKeys=new HashSet<String>(); requirements.forEach(r->{ activeKeys.add(requirementKey(generationContainerId,Type.DELIVERY,r.requirementKey(),r.batchKey())); activeKeys.add(requirementKey(generationContainerId,Type.TRANSPORT,r.requirementKey(),r.batchKey())); });
for(var old:existing) if(old.getRequirementKey()!=null && !"BATCH_REPLACED".equals(old.getRetirementReason()) && !activeKeys.contains(old.getWorkItemKey())) {
String containerId=source.containerBookingId();
@@ -116,6 +138,7 @@ public class AppointmentWorkSyncService {
store.save(old);store.history(old,actor,"SOURCE_REMOVED","原排车已移除该柜实物,保留其他柜的当前关联",null,null,null);continue;
}
var replacements=requirements.stream().filter(r->r.replacesRequirementKeys().contains(old.getRequirementKey()))
.filter(r->old.getType()!=Type.DELIVERY || old.getSources().isEmpty() || r.sources().isEmpty() || r.sources().stream().anyMatch(s->old.getSources().stream().anyMatch(o->Objects.equals(o.customerId(),s.customerId()))))
.map(r->requirementKey(containerId,old.getType(),r.requirementKey(),r.batchKey())).distinct().toList();
// Removed dispatch items no longer exist in the original allocation table. Its full current
// container manifest restores the unallocated demand or names the newly assigned dispatch.
@@ -126,7 +149,9 @@ public class AppointmentWorkSyncService {
}
return created.stream().map(AppointmentWorkState::getWorkItemKey).toList();
}
private static String requirementKey(String container,Type type,String requirement,String batch) { return batch==null?key(container,type,requirement):key("BATCH:"+batch,type,null); }
private static String requirementKey(String container,Type type,String requirement,String batch) {
return batch==null?key(container,type,requirement):key("BATCH:"+batch,type,type==Type.DELIVERY && batch.startsWith("FBA_APPOINTMENT:")?requirement:null);
}
private void add(ContainerSource source,Type type,Requirement requirement,Flow flow,Map<String,AppointmentWorkState> known,List<AppointmentWorkState> created) {
var state=create(source,type,requirement,flow); var old=known.get(state.getWorkItemKey());
if(old==null) { created.add(state); known.put(state.getWorkItemKey(),state); }
@@ -147,14 +172,14 @@ public class AppointmentWorkSyncService {
}
old.setBatchNo(requirement.batchNo()); old.setOriginName(requirement.originName()); old.setDestinationName(requirement.destinationName()); old.setDestinationKind(requirement.destinationKind()); old.setReferences(requirement.references()); old.setSources(requirement.sources());
if(type==Type.DELIVERY) old.setTimezone(requirement.timezone());
if(update) store.save(old);
if(update && !created.contains(old)) store.save(old);
}
}
private AppointmentWorkState create(ContainerSource source,Type type,Requirement requirement,Flow flow) {
var state=new AppointmentWorkState(); state.setWorkItemKey(requirement==null?key(source.containerBookingId(),type,null):requirementKey(source.containerBookingId(),type,requirement.requirementKey(),requirement.batchKey()));
state.setType(type); state.setFlow(flow); state.setContainerBookingId(source.containerBookingId()); state.setContainerNo(source.containerNo()); state.setSourceVersion(source.sourceVersion());
state.setTimezone(type==Type.ROUTE?source.timezone():null); state.setContainers(List.of(new ContainerReference(source.containerBookingId(),null,source.containerNo())));
if(requirement!=null) { state.setRequirementKey(requirement.requirementKey()); state.setBatchKey(requirement.batchKey()); state.setBatchNo(requirement.batchNo()); state.setDestinationKind(requirement.destinationKind());
if(requirement!=null) { state.setRequirementKey(type==Type.TRANSPORT && requirement.batchKey()!=null?requirement.batchKey():requirement.requirementKey()); state.setBatchKey(requirement.batchKey()); state.setBatchNo(requirement.batchNo()); state.setDestinationKind(requirement.destinationKind());
state.setOriginName(requirement.originName()); state.setDestinationName(requirement.destinationName()); state.setTimezone(type==Type.DELIVERY?requirement.timezone():null); state.setReferences(requirement.references()); state.setSources(requirement.sources());
if(requirement.batchKey()!=null) state.setBatchKeys(List.of(requirement.batchKey())); }
return state;
@@ -166,17 +191,18 @@ public class AppointmentWorkSyncService {
var fact=facts.get(state.getWorkItemKey()); if(fact==null) continue;
if(fact!=null && state.getObservedImpactToken()!=null && !Objects.equals(state.getObservedImpactToken(),fact.impactToken()) && state.isObservedArranged()) {
affected.add(state.getWorkItemKey());
expandDependents(state.getWorkItemKey(),states,affected);
if(state.getObservedDependentImpactToken()!=null && !Objects.equals(state.getObservedDependentImpactToken(),fact.dependentImpactToken()))
expandDependents(state.getWorkItemKey(),states,affected);
}
}
for(var state:states) {
if(state.isRetired()) continue;
var fact=facts.get(state.getWorkItemKey()); if(fact==null) continue;
boolean changed=changed(state,fact) || !Objects.equals(state.getObservedImpactToken(),fact.impactToken()) || state.isObservedArranged()!=fact.arranged() || !state.getReadPermissions().equals(fact.readPermissions()),baseline=state.getObservedFingerprint()==null;
boolean changed=changed(state,fact) || !Objects.equals(state.getObservedImpactToken(),fact.impactToken()) || !Objects.equals(state.getObservedDependentImpactToken(),fact.dependentImpactToken()) || state.isObservedArranged()!=fact.arranged() || !state.getReadPermissions().equals(fact.readPermissions()),baseline=state.getObservedFingerprint()==null;
boolean review=affected.contains(state.getWorkItemKey()) && arranged(state,fact) && !completed(fact);
if(review) { state.setReviewRequired(true); state.setReviewVersion(state.getReviewVersion()+1); state.setReviewReason("原业务时间、货量或执行关联发生变化,请核对已有安排"); }
if(changed || baseline || review) {
state.setObservedFingerprint(fact.fingerprint()); state.setObservedImpactToken(fact.impactToken()); state.setObservedArranged(fact.arranged()); state.setReadPermissions(List.copyOf(fact.readPermissions())); store.save(state);
state.setObservedFingerprint(fact.fingerprint()); state.setObservedImpactToken(fact.impactToken()); state.setObservedDependentImpactToken(fact.dependentImpactToken()); state.setObservedArranged(fact.arranged()); state.setReadPermissions(List.copyOf(fact.readPermissions())); store.save(state);
store.history(state,actor,review?"IMPACT_OBSERVED":"BASELINE_OBSERVED",review?state.getReviewReason():"记录当前原业务基线,不生成执行事实",null,null,null);
}
}
@@ -185,9 +211,29 @@ public class AppointmentWorkSyncService {
var queue=new ArrayDeque<String>(); queue.add(key); var seen=new HashSet<String>();
var byKey=states.stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity()));
while(!queue.isEmpty()) { var current=queue.remove(); if(!seen.add(current)) continue;
// A changed customer batch requires its shared vehicle to be checked. That does not
// make the vehicle's other customers' appointments change. A change originating in
// the vehicle itself still reaches all of its current dependent appointments.
var currentState=byKey.get(current);
if(!current.equals(key) && currentState!=null && currentState.getType()==Type.TRANSPORT) continue;
for(var candidate:states) if(!candidate.isRetired() && effectiveKeys(candidate.getPrerequisites(),byKey).contains(current) && affected.add(candidate.getWorkItemKey())) queue.add(candidate.getWorkItemKey());
}
}
/** Reverse of the impact traversal: a shared vehicle is an authority, not a sibling bridge. */
static List<AppointmentWorkState> impactPrerequisites(AppointmentWorkState target,List<AppointmentWorkState> states) {
var byKey=states.stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity()));
var pending=new ArrayDeque<>(effectiveKeys(target.getPrerequisites(),byKey));
var seen=new HashSet<String>(); seen.add(target.getWorkItemKey());
var result=new ArrayList<AppointmentWorkState>();
while(!pending.isEmpty()) {
var key=pending.remove(); if(!seen.add(key)) continue;
var state=byKey.get(key);
if(state==null || state.isRetired()) throw conflict("关联安排已变化,须先同步当前工作关系");
result.add(state);
if(state.getType()!=Type.TRANSPORT) pending.addAll(effectiveKeys(state.getPrerequisites(),byKey));
}
return List.copyOf(result);
}
private void reconcileTransport(List<AppointmentWorkState> all,Map<String,Fact> facts) {
var members=all.stream().filter(s->s.getType()==Type.TRANSPORT && s.getTripKey()==null && facts.containsKey(s.getWorkItemKey()) && facts.get(s.getWorkItemKey()).tripKey()!=null)
.collect(Collectors.groupingBy(s->facts.get(s.getWorkItemKey()).tripKey()));
@@ -12,7 +12,8 @@ public record DispatchResponse(Long id, String dispatchNo, String vehiclePlate,
int itemCount, int palletCount, int packageCount, int pickedPackageCount,
int shippedPackageCount, int remainingPackageCount, String partialCompletionReason,
LocalDateTime createdAt,
List<Item> items, List<BolLine> bolLines, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow) {
List<Item> items, List<BolLine> bolLines, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow,
List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference> appointmentBatches) {
public record Item(Long id, Long planPalletId, Long warehousePalletId, String palletNo,
String overseasWarehouseId, String overseasWarehouseName,
String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode,
@@ -45,6 +45,8 @@ public class DispatchFleetAdapter implements FleetDispatchGateway {
private final DispatchItemMapper items;
private final DispatchPdaPickMapper picks;
private final DispatchPdaWorkflowService workflow;
@jakarta.annotation.Resource @org.springframework.context.annotation.Lazy
private com.baiyue.lastleg.dispatch.service.DispatchService dispatchService;
public DispatchFleetAdapter(DispatchOrderMapper orders, DispatchPdaTaskMapper tasks,
OutboundOrderMapper outbounds, DeliveryOrderMapper deliveries, DispatchItemMapper items,
@@ -54,6 +56,9 @@ public class DispatchFleetAdapter implements FleetDispatchGateway {
this.deliveryOperations = deliveryOperations;
}
@Override
public void removeItem(String dispatchNo,long itemId) { dispatchService.removeItem(dispatchNo,itemId); }
@Override
public Snapshot lockForPlanning(String dispatchNo) {
return lockBeforeDeparture(dispatchNo, false);
@@ -682,9 +682,10 @@ public class DispatchService {
.orderByDesc(DispatchOrder::getCreatedAt));
var workflows = acrossCustomers ? fleetWorkflow.read(page.getRecords().stream()
.map(DispatchOrder::getDispatchNo).toList()) : java.util.Map.<String, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow>of();
var appointmentReferences=appointmentAllocations.references(page.getRecords().stream().map(DispatchOrder::getId).toList());
List<DispatchResponse> records = page.getRecords().stream()
.map(order -> response(order, items.findByDispatchOrder(order.getCustomerId(), order.getId()),
List.of(), workflows.get(order.getDispatchNo()))).toList();
List.of(), workflows.get(order.getDispatchNo()),appointmentReferences.getOrDefault(order.getId(),List.of()))).toList();
return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
@@ -837,6 +838,11 @@ public class DispatchService {
private DispatchResponse response(DispatchOrder order, List<DispatchItem> itemList,
List<DispatchResponse.BolLine> bolLineList, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow) {
return response(order,itemList,bolLineList,workflow,appointmentAllocations.references(List.of(order.getId())).getOrDefault(order.getId(),List.of()));
}
private DispatchResponse response(DispatchOrder order, List<DispatchItem> itemList,
List<DispatchResponse.BolLine> bolLineList, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow,
List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference> appointmentBatches) {
String orderNos = itemList.stream().flatMap(item -> splitOrderRefs(item.getOrderRefs(), item.getOrderNo()).stream())
.distinct().collect(Collectors.joining(","));
return new DispatchResponse(order.getId(), order.getDispatchNo(), order.getVehiclePlate(), order.getDriverName(),
@@ -855,7 +861,7 @@ public class DispatchService {
item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(),
Objects.requireNonNullElse(item.getPackageCount(), 0), item.getGrossWeightKg(),
item.getVolumeWeightKg(), item.getVolumeCbm(), item.getSourceStage())).toList(),
bolLineList == null ? List.of() : bolLineList, workflow);
bolLineList == null ? List.of() : bolLineList, workflow,appointmentBatches);
}
private static String candidateRoute(List<DispatchPalletCandidate> selected) {
@@ -21,10 +21,25 @@ public interface FleetDispatchGateway {
Snapshot lockForPlanning(String dispatchNo);
/** Allows pre-departure resource/time changes without changing cargo or recorded picking. */
Snapshot lockForAssignment(String dispatchNo);
/** Original dispatch command; caller must first detach the dispatch from its vehicle. */
void removeItem(String dispatchNo,long itemId);
/** Caller holds the dispatch lock for the entire transaction, including downstream snapshots. */
void assign(Snapshot dispatch, Assignment assignment);
FleetExecutionModels.Manifest manifest(String dispatchNo, boolean lock);
FleetCompletionModels.DispatchCompletion completion(String dispatchNo, boolean lock);
record ReturnedReceipt(String itemId,String palletId,int packages,Instant receivedAt) { }
record TerminalDelivery(String customerId,String deliveryNo,String destinationKey,int packages,
Instant terminalAt,String status,long recordVersion,java.util.List<ReturnedReceipt> receipts) { }
record TerminalEvidence(String dispatchNo,String customerId,String warehouseId,int shippedPackages,
java.util.List<TerminalDelivery> deliveries) { }
/** Narrow execution facts for appointment eligibility, derived from the original verified evidence. */
default TerminalEvidence terminalEvidence(String dispatchNo,boolean lock) {
var evidence=transitEvidence(dispatchNo,lock);
return new TerminalEvidence(evidence.dispatchNo(),evidence.customerId(),evidence.warehouseId(),evidence.shippedPackages(),
evidence.deliveries().stream().map(d->new TerminalDelivery(d.cargo().customerId(),d.cargo().deliveryNo(),
d.cargo().destinationKey(),d.cargo().packages(),d.cargo().terminalAt(),d.status(),d.recordVersion(),
d.cargo().returnedCargo().stream().map(r->new ReturnedReceipt(r.itemId(),r.palletId(),r.packages(),r.receivedAt())).toList())).toList());
}
/** Actual departure/terminal evidence for an in-transit handover; does not replay departure. */
com.baiyue.lastleg.fleet.exception.FleetTransitEvidence transitEvidence(String dispatchNo, boolean lock);
/** Updates only unfinished delivery resource snapshots; departure and financial facts stay immutable. */
@@ -150,6 +150,10 @@ public class FleetTripExecutionService {
String warning = null;
if (reason == null) reason = loadBlockReason(trip, cargo(manifest));
if (reason == null) reason = loadingReviewBlockReason(trip);
if (reason == null) for (var member : bindings.listByTrip(trip.getId())) {
reason = appointmentAllocations.departureBlockReason(member.getDispatchNo());
if (reason != null) break;
}
if (reason == null) {
var driver = drivers.detail(trip.getDriverId(), trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC));
if (!driver.qualification().canDispatch()) reason = "司机资格不满足本次任务,请在平台核对";
@@ -2,6 +2,11 @@ package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface FleetTripHistoryMapper extends BaseMapper<FleetTripHistory> { }
public interface FleetTripHistoryMapper extends BaseMapper<FleetTripHistory> {
@Select("SELECT * FROM ll_fleet_trip_history WHERE trip_id=#{tripId} AND action='BIND_DISPATCH' ORDER BY record_version DESC LIMIT 1001")
java.util.List<FleetTripHistory> bindingHistory(@Param("tripId") long tripId);
}
@@ -31,14 +31,19 @@ public final class FleetTripModels {
public record ReviewState(String status, String reason, String reviewerId, Instant reviewedAt, Long recordVersion) {
public static ReviewState pending() { return new ReviewState("PENDING", null, null, null, null); }
}
public record DispatchBinding(String dispatchNo, String customerId, int packageCount, String boundBy, Instant boundAt, ReviewState review) { }
public record DispatchBinding(String dispatchNo, String customerId, int packageCount, String boundBy, Instant boundAt, ReviewState review,
List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference> appointmentBatches) { }
/** Original bound references only; these quantities never authorize current allocation. */
public record PreviousDispatch(String dispatchNo,String customerId,long recordVersion,Instant recordedAt,
List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference> appointmentBatches) { }
public record Response(String tripNo, String status, String warehouseId, String warehouseCode, String warehouseName,
String vehicleId, String vehiclePlate, String driverId, String driverName, String driverPhone,
Integer maxPalletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm,
Instant plannedDepartureAt, Instant expectedDriverEndAt, Instant departedAt, Instant completedAt,
List<Stop> stops, String remark, long recordVersion, Instant updatedAt,
FleetCompletionModels.ReleaseInfo vehicleRelease, FleetCompletionModels.ReleaseInfo driverRelease) { }
public record Detail(Response trip, DriverQualificationPolicy.Qualification driverQualification, List<DispatchBinding> dispatches, ReviewState loadingReview) { }
public record Detail(Response trip, DriverQualificationPolicy.Qualification driverQualification, List<DispatchBinding> dispatches, ReviewState loadingReview,
List<PreviousDispatch> previousDispatches,String appointmentHistoryUnavailableReason) { }
public record AuditSnapshot(Response trip, List<DispatchBinding> dispatches, FleetExecutionModels.ConfirmedLoad confirmedLoad,
FleetCompletionModels.Completed completion, ReviewState loadingReview,
FleetExecutionModels.PlannedLoad capacityPlan, List<FleetBookingModels.Response> externalBookings) { }
@@ -135,7 +135,9 @@ public class FleetTripService {
dispatches.assign(dispatch, assignment(trip));
}
persist(trip, request.recordVersion(), "ASSIGN", request.reason(), operator);
return new FleetTripModels.Detail(response(trip), driver.qualification(), bindingResponses(trip), FleetTripReview.read(json, trip.getLoadingReviewJson()));
var currentBindings=bindingResponses(trip);
var history=previousDispatches(trip,currentBindings);
return new FleetTripModels.Detail(response(trip), driver.qualification(), currentBindings, FleetTripReview.read(json, trip.getLoadingReviewJson()),history.dispatches(),history.unavailableReason());
}
@Transactional
@@ -302,12 +304,43 @@ public class FleetTripService {
return detail(trip);
}
private FleetTripModels.Detail detail(FleetTripEntity trip) {
var currentBindings=bindingResponses(trip);
var history=previousDispatches(trip,currentBindings);
return new FleetTripModels.Detail(response(trip), trip.getDriverId() == null ? null
: drivers.detail(trip.getDriverId(), instant(trip.getExpectedDriverEndAt())).qualification(), bindingResponses(trip), FleetTripReview.read(json, trip.getLoadingReviewJson()));
: drivers.detail(trip.getDriverId(), instant(trip.getExpectedDriverEndAt())).qualification(), currentBindings, FleetTripReview.read(json, trip.getLoadingReviewJson()),history.dispatches(),history.unavailableReason());
}
private List<FleetTripModels.DispatchBinding> bindingResponses(FleetTripEntity trip) {
return bindings.listByTrip(trip.getId()).stream().map(binding -> new FleetTripModels.DispatchBinding(binding.getDispatchNo(),
binding.getCustomerId(), binding.getPackageCount(), binding.getBoundBy(), instant(binding.getBoundAt()), FleetTripReview.read(json, binding.getReviewJson()))).toList();
var current=bindings.listByTrip(trip.getId());
var references=appointmentAllocations.references(current.stream().map(FleetTripDispatch::getDispatchId).toList());
return current.stream().map(binding -> new FleetTripModels.DispatchBinding(binding.getDispatchNo(),
binding.getCustomerId(), binding.getPackageCount(), binding.getBoundBy(), instant(binding.getBoundAt()), FleetTripReview.read(json, binding.getReviewJson()),references.getOrDefault(binding.getDispatchId(),List.of()))).toList();
}
private record AppointmentHistory(List<FleetTripModels.PreviousDispatch> dispatches,String unavailableReason) { }
private AppointmentHistory previousDispatches(FleetTripEntity trip,List<FleetTripModels.DispatchBinding> current) {
var history=histories.bindingHistory(trip.getId());
if(history.size()>1000) return new AppointmentHistory(List.of(),"原车绑定历史超过完整读取上限,请分页核对车次历史");
String unavailableReason=null;
var seen=new HashSet<List<String>>();
current.forEach(binding->binding.appointmentBatches().forEach(batch->batch.sources().forEach(source->seen.add(referenceKey(batch,source)))));
var previous=new ArrayList<FleetTripModels.PreviousDispatch>();
for(var event:history) {
var snapshot=read(event.getSnapshotJson(),FleetTripModels.AuditSnapshot.class);
if(snapshot.dispatches()==null) { unavailableReason="原车历史缺少批次来源依据,已移出客户的历史追踪不完整";continue; }
for(var binding:snapshot.dispatches()) {
if(binding.appointmentBatches()==null) { unavailableReason="原车历史缺少批次来源依据,已移出客户的历史追踪不完整";continue; }
var references=new ArrayList<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference>();
for(var batch:binding.appointmentBatches()) {
var missing=batch.sources().stream().filter(source->seen.add(referenceKey(batch,source))).toList();
if(!missing.isEmpty()) references.add(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference(batch.batchKey(),batch.batchNo(),batch.customerId(),missing));
}
if(!references.isEmpty()) previous.add(new FleetTripModels.PreviousDispatch(binding.dispatchNo(),binding.customerId(),event.getRecordVersion(),instant(event.getOccurredAt()),List.copyOf(references)));
}
}
return new AppointmentHistory(List.copyOf(previous),unavailableReason);
}
private static List<String> referenceKey(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference batch,
com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchSource source) {
return List.of(batch.batchKey(),batch.customerId(),source.sourceKey(),source.destinationKey());
}
private static FleetDispatchGateway.Assignment assignment(FleetTripEntity trip) {
return new FleetDispatchGateway.Assignment(trip.getVehiclePlate(), trip.getDriverName(), trip.getDriverPhone());
@@ -35,7 +35,7 @@ public class AppointmentWorkTransportProvider implements AppointmentWorkFactGate
var action=new Action(ActionCode.ARRANGE_TRANSPORT,"按批次选择实物并安排车次",request.workItemKey(),new TripTarget(null,request.batchKeys()));
return new Fact(request.workItemKey(),"UNASSIGNED","UNASSIGNED",false,PlanStatus.UNPLANNED,ExecutionStatus.PENDING,null,null,null,null,null,null,
List.of(ORDER_READ),List.of("platform:fleet:trip:create","platform:fleet:trip:edit"),action,
request.batchKey()==null?List.of("请先明确原送货批次,再按真实实物安排车次"):List.of(),List.of(),List.of(),null);
request.batchKey()==null?List.of("请先明确原送货批次,再按真实实物安排车次"):List.of(),List.of(),List.of(),null,"UNASSIGNED");
}
String timezone=timezone(trip.getWarehouseId());
var missing=timezone==null?List.of("SOURCE_TIMEZONE"):List.<String>of();
@@ -49,7 +49,7 @@ public class AppointmentWorkTransportProvider implements AppointmentWorkFactGate
target==null?PlanStatus.UNPLANNED:trip.getVehicleId()!=null && trip.getDriverId()!=null?PlanStatus.CONFIRMED:PlanStatus.TARGET_SET,
execution,target,trip.getVehicleId()!=null && trip.getDriverId()!=null?target:null,actual,timezone,trip.getTripNo(),trip.getId().toString(),
List.of(ORDER_READ,"platform:fleet:trip:list"),action==null?List.of():List.of("platform:fleet:trip:edit"),action,
"CANCELLED".equals(trip.getStatus())?List.of("车次已取消,请重新安排运输"):List.of(),missing,List.of(new BusinessReference("FLEET_TRIP",trip.getId().toString(),trip.getTripNo(),"真实车次")),null);
"CANCELLED".equals(trip.getStatus())?List.of("车次已取消,请重新安排运输"):List.of(),missing,List.of(new BusinessReference("FLEET_TRIP",trip.getId().toString(),trip.getTripNo(),"真实车次")),null,commitments.impactToken("TRIP_PLAN:"+trip.getId()));
}
private String timezone(Long warehouse) {
if(warehouse==null) return null;
@@ -14,10 +14,13 @@ public class AppointmentWorkTripChanges {
public void changed(FleetTripEntity trip) {
changes.requirementsChanged();
String assignment=trip.getVehicleId()==null && trip.getDriverId()==null?null:trip.getVehicleId()+":"+trip.getDriverId();
String cargo=AppointmentWorkPolicy.digest(mapper.batches(trip.getId())+":"+Objects.toString(trip.getStopsJson(),""));
String cargo=AppointmentWorkPolicy.digest(mapper.batches(trip.getId()).stream().distinct().sorted().toList()+":"+Objects.toString(trip.getStopsJson(),""));
// Fleet plan storage is explicitly UTC. This is an audit baseline, not a location-time projection.
var start=trip.getPlannedDepartureAt(); var end=trip.getExpectedDriverEndAt();
var slot=start==null?null:new AppointmentWorkModels.Slot(start.toLocalDate(),start.toLocalTime(),end==null?null:end.toLocalDate(),end==null?null:end.toLocalTime(),"UTC");
commitments.record("TRIP:"+trip.getId(),new AppointmentWorkCommitmentService.Commitment(slot,null,assignment,cargo,"CANCELLED".equals(trip.getStatus())));
// Cargo/stop membership changes require this vehicle to be rechecked, but do not
// invalidate unchanged customers' receiving appointments. Common plan changes do.
commitments.record("TRIP_PLAN:"+trip.getId(),new AppointmentWorkCommitmentService.Commitment(slot,null,assignment,null,"CANCELLED".equals(trip.getStatus())));
}
}
@@ -3,7 +3,9 @@ package com.baiyue.lastleg.inboundplan.dto;
import java.util.List;
public record FbaAppointmentBatchContext(FbaAppointmentResponse appointment,List<SourceAllocation> sources,
List<String> allowedActions,List<String> blockReasons) {
List<String> allowedActions,List<String> blockReasons,
List<FbaAppointmentResponse> currentBatches,List<SourceRef> originalSourceRefs) {
public record SourceRef(String customerId,String sourceKey,String destinationKey) { }
public record SourceAllocation(String customerId,String sourceKey,String destinationKey,Integer packageCount,
Integer originalPackages,String sourceVersion,Integer cargoVerificationVersion,
List<String> shipmentReferences) { }
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.inboundplan.receiving;
import java.util.List;
/** Original execution evidence, scoped by customer; work projections cannot authorize rebooking. */
public interface FbaAppointmentExecutionPort {
record Member(String batchKey,String customerId) { }
record Eligibility(Member member,boolean completed,boolean canRebook,String reason) { }
record Scope(String version,List<Eligibility> members) { }
Scope inspect(List<Member> members);
/** Locks original trips and selected customers' dispatch/execution rows before core batch locks. */
Scope lockAndInspect(List<Member> members,String expectedVersion);
}
@@ -0,0 +1,18 @@
package com.baiyue.lastleg.inboundplan.receiving;
import java.util.List;
/** Original Dispatch/Fleet authority; implementations never invent physical pallet splits. */
public interface FbaAppointmentTransportPort {
record Vehicle(String tripNo, Integer version, String dispatchNo, String customerId,
String batchKey, String status, int packageCount) { }
record Scope(String version, List<Vehicle> vehicles, List<String> blockReasons) { }
record Move(String batchKey, String customerId, String sourceKey, String destinationKey,
int originalPackages, int retainedPackages, String laterBatchKey) { }
record Result(List<Vehicle> vehicles, List<String> nextActions) { }
Scope inspect(List<String> batchKeys);
/** Lock order: trip -> dispatch -> outbound/PDA -> appointment; called BEFORE core locks batches. */
Scope lockAndInspect(List<String> batchKeys, String expectedVersion);
/** Same transaction as core allocation. Throw on failure so physical/allocation changes roll back together. */
Result apply(Scope locked, List<Move> moves, String operator, String reason);
}
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.inboundplan.receiving;
import java.time.LocalDate;
import java.util.List;
/** Work domain owns the verified responsible person and durable follow-up inheritance. */
public interface FbaAppointmentWorkPort {
record Member(String batchKey,String customerId) { }
record Followup(String ownerId,String ownerName,LocalDate nextFollowupDate) { }
Followup resolveFollowup(String ownerId,LocalDate nextFollowupDate);
void seedFollowup(Member original,Member later,Followup followup,String operatorId);
void inheritFollowup(List<Member> originals,List<Member> successors,String operatorId);
}
@@ -0,0 +1,64 @@
package com.baiyue.lastleg.inboundplan.receiving;
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails;
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.time.LocalDate;
import java.util.List;
/** Receiving coverage is separate from a stable cargo batch and from shared vehicles. */
public final class FbaReceivingContracts {
private FbaReceivingContracts() { }
public record Member(String batchKey, String appointmentNo, int batchVersion, String customerId,
List<Source> sources, String customerName) { }
public record Source(String sourceKey, String destinationKey, int packageCount,
String sourceVersion, Integer verificationVersion, String inboundCode) { }
public record Agreement(String agreementKey, int version, String status, String destinationKey,
FbaAppointmentDetails details, String timezone, List<Member> members,
String warehouseEvidence) { }
public record Excluded(Member member, String reason, String currentAgreementKey) { }
public record Pending(String changeKey, int version, String status, ChangeIntent intent,
List<Member> originalMembers, List<Agreement> originalAgreements,FbaAppointmentWorkPort.Followup followup) { }
public record CustomerPlan(String batchKey,String customerId,FbaAppointmentDetails details,String timezone) { }
public record BatchReceiving(String batchKey, List<Agreement> agreements, List<Pending> pendingChanges,List<CustomerPlan> plans) { }
public record Context(FbaAppointmentResponse batch, List<Agreement> agreements,
List<Pending> pendingChanges, FbaAppointmentTransportPort.Scope transport,
List<String> allowedActions, List<String> blockReasons,FbaAppointmentExecutionPort.Scope executionScope,List<CustomerPlan> plans) { }
public record GroupContext(Agreement agreement, List<Member> eligibleMembers, List<Excluded> excludedMembers,
FbaAppointmentTransportPort.Scope transport,FbaAppointmentExecutionPort.Scope executionScope) { }
public record Selection(@NotBlank String batchKey, @Min(0) int batchVersion,
@NotBlank String customerId) { }
public record Plan(@NotBlank @Size(max=64) String idempotencyKey,@NotNull @Valid Selection member,
@NotNull @Valid FbaAppointmentDetails details,@NotBlank String executionVersion) { }
public record ExecutionQuery(@NotEmpty @Size(max=200) List<FbaAppointmentExecutionPort.Member> members) { }
public record CargoVersion(@NotBlank String sourceKey,@NotBlank String destinationKey,
@NotBlank String expectedSourceVersion,@Min(1) Integer expectedVerificationVersion) { }
public record RefreshCargo(@NotBlank @Size(max=64) String idempotencyKey,@NotNull @Valid Selection member,
@NotEmpty @Size(max=100) List<@NotNull @Valid CargoVersion> sources,
@NotBlank String executionVersion) { }
public record Quantity(@NotBlank String sourceKey, @NotBlank String destinationKey,
@Min(0) int retainedPackages, @NotBlank String expectedSourceVersion,
Integer expectedVerificationVersion) { }
public record ChangeIntent(@NotBlank String batchKey, @Min(0) int batchVersion,
@NotBlank String customerId, @NotEmpty @Valid List<Quantity> quantities,
LocalDate laterDate, @NotBlank @Size(max=64) String ownerId,
LocalDate nextFollowupDate, @NotBlank @Size(max=1000) String reason,
@NotBlank String transportVersion) { }
public record Propose(@NotBlank @Size(max=64) String idempotencyKey, @NotNull @Valid ChangeIntent intent) { }
public record Resolve(@NotBlank @Size(max=64) String idempotencyKey, @Min(0) int version,
@NotBlank String decision, @Size(max=1000) String warehouseEvidence,
@NotBlank String transportVersion) { }
public record Booking(@NotEmpty @Valid List<Selection> members, @NotNull @Valid FbaAppointmentDetails details,
@NotBlank @Size(max=1000) String warehouseEvidence) { }
public record Book(@NotBlank @Size(max=64) String idempotencyKey, @NotEmpty @Valid List<Booking> bookings,
String cancelledAgreementKey, Integer cancelledAgreementVersion,
@NotBlank String executionVersion) { }
public record GroupChange(@NotBlank @Size(max=64) String idempotencyKey, @Min(0) int version,
@NotBlank String action, @Valid FbaAppointmentDetails details,
@NotBlank @Size(max=1000) String warehouseEvidence,
@NotBlank String transportVersion) { }
public record Step(String step, String status, String targetKey, String message) { }
public record Result(String changeKey, String status, List<FbaAppointmentResponse> batches,
List<Agreement> agreements, List<Step> steps, List<String> nextActions) { }
}
@@ -0,0 +1,52 @@
package com.baiyue.lastleg.inboundplan.receiving;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import static com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*;
@RestController
@RequestMapping("/api/platform/v1/last-leg/appointment-receiving")
public class FbaReceivingController {
private static final String READ="hasAuthority('platform:appointment:list')";
private static final String EDIT="hasAuthority('platform:overseas-warehouse:inbound-order:fba-appointment')";
private static final String DELETE="hasAuthority('platform:appointment:delete')";
private final FbaReceivingService service;
public FbaReceivingController(FbaReceivingService service) {this.service=service;}
@GetMapping("/batches/{batchKey}") @PreAuthorize(READ)
public ApiResponse<Context> context(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String batchKey) {platform(user);return ApiResponse.success(service.context(batchKey));}
@GetMapping("/agreements/{agreementKey}") @PreAuthorize(READ)
public ApiResponse<GroupContext> group(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String agreementKey) {platform(user);return ApiResponse.success(service.groupContext(agreementKey));}
@PostMapping("/execution-scope") @PreAuthorize(READ)
public ApiResponse<FbaAppointmentExecutionPort.Scope> executionScope(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody ExecutionQuery request) {platform(user);return ApiResponse.success(service.executionScope(request));}
@PostMapping("/changes") @PreAuthorize(EDIT+" and "+DELETE)
public ApiResponse<Result> propose(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody Propose request) {return ApiResponse.success(service.propose(platform(user),request));}
@PostMapping("/changes/{changeKey}/resolve") @PreAuthorize(EDIT+" and "+DELETE)
public ApiResponse<Result> resolve(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String changeKey,@Valid @RequestBody Resolve request) {return ApiResponse.success(service.resolve(platform(user),changeKey,request));}
@PostMapping("/bookings") @PreAuthorize(EDIT)
public ApiResponse<Result> book(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody Book request) {return ApiResponse.success(service.book(platform(user),request));}
@PostMapping("/plans") @PreAuthorize(EDIT)
public ApiResponse<Result> plan(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody Plan request) {return ApiResponse.success(service.plan(platform(user),request));}
@PostMapping("/cargo-refresh") @PreAuthorize(EDIT)
public ApiResponse<Result> refreshCargo(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody RefreshCargo request) {return ApiResponse.success(service.refreshCargo(platform(user),request));}
@PostMapping("/agreements/{agreementKey}/reschedule") @PreAuthorize(EDIT)
public ApiResponse<Result> reschedule(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String agreementKey,@Valid @RequestBody GroupChange request) {
var operator=platform(user);if(!"RESCHEDULE".equals(request.action())) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"此入口只允许改期");
return ApiResponse.success(service.changeGroup(operator,agreementKey,request));
}
@PostMapping("/agreements/{agreementKey}/cancel") @PreAuthorize(DELETE)
public ApiResponse<Result> cancel(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String agreementKey,@Valid @RequestBody GroupChange request) {
var operator=platform(user);if(!"CANCEL".equals(request.action())) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"此入口只允许取消");
return ApiResponse.success(service.changeGroup(operator,agreementKey,request));
}
private static String platform(AuthenticatedUser user) {
if(user==null || user.userId()==null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if(!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId())) throw new ResponseStatusException(HttpStatus.FORBIDDEN);
return String.valueOf(user.userId());
}
}
@@ -0,0 +1,66 @@
package com.baiyue.lastleg.inboundplan.receiving;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface FbaReceivingMapper {
record GroupRow(String agreementKey, int version, String status, String destinationKey, String confirmationNo, String snapshotJson) { }
record ChangeRow(String changeKey, int version, String status, String batchKey, String customerId, String snapshotJson, String resultJson) { }
record BatchJson(String batchKey,String snapshotJson) { }
record CustomerLabel(String customerId,String customerName) { }
record PlanRow(String batchKey,String customerId,String snapshotJson) { }
@Select("<script>SELECT p.batch_key,p.customer_id,p.snapshot_json FROM ll_fba_receiving_plan p JOIN ll_inbound_fba_appointment a ON p.batch_key=CONCAT('FBA_APPOINTMENT:',a.id) WHERE a.replacement_nos_json IS NULL AND EXISTS (SELECT 1 FROM ll_inbound_fba_appointment_item i WHERE i.appointment_id=a.id AND i.customer_id=p.customer_id AND i.active=1) AND p.batch_key IN <foreach collection='keys' item='key' open='(' separator=',' close=')'>#{key}</foreach> ORDER BY p.batch_key,p.customer_id</script>")
List<PlanRow> plans(@Param("keys") List<String> keys);
@Delete("DELETE FROM ll_fba_receiving_plan WHERE batch_key=#{batch} AND customer_id=#{customer}")
int deletePlan(@Param("batch") String batch,@Param("customer") String customer);
@Insert("INSERT INTO ll_fba_receiving_plan(batch_key,customer_id,snapshot_json) VALUES(#{batchKey},#{customerId},#{snapshotJson})")
int insertPlan(PlanRow row);
@Select("SELECT DISTINCT i.customer_id,o.customer_name FROM ll_inbound_fba_appointment_item i JOIN ll_order o ON o.id=i.order_id AND o.customer_id=i.customer_id WHERE CONCAT('FBA_APPOINTMENT:',i.appointment_id)=#{batch} AND i.active=1")
List<CustomerLabel> labels(String batch);
record LiveSource(String customerId,String sourceKey,String destinationKey,Integer packageCount) { }
@Select("SELECT customer_id,source_key,CONCAT('FBA:',fba_warehouse_code) AS destination_key,package_count FROM ll_inbound_fba_appointment_item WHERE CONCAT('FBA_APPOINTMENT:',appointment_id)=#{batch} AND active=1")
List<LiveSource> liveSources(String batch);
@Select("<script>SELECT DISTINCT m.batch_key,a.snapshot_json FROM ll_fba_receiving_member m JOIN ll_fba_receiving_agreement a ON a.agreement_key=m.agreement_key WHERE m.active_ref IS NOT NULL AND m.batch_key IN <foreach collection='keys' item='key' open='(' separator=',' close=')'>#{key}</foreach> ORDER BY m.batch_key</script>")
List<BatchJson> currentGroups(@Param("keys") List<String> keys);
@Select("<script>SELECT batch_key,snapshot_json FROM ll_fba_receiving_change WHERE status='WAITING' AND batch_key IN <foreach collection='keys' item='key' open='(' separator=',' close=')'>#{key}</foreach> ORDER BY batch_key,change_key</script>")
List<BatchJson> pendingGroups(@Param("keys") List<String> keys);
@Select("SELECT agreement_key,version,status,destination_key,confirmation_no,snapshot_json FROM ll_fba_receiving_agreement WHERE agreement_key=#{key}")
GroupRow group(String key);
@Select("SELECT agreement_key,version,status,destination_key,confirmation_no,snapshot_json FROM ll_fba_receiving_agreement WHERE agreement_key=#{key} FOR UPDATE")
@Options(flushCache=Options.FlushCachePolicy.TRUE,useCache=false)
GroupRow lockGroup(String key);
@Insert("INSERT INTO ll_fba_receiving_agreement(agreement_key,version,status,destination_key,confirmation_no,snapshot_json) VALUES(#{agreementKey},#{version},#{status},#{destinationKey},#{confirmationNo},#{snapshotJson})")
int insertGroup(GroupRow row);
@Update("UPDATE ll_fba_receiving_agreement SET version=#{version},status=#{status},confirmation_no=#{confirmationNo},snapshot_json=#{snapshotJson} WHERE agreement_key=#{agreementKey}")
int updateGroup(GroupRow row);
@Insert("INSERT INTO ll_fba_receiving_revision(agreement_key,version,snapshot_json) VALUES(#{agreementKey},#{version},#{snapshotJson})")
int revision(GroupRow row);
@Select("SELECT DISTINCT agreement_key FROM ll_fba_receiving_member WHERE batch_key=#{batch} ORDER BY agreement_key")
List<String> groupKeys(String batch);
@Select("SELECT agreement_key FROM ll_fba_receiving_member WHERE batch_key=#{batch} AND customer_id=#{customer} AND active_ref IS NOT NULL")
String currentKey(@Param("batch") String batch,@Param("customer") String customer);
@Insert("INSERT INTO ll_fba_receiving_member(agreement_key,batch_key,customer_id,active_ref) VALUES(#{group},#{batch},#{customer},#{activeRef})")
int addMember(@Param("group") String group,@Param("batch") String batch,@Param("customer") String customer,@Param("activeRef") String activeRef);
@Update("UPDATE ll_fba_receiving_member SET active_ref=NULL WHERE agreement_key=#{group}")
int releaseGroup(String group);
@Update("UPDATE ll_fba_receiving_member SET active_ref=NULL WHERE agreement_key=#{group} AND batch_key=#{batch} AND customer_id=#{customer}")
int releaseMember(@Param("group") String group,@Param("batch") String batch,@Param("customer") String customer);
@Select("SELECT change_key,version,status,batch_key,customer_id,snapshot_json,result_json FROM ll_fba_receiving_change WHERE change_key=#{key}")
ChangeRow change(String key);
@Select("SELECT change_key,version,status,batch_key,customer_id,snapshot_json,result_json FROM ll_fba_receiving_change WHERE change_key=#{key} FOR UPDATE")
@Options(flushCache=Options.FlushCachePolicy.TRUE,useCache=false)
ChangeRow lockChange(String key);
@Select("SELECT change_key,version,status,batch_key,customer_id,snapshot_json,result_json FROM ll_fba_receiving_change WHERE batch_key=#{batch} AND status='WAITING' ORDER BY change_key")
List<ChangeRow> pending(String batch);
@Select("SELECT result_json FROM ll_fba_receiving_change WHERE batch_key=#{batch} AND status='APPLIED' ORDER BY change_key")
List<String> appliedResults(String batch);
@Select("SELECT snapshot_json FROM ll_fba_receiving_change WHERE batch_key=#{batch} AND status='APPLIED' ORDER BY change_key")
List<String> appliedChanges(String batch);
@Select("SELECT COUNT(*) FROM ll_fba_receiving_change WHERE batch_key=#{batch} AND customer_id=#{customer} AND status='WAITING'")
long pendingCount(@Param("batch") String batch,@Param("customer") String customer);
@Insert("INSERT INTO ll_fba_receiving_change(change_key,version,status,batch_key,customer_id,snapshot_json,active_ref) VALUES(#{changeKey},#{version},#{status},#{batchKey},#{customerId},#{snapshotJson},CONCAT(#{batchKey},'/',#{customerId}))")
int insertChange(ChangeRow row);
@Update("UPDATE ll_fba_receiving_change SET version=#{version},status=#{status},snapshot_json=#{snapshotJson},result_json=#{resultJson},active_ref=NULL WHERE change_key=#{changeKey}")
int finishChange(ChangeRow row);
}
@@ -0,0 +1,284 @@
package com.baiyue.lastleg.inboundplan.receiving;
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse;
import com.baiyue.lastleg.inboundplan.mapper.FbaAppointmentCommandMapper;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
import java.util.*;
import java.util.function.Supplier;
import static com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*;
import static com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.conflict;
@Service
@Transactional(isolation=Isolation.READ_COMMITTED)
public class FbaReceivingService {
private final FbaReceivingStore store;
private final InboundFbaAppointmentService batches;
private final FbaAppointmentTransportPort transport;
private final FbaAppointmentCommandMapper commands;
private final FbaAppointmentWorkPort work;
private final FbaAppointmentExecutionPort execution;
public FbaReceivingService(FbaReceivingStore store,InboundFbaAppointmentService batches,FbaAppointmentTransportPort transport,FbaAppointmentCommandMapper commands,FbaAppointmentWorkPort work,FbaAppointmentExecutionPort execution) {
this.store=store;this.batches=batches;this.transport=transport;this.commands=commands;this.work=work;this.execution=execution;
}
@Transactional(readOnly=true)
public FbaAppointmentExecutionPort.Scope executionScope(ExecutionQuery request) { return execution.inspect(request.members()); }
@Transactional(readOnly=true)
public Context context(String key) {
var batch=batches.batchSnapshots(List.of(key)).get(0);var pending=store.pending(key);
var agreements=store.agreements(key);
var scope=transport.inspect(List.of(key));var blocks=new ArrayList<>(scope.blockReasons());
if(!batch.replacementAppointmentNos().isEmpty()) blocks.add("原批次已全部转入后续安排,请进入当前批次");
var actions=new ArrayList<String>();
var customers=batch.items().stream().map(FbaAppointmentResponse.Item::customerId).collect(java.util.stream.Collectors.toSet());
var covered=agreements.stream().filter(a->"ACTIVE".equals(a.status())).flatMap(a->a.members().stream()).filter(m->key.equals(m.batchKey())).map(Member::customerId).collect(java.util.stream.Collectors.toSet());
var waiting=pending.stream().map(p->p.intent().customerId()).collect(java.util.stream.Collectors.toSet());
var executionScope=execution.inspect(customers.stream().sorted().map(c->new FbaAppointmentExecutionPort.Member(key,c)).toList());
if(blocks.isEmpty()) {
if(customers.stream().anyMatch(c->!waiting.contains(c))) actions.add("PROPOSE_CUSTOMER_CHANGE");
}
if(batch.replacementAppointmentNos().isEmpty() && executionScope.members().stream().anyMatch(e->e.canRebook() && !e.completed() && !covered.contains(e.member().customerId()) && !waiting.contains(e.member().customerId()))) {
actions.add("BOOK_RECEIVING");actions.add("PLAN_RECEIVING");actions.add("REFRESH_CARGO");
}
return new Context(batch,agreements,pending,scope,List.copyOf(actions),List.copyOf(blocks),executionScope,store.plans(key));
}
@Transactional(readOnly=true)
public GroupContext groupContext(String key) {
var group=store.agreement(key,false);var eligible=new ArrayList<Member>();var excluded=new ArrayList<Excluded>();
var executionScope=execution.inspect(group.members().stream().map(m->new FbaAppointmentExecutionPort.Member(m.batchKey(),m.customerId())).toList());
for(var old:group.members()) {
var snapshot=batches.batchSnapshots(List.of(old.batchKey())).get(0);
var current=store.currentAgreement(old.batchKey(),old.customerId());
var member=snapshot.replacementAppointmentNos().isEmpty()?store.members(snapshot).stream().filter(m->m.customerId().equals(old.customerId())).findFirst().orElse(null):null;
String reason=!snapshot.replacementAppointmentNos().isEmpty()?"原批次已转入其他批次":member==null?"客户已移出本批":
store.hasPending(old.batchKey(),old.customerId())?"正在处理其他待确认变化":
current!=null && !current.agreementKey().equals(key)?"已另行预约":null;
var eligibility=executionScope.members().stream().filter(e->e.member().batchKey().equals(old.batchKey()) && e.member().customerId().equals(old.customerId())).findFirst().orElseThrow(()->conflict("客户执行资格读取不完整"));
if(reason==null && (eligibility.completed() || !eligibility.canRebook())) reason=eligibility.completed()?"本客户货物已实际履约完成":eligibility.reason();
if(reason==null && !eligibility.canRebook()) reason="本客户已开始执行,请从原运输异常处理";
if(reason==null) eligible.add(member);else excluded.add(new Excluded(old,reason,current==null?null:current.agreementKey()));
}
var transportMembers="CANCELLED".equals(group.status()) && !eligible.isEmpty()?eligible:group.members();
var scope=transport.inspect(transportMembers.stream().map(Member::batchKey).distinct().sorted().toList());
// Executed authority may block all further ordinary changes; no synthetic completion here.
var bookingScope=execution.inspect(eligible.stream().map(m->new FbaAppointmentExecutionPort.Member(m.batchKey(),m.customerId())).toList());
return new GroupContext(group,List.copyOf(eligible),List.copyOf(excluded),scope,bookingScope);
}
public Result propose(String operator,Propose request) {
return command("RECEIVING_PROPOSE",operator,request.idempotencyKey(),request,()->{
var intent=request.intent();var followup=work.resolveFollowup(intent.ownerId(),intent.nextFollowupDate());
var scope=transport.lockAndInspect(List.of(intent.batchKey()),intent.transportVersion());requireMutable(scope);
var batch=batches.lockReceivingBatch(intent.batchKey());requireVersion(batch,intent.batchVersion());
recheckTransport(List.of(intent.batchKey()),scope);
if(store.hasPending(intent.batchKey(),intent.customerId())) throw conflict("本客户已有待确认变化,请继续原记录");
var member=store.members(batch).stream().filter(m->m.customerId().equals(intent.customerId())).findFirst().orElseThrow(()->bad("客户不属于本批次"));
validateQuantities(member,intent);
var group=store.currentAgreement(batch.batchKey(),intent.customerId());
if(group!=null) {group=store.agreement(group.agreementKey(),true);requireActive(group);}
var pending=new Pending("CH-"+UUID.randomUUID().toString().replace("-","").toUpperCase(Locale.ROOT),0,"WAITING",intent,List.of(member),group==null?List.of():List.of(group),followup);
store.save(pending);
batches.receivingChanged(List.of(batch));
return new Result(pending.changeKey(),"WAITING",List.of(batch),pending.originalAgreements(),List.of(new Step("RECORD_CHANGE","SUCCESS",pending.changeKey(),"原安排及数量保留,待确认变化已阻止相关发车")),List.of("CONTINUE_WAREHOUSE_CONFIRMATION"));
});
}
public Result resolve(String operator,String changeKey,Resolve request) {
return command("RECEIVING_RESOLVE:"+changeKey,operator,request.idempotencyKey(),request,()->{
var initial=store.change(changeKey,false);var intent=initial.intent();
// Withdrawing only removes the pending proposal. It must remain possible after
// picking starts, without editing cargo, the appointment, or vehicle facts.
var withdrawing="WITHDRAW".equals(request.decision());
var followup=withdrawing?null:work.resolveFollowup(intent.ownerId(),intent.nextFollowupDate());
var scope=withdrawing?null:transport.lockAndInspect(List.of(intent.batchKey()),request.transportVersion());
if(!withdrawing) requireMutable(scope);
var batch=batches.lockReceivingBatch(intent.batchKey());var pending=store.change(changeKey,true);
if(pending.version()!=request.version() || !"WAITING".equals(pending.status())) throw conflict("变化已被处理,请重新读取当前结果");
if(withdrawing) {
var result=new Result(changeKey,"WITHDRAWN",List.of(batch),pending.originalAgreements(),List.of(new Step("WITHDRAW_CHANGE","SUCCESS",changeKey,"变化已撤回,原安排保留")),List.of("REVIEW_TRANSPORT"));
store.finish(pending,"WITHDRAWN",result);batches.receivingChanged(List.of(batch));return result;
}
if(!"CONFIRM".equals(request.decision()) || blank(request.warehouseEvidence())) throw bad("请填写仓库明确同意保留量及移出量的回复依据");
requireVersion(batch,intent.batchVersion());
Agreement original=null;
for(var expected:pending.originalAgreements()) {
var current=store.agreement(expected.agreementKey(),true);
if(current.version()!=expected.version() || !"ACTIVE".equals(current.status())) throw conflict("仓库预约覆盖已变化,不能沿旧回复处理");
original=current;
}
var currentKey=store.currentAgreement(intent.batchKey(),intent.customerId());
if(!Objects.equals(currentKey==null?null:currentKey.agreementKey(),original==null?null:original.agreementKey())) throw conflict("本客户有效预约关系已变化");
var changed=batches.moveReceivingCustomer(operator,intent);var retained=changed.get(0);var later=changed.get(1);
work.seedFollowup(new FbaAppointmentWorkPort.Member(intent.batchKey(),intent.customerId()),new FbaAppointmentWorkPort.Member(later.batchKey(),intent.customerId()),followup,operator);
var movements=new ArrayList<FbaAppointmentTransportPort.Move>();
for(var source:pending.originalMembers().get(0).sources()) {
var q=intent.quantities().stream().filter(v->v.sourceKey().equals(source.sourceKey()) && v.destinationKey().equals(source.destinationKey())).findFirst().orElseThrow();
movements.add(new FbaAppointmentTransportPort.Move(intent.batchKey(),intent.customerId(),source.sourceKey(),source.destinationKey(),source.packageCount(),q.retainedPackages(),later.batchKey()));
}
var physical=transport.apply(scope,List.copyOf(movements),operator,intent.reason());
var agreements=new ArrayList<Agreement>();
if(original!=null) {
var members=new ArrayList<>(original.members());members.removeIf(m->m.batchKey().equals(intent.batchKey()) && m.customerId().equals(intent.customerId()));
var remaining=store.members(retained).stream().filter(m->m.customerId().equals(intent.customerId())).findFirst();
if(remaining.isPresent()) members.add(remaining.get());else store.releaseMember(original.agreementKey(),intent.batchKey(),intent.customerId());
agreements.add(members.isEmpty()?store.cancel(original,"SUPERSEDED",request.warehouseEvidence()):store.update(original,members,original.details(),request.warehouseEvidence()));
}
var projected=retained.items().isEmpty()?retained:batches.projectReceiving(intent.batchKey(),false);
var steps=List.of(new Step("ALLOCATE_CARGO","SUCCESS",later.batchKey(),"原来源总量不变,后送量已落在新批次"),new Step("ADJUST_TRANSPORT","SUCCESS",intent.batchKey(),"原车辆按实际保留货物调整"));
var actions=new ArrayList<String>();actions.add("BOOK_LATER_BATCH");actions.addAll(physical.nextActions());
var result=new Result(changeKey,"APPLIED",List.of(projected,later),List.copyOf(agreements),steps,List.copyOf(actions));
store.finish(pending,"APPLIED",result);batches.receivingChanged(result.batches());return result;
});
}
public Result refreshCargo(String operator,RefreshCargo request) {
return command("RECEIVING_REFRESH_CARGO",operator,request.idempotencyKey(),request,()->{
var selection=request.member();var selected=List.of(new FbaAppointmentExecutionPort.Member(selection.batchKey(),selection.customerId()));
var locked=execution.lockAndInspect(selected,request.executionVersion());requireRebookable(selected,locked);
var batch=batches.lockReceivingBatch(selection.batchKey());requireVersion(batch,selection.batchVersion());
var current=execution.inspect(selected);
if(!Objects.equals(locked.version(),current.version())) throw conflict("客户执行关联已变化,请重新核对当前范围");
requireRebookable(selected,current);
if(store.hasPending(selection.batchKey(),selection.customerId())) throw conflict("本客户有待确认变化,请先处理或撤回原变化再核对资料");
if(store.currentAgreement(selection.batchKey(),selection.customerId())!=null) throw conflict("本客户已有正式确认,不能直接采用新货物资料,请先处理其有效预约");
var refreshed=batches.refreshReceivingCargo(selection.batchKey(),selection.customerId(),request.sources());
boolean changed=refreshed.version()!=batch.version();
return new Result(null,changed?"APPLIED":"UNCHANGED",List.of(refreshed),List.of(),changed?List.of(new Step("REFRESH_CARGO","SUCCESS",batch.batchKey(),"本客户已采用核对后的最新资料,批次数量及其他客户安排保留")):List.of(),List.of("CONTINUE_CUSTOMER_PLAN"));
});
}
public Result plan(String operator,Plan request) {
return command("RECEIVING_PLAN",operator,request.idempotencyKey(),request,()->{
var selection=request.member();var memberKey=new FbaAppointmentExecutionPort.Member(selection.batchKey(),selection.customerId());
var selected=List.of(memberKey);var locked=execution.lockAndInspect(selected,request.executionVersion());requireRebookable(selected,locked);
var batch=batches.lockReceivingBatch(selection.batchKey());requireVersion(batch,selection.batchVersion());
var current=execution.inspect(selected);
if(!Objects.equals(locked.version(),current.version())) throw conflict("客户执行关联已变化,请重新核对当前范围");
requireRebookable(selected,current);
if(store.hasPending(selection.batchKey(),selection.customerId())) throw conflict("本客户有待确认变化,请先处理原变化");
if(store.currentAgreement(selection.batchKey(),selection.customerId())!=null) throw conflict("本客户已有正式确认,请使用明确范围的改期或取消操作");
var member=store.member(batch,selection.customerId());if(member==null) throw conflict("客户已移出原批次");
var timezone=batches.validateReceivingPlan(member,request.details());
var plan=new CustomerPlan(batch.batchKey(),selection.customerId(),FbaReceivingStore.normalized(request.details()),timezone);
if(plan.equals(store.plan(batch.batchKey(),selection.customerId()))) return new Result(null,"UNCHANGED",List.of(batch),List.of(),List.of(),List.of("CONTINUE_CUSTOMER_PLAN"));
store.freezeUnconfirmedPlans(batch);
store.savePlan(plan);var projected=batches.projectReceiving(batch.batchKey(),false);
return new Result(null,"APPLIED",List.of(projected),List.of(),List.of(new Step("PLAN_RECEIVING","SUCCESS",batch.batchKey(),"仅本客户计划已保存,其他客户安排及货量保留")),List.of("CONTINUE_CUSTOMER_PLAN"));
});
}
public Result book(String operator,Book request) {
return command("RECEIVING_BOOK",operator,request.idempotencyKey(),request,()->{
if(request.bookings()==null || request.bookings().isEmpty() || request.bookings().size()>100) throw bad("请选择需要重新预约的客户");
var selections=request.bookings().stream().flatMap(b->b.members().stream()).toList();
var keys=selections.stream().map(Selection::batchKey).distinct().sorted().toList();
var executionMembers=selections.stream().map(s->new FbaAppointmentExecutionPort.Member(s.batchKey(),s.customerId())).distinct().toList();
var executionScope=execution.lockAndInspect(executionMembers,request.executionVersion());
requireRebookable(executionMembers,executionScope);
var snapshots=lock(keys);
// An unbound customer can acquire a new dispatch before we obtain the batch lock.
// Binding shares that batch lock, so rereading here closes the gap without reversing locks.
var currentExecution=execution.inspect(executionMembers);
if(!Objects.equals(executionScope.version(),currentExecution.version())) throw conflict("客户执行关联已变化,请重新核对当前范围");
requireRebookable(executionMembers,currentExecution);
snapshots.values().forEach(store::freezeUnconfirmedPlans);
Agreement cancelled=null;
if(request.cancelledAgreementKey()!=null) {
cancelled=store.agreement(request.cancelledAgreementKey(),true);
if(!"CANCELLED".equals(cancelled.status()) || !Objects.equals(cancelled.version(),request.cancelledAgreementVersion())) throw conflict("取消组版本已变化,请重新读取待重约范围");
}
var seen=new HashSet<String>();var saved=new ArrayList<Agreement>();
for(var booking:request.bookings()) {
if(blank(booking.warehouseEvidence()) || booking.members()==null || booking.members().isEmpty()) throw bad("请记录仓库确认覆盖本次客户和货量的依据");
var members=new ArrayList<Member>();
for(var selection:booking.members()) {
if(!seen.add(selection.batchKey()+"/"+selection.customerId())) throw bad("同客户批次不能重复预约");
var batch=snapshots.get(selection.batchKey());requireVersion(batch,selection.batchVersion());
if(store.hasPending(selection.batchKey(),selection.customerId())) throw conflict("该客户有其他待确认变化,不能直接重约");
if(store.currentAgreement(selection.batchKey(),selection.customerId())!=null) throw conflict("该客户已有有效收货预约,请明确变更当前预约范围");
if(cancelled!=null && cancelled.members().stream().noneMatch(m->m.batchKey().equals(selection.batchKey()) && m.customerId().equals(selection.customerId()))) throw conflict("所选客户不属于该取消组的原覆盖范围");
members.add(store.members(batch).stream().filter(m->m.customerId().equals(selection.customerId())).findFirst().orElseThrow(()->conflict("客户已移出原批次")));
}
var timezone=batches.validateReceiving(members,booking.details());saved.add(store.create(members,booking.details(),timezone,booking.warehouseEvidence()));
members.forEach(m->store.deletePlan(m.batchKey(),m.customerId()));
}
var projected=keys.stream().map(k->batches.projectReceiving(k,false)).toList();
return new Result(null,"APPLIED",projected,List.copyOf(saved),saved.stream().map(a->new Step("BOOK_RECEIVING","SUCCESS",a.agreementKey(),"使用原批次形成新的有效收货预约" )).toList(),List.of("REVIEW_TRANSPORT"));
});
}
public Result changeGroup(String operator,String key,GroupChange request) {
return command("RECEIVING_GROUP:"+key,operator,request.idempotencyKey(),request,()->{
var initial=store.agreement(key,false);var keys=initial.members().stream().map(Member::batchKey).distinct().sorted().toList();
var scope=transport.lockAndInspect(keys,request.transportVersion());requireMutable(scope);var snapshots=lock(keys);
recheckTransport(keys,scope);
var group=store.agreement(key,true);requireActive(group);
if(group.version()!=request.version()) throw conflict("共同预约范围已变化,请重新核对全部客户");
var members=new ArrayList<Member>();
for(var old:group.members()) {
if(store.hasPending(old.batchKey(),old.customerId())) throw conflict("当前成员有待确认变化,请先处理其实际范围");
var current=store.currentAgreement(old.batchKey(),old.customerId());
if(current==null || !key.equals(current.agreementKey())) throw conflict("成员已退出本预约,请重新读取范围");
members.add(store.members(snapshots.get(old.batchKey())).stream().filter(m->m.customerId().equals(old.customerId())).findFirst().orElseThrow(()->conflict("当前覆盖货物已变化")));
}
if(blank(request.warehouseEvidence())) throw bad("请记录本次仓库回复依据");
Agreement changed;
if("CANCEL".equals(request.action())) {
changed=store.cancel(group,"CANCELLED",request.warehouseEvidence());
members.forEach(m->store.deletePlan(m.batchKey(),m.customerId()));
}
else if("RESCHEDULE".equals(request.action())) {
batches.validateReceiving(members,request.details());
if(Objects.equals(group.details(),request.details())) return new Result(null,"UNCHANGED",List.copyOf(snapshots.values()),List.of(group),List.of(),List.of("REVIEW_TRANSPORT"));
changed=store.update(group,members,request.details(),request.warehouseEvidence());
if(changed.equals(group)) return new Result(null,"UNCHANGED",List.copyOf(snapshots.values()),List.of(group),List.of(),List.of("REVIEW_TRANSPORT"));
} else throw bad("请选择整份改期或整份取消");
var projected=keys.stream().map(k->batches.projectReceiving(k,"CANCEL".equals(request.action()))).toList();
return new Result(null,"APPLIED",projected,List.of(changed),List.of(new Step(request.action(),"SUCCESS",key,"按服务端当前覆盖成员处理,车辆和批次数量保留")),List.of("CANCEL".equals(request.action())?"REBOOK_GROUP":"REVIEW_TRANSPORT"));
});
}
private Map<String,FbaAppointmentResponse> lock(List<String> keys) {var result=new LinkedHashMap<String,FbaAppointmentResponse>();for(var key:keys) result.put(key,batches.lockReceivingBatch(key));return result;}
private void recheckTransport(List<String> keys,FbaAppointmentTransportPort.Scope locked) {
// Original bindings also lock the batch. Re-read after acquiring it to reject a new
// association created between the initial transport locks and the batch lock.
var current=transport.inspect(keys);
if(!Objects.equals(locked.version(),current.version())) throw conflict("运输关联已变化,请重新核对当前范围");
requireMutable(current);
}
private static void requireRebookable(List<FbaAppointmentExecutionPort.Member> members,FbaAppointmentExecutionPort.Scope scope) {
if(scope==null || scope.members()==null || !new HashSet<>(members).equals(scope.members().stream().map(FbaAppointmentExecutionPort.Eligibility::member).collect(java.util.stream.Collectors.toSet()))) throw conflict("客户执行资格读取不完整");
for(var member:scope.members()) if(member.completed() || !member.canRebook()) throw conflict(member.completed()?"已完成货物不能重新预约":blank(member.reason())?"本客户已开始执行,请从原运输异常处理":member.reason());
}
private static void validateQuantities(Member member,ChangeIntent intent) {
if(intent.quantities()==null || intent.quantities().size()!=member.sources().size()) throw bad("请填写本客户每票原来源的本次先送箱数");
var seen=new HashSet<String>();boolean changed=false;
for(var q:intent.quantities()) {
if(!seen.add(q.sourceKey()+"/"+q.destinationKey())) throw bad("来源不可重复");
var source=member.sources().stream().filter(s->s.sourceKey().equals(q.sourceKey()) && s.destinationKey().equals(q.destinationKey())).findFirst().orElseThrow(()->bad("所选来源不属于本客户批次"));
if(q.retainedPackages()<0 || q.retainedPackages()>source.packageCount()) throw bad("本次先送量须在零至本批箱数之间");
if(!Objects.equals(q.expectedSourceVersion(),source.sourceVersion()) || !Objects.equals(q.expectedVerificationVersion(),source.verificationVersion())) throw conflict("来源或核实版本已变化");
changed|=q.retainedPackages()!=source.packageCount();
}
if(!changed) throw bad("本次货量没有变化,无需保存调整");
}
private Result command(String operation,String operator,String key,Object request,Supplier<Result> action) {
if(blank(operator)||blank(key)||key.length()>64) throw bad("操作人及请求标识不能为空");
String hash;
try {hash=HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest((operation+"\n"+operator+"\n"+store.write(request)).getBytes(java.nio.charset.StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}
var existing=commands.find(key);if(existing!=null) return replay(existing,hash);
try {commands.begin(key,hash,operation,operator,LocalDateTime.now(ZoneOffset.UTC));}catch(DuplicateKeyException duplicate){return replay(commands.find(key),hash);}
try {
var result=action.get();
if(commands.finish(key,result.batches().isEmpty()?null:result.batches().get(0).appointmentNo(),store.write(result))!=1) throw conflict("命令结果未保存");
return result;
} catch(DuplicateKeyException duplicate) {throw conflict("当前目的仓约号或客户覆盖已被其他预约使用,请重新读取范围");}
}
private Result replay(FbaAppointmentCommandMapper.Command row,String hash) {
if(row==null || !hash.equals(row.requestHash()) || row.responseJson()==null) throw conflict("同一请求标识的内容不同或结果尚未就绪");
return store.read(row.responseJson(),Result.class);
}
private static void requireVersion(FbaAppointmentResponse batch,int version) {if(batch.version()!=version) throw conflict("批次已更新,请刷新后重新核对");}
private static void requireActive(Agreement group) {if(!"ACTIVE".equals(group.status())) throw conflict("原收货预约已失效,请使用重新预约入口");}
private static void requireMutable(FbaAppointmentTransportPort.Scope scope) {if(!scope.blockReasons().isEmpty()) throw conflict(String.join(";",scope.blockReasons()));}
private static boolean blank(String s) {return s==null || s.isBlank();}
private static ResponseStatusException bad(String message) {return new ResponseStatusException(HttpStatus.BAD_REQUEST,message);}
}
@@ -0,0 +1,147 @@
package com.baiyue.lastleg.inboundplan.receiving;
import com.baiyue.lastleg.inboundplan.dto.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
import static com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*;
/** Persisted receiving authority. All mutations run inside the caller's batch-locked transaction. */
@Component
public class FbaReceivingStore {
private final FbaReceivingMapper mapper;
private final ObjectMapper json;
public FbaReceivingStore(FbaReceivingMapper mapper,ObjectMapper json) { this.mapper=mapper;this.json=json; }
public boolean hasPending(String batchKey,String customerId) { return mapper.pendingCount(batchKey,customerId)>0; }
/** Three bounded queries for calendar/work providers; never a per-batch query loop. */
public List<BatchReceiving> snapshots(List<String> batchKeys) {
if(batchKeys==null || batchKeys.isEmpty()) return List.of();
if(batchKeys.size()>200 || batchKeys.stream().anyMatch(k->k==null || !k.matches("FBA_APPOINTMENT:[1-9][0-9]*"))) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"每次最多读取200个有效批次");
var groups=new HashMap<String,List<Agreement>>();var pending=new HashMap<String,List<Pending>>();var plans=new HashMap<String,List<CustomerPlan>>();
for(var row:mapper.currentGroups(batchKeys)) groups.computeIfAbsent(row.batchKey(),k->new ArrayList<>()).add(read(row.snapshotJson(),Agreement.class));
for(var row:mapper.pendingGroups(batchKeys)) pending.computeIfAbsent(row.batchKey(),k->new ArrayList<>()).add(read(row.snapshotJson(),Pending.class));
for(var row:mapper.plans(batchKeys)) plans.computeIfAbsent(row.batchKey(),k->new ArrayList<>()).add(read(row.snapshotJson(),CustomerPlan.class));
return batchKeys.stream().distinct().map(k->new BatchReceiving(k,List.copyOf(groups.getOrDefault(k,List.of())),List.copyOf(pending.getOrDefault(k,List.of())),List.copyOf(plans.getOrDefault(k,List.of())))).toList();
}
public List<CustomerPlan> plans(String batchKey) { return mapper.plans(List.of(batchKey)).stream().map(r->read(r.snapshotJson(),CustomerPlan.class)).toList(); }
public CustomerPlan plan(String batchKey,String customerId) { return plans(batchKey).stream().filter(p->customerId.equals(p.customerId())).findFirst().orElse(null); }
public CustomerPlan savePlan(CustomerPlan input) {
var saved=new CustomerPlan(input.batchKey(),input.customerId(),normalized(input.details()),input.timezone());
mapper.deletePlan(saved.batchKey(),saved.customerId());
mapper.insertPlan(new FbaReceivingMapper.PlanRow(saved.batchKey(),saved.customerId(),write(saved)));return saved;
}
public void deletePlan(String batchKey,String customerId) { mapper.deletePlan(batchKey,customerId); }
/** Preserve each still-unconfirmed customer's old target before changing the aggregate header. */
public void freezeUnconfirmedPlans(FbaAppointmentResponse batch) {
if(!Set.of("PENDING","FAILED").contains(batch.appointmentStatus())) return;
var existing=plans(batch.batchKey()).stream().map(CustomerPlan::customerId).collect(java.util.stream.Collectors.toSet());
var covered=agreements(batch.batchKey()).stream().filter(a->"ACTIVE".equals(a.status())).flatMap(a->a.members().stream()).filter(m->batch.batchKey().equals(m.batchKey())).map(Member::customerId).collect(java.util.stream.Collectors.toSet());
var details=new FbaAppointmentDetails(batch.plannedDate(),batch.plannedAt(),batch.plannedEndAt(),null,null,null,batch.appointmentStatus(),batch.appointmentRemark(),null,null);
for(var customer:batch.items().stream().map(FbaAppointmentResponse.Item::customerId).distinct().toList())
if(!existing.contains(customer) && !covered.contains(customer)) savePlan(new CustomerPlan(batch.batchKey(),customer,details,batch.timezone()));
}
public Agreement currentAgreement(String batchKey,String customerId) {
var key=mapper.currentKey(batchKey,customerId);return key==null?null:agreement(key,false);
}
public List<Agreement> agreements(String batchKey) { return mapper.groupKeys(batchKey).stream().map(k->agreement(k,false)).toList(); }
public List<Pending> pending(String batchKey) { return mapper.pending(batchKey).stream().map(r->read(r.snapshotJson(),Pending.class)).toList(); }
public List<String> successorKeys(String batchKey) {
return mapper.appliedResults(batchKey).stream().map(r->read(r,Result.class)).flatMap(r->r.batches().stream())
.map(FbaAppointmentResponse::batchKey).filter(k->!k.equals(batchKey)).distinct().toList();
}
public List<com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef> originalSources(String batchKey) {
return mapper.appliedChanges(batchKey).stream().map(r->read(r,Pending.class)).flatMap(p->p.originalMembers().stream())
.flatMap(m->m.sources().stream().map(s->new com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef(m.customerId(),s.sourceKey(),s.destinationKey()))).distinct().toList();
}
public Agreement agreement(String key,boolean lock) {
var row=lock?mapper.lockGroup(key):mapper.group(key);
if(row==null) throw new ResponseStatusException(HttpStatus.NOT_FOUND,"收货预约不存在");
return read(row.snapshotJson(),Agreement.class);
}
public List<Member> members(FbaAppointmentResponse batch) {
return members(batch,null);
}
public Member member(FbaAppointmentResponse batch,String customerId) {return members(batch,customerId).stream().findFirst().orElse(null);}
private List<Member> members(FbaAppointmentResponse batch,String customerId) {
var labels=new HashMap<String,String>();for(var label:mapper.labels(batch.batchKey())) if(label.customerName()!=null) labels.putIfAbsent(label.customerId(),label.customerName());
var grouped=new LinkedHashMap<String,List<Source>>();
for(var i:batch.items()) {
if(customerId!=null && !customerId.equals(i.customerId())) continue;
if(i.packageCount()==null || i.packageCount()<=0) throw conflict("请先核实本批各票箱数,再调整收货范围");
grouped.computeIfAbsent(i.customerId(),ignored->new ArrayList<>()).add(new Source(i.sourceKey(),i.destinationKey(),i.packageCount(),i.sourceVersion(),i.cargoVerificationVersion(),i.inboundCode()));
}
return grouped.entrySet().stream().map(e->new Member(batch.batchKey(),batch.appointmentNo(),batch.version(),e.getKey(),List.copyOf(e.getValue()),labels.get(e.getKey()))).toList();
}
public Agreement create(List<Member> members,FbaAppointmentDetails details,String timezone,String evidence) {
details=normalized(details);
String destination=members.get(0).sources().get(0).destinationKey();
var group=new Agreement("RA-"+UUID.randomUUID().toString().replace("-","").toUpperCase(Locale.ROOT),0,"ACTIVE",destination,details,timezone,List.copyOf(members),evidence);
try {
mapper.insertGroup(row(group));mapper.revision(row(group));
for(var member:members) mapper.addMember(group.agreementKey(),member.batchKey(),member.customerId(),member.batchKey()+"/"+member.customerId());
} catch(org.springframework.dao.DuplicateKeyException duplicate) {throw conflict("该目的仓约号或客户覆盖已被其他有效预约使用");}
return group;
}
public Agreement update(Agreement old,List<Member> members,FbaAppointmentDetails details,String evidence) {
details=normalized(details);
if(Objects.equals(old.details(),details) && coverage(old.members()).equals(coverage(members))) return old;
var group=new Agreement(old.agreementKey(),old.version()+1,old.status(),old.destinationKey(),details,old.timezone(),List.copyOf(members),evidence);
try {mapper.updateGroup(row(group));mapper.revision(row(group));} catch(org.springframework.dao.DuplicateKeyException duplicate) {throw conflict("该目的仓约号已被其他有效预约使用");}return group;
}
public Agreement cancel(Agreement old,String status,String evidence) {
var group=new Agreement(old.agreementKey(),old.version()+1,status,old.destinationKey(),old.details(),old.timezone(),old.members(),evidence);
mapper.releaseGroup(old.agreementKey());mapper.updateGroup(row(group));mapper.revision(row(group));return group;
}
public void releaseMember(String group,String batch,String customer) { mapper.releaseMember(group,batch,customer); }
public void assertLegacyEditable(String batchKey) {
assertRepartitionable(batchKey);
if(!plans(batchKey).isEmpty()) throw conflict("本批已有按客户维护的计划,请按客户继续处理,不能整批覆盖或取消");
}
public void assertRepartitionable(String batchKey) {
if(!pending(batchKey).isEmpty()) throw conflict("本批有待仓库确认的变化,请从变化记录继续处理");
var active=agreements(batchKey).stream().filter(a->"ACTIVE".equals(a.status())).toList();
if(active.size()>1 || active.stream().anyMatch(a->a.members().stream().anyMatch(m->!m.batchKey().equals(batchKey))))
throw conflict("本批属于独立收货或跨批共同预约,请使用明确范围的收货预约操作");
if(!active.isEmpty()) {
var actual=new HashSet<>(mapper.liveSources(batchKey));
var covered=new HashSet<FbaReceivingMapper.LiveSource>();
for(var member:active.get(0).members()) {
if(!Objects.equals(mapper.currentKey(batchKey,member.customerId()),active.get(0).agreementKey())) throw conflict("本批客户覆盖关系已变化,请按当前收货范围操作");
for(var source:member.sources()) covered.add(new FbaReceivingMapper.LiveSource(member.customerId(),source.sourceKey(),source.destinationKey(),source.packageCount()));
}
if(!actual.equals(covered)) throw conflict("本批仅部分客户已取得确认,请分别处理当前客户或明确共同预约范围");
}
}
/** Existing single-batch commands produce the same receiving authority; no read-time reconstruction. */
public void synchronize(Object result) {
if(!(result instanceof FbaAppointmentResponse batch)) return;
var active=agreements(batch.batchKey()).stream().filter(a->"ACTIVE".equals(a.status())).toList();
if("SUCCESS".equals(batch.appointmentStatus())) {
var details=new FbaAppointmentDetails(batch.plannedDate(),batch.plannedAt(),batch.plannedEndAt(),batch.confirmationNo(),batch.appointmentAt(),batch.appointmentEndAt(),"SUCCESS",batch.appointmentRemark(),null,null);
if(active.isEmpty()) create(members(batch),details,batch.timezone(),"登记收货方正式确认");
else update(active.get(0),members(batch),details,"登记收货方正式确认");
} else for(var agreement:active) cancel(agreement,"CANCELLED".equals(batch.appointmentStatus())?"CANCELLED":"SUPERSEDED","原预约结果已变更");
}
public Pending change(String key,boolean lock) {
var row=lock?mapper.lockChange(key):mapper.change(key);
if(row==null) throw new ResponseStatusException(HttpStatus.NOT_FOUND,"预约变化记录不存在");
return read(row.snapshotJson(),Pending.class);
}
public void save(Pending change) { mapper.insertChange(new FbaReceivingMapper.ChangeRow(change.changeKey(),change.version(),change.status(),change.intent().batchKey(),change.intent().customerId(),write(change),null)); }
public void finish(Pending old,String status,Result result) {
var updated=new Pending(old.changeKey(),old.version()+1,status,old.intent(),old.originalMembers(),old.originalAgreements(),old.followup());
mapper.finishChange(new FbaReceivingMapper.ChangeRow(old.changeKey(),updated.version(),status,old.intent().batchKey(),old.intent().customerId(),write(updated),write(result)));
}
private static List<String> coverage(List<Member> members) {
return members.stream().flatMap(m->m.sources().stream().map(s->List.of(m.batchKey(),m.customerId(),s.sourceKey(),s.destinationKey(),s.packageCount(),Objects.toString(s.sourceVersion(),""),Objects.toString(s.verificationVersion(),"")).toString())).sorted().toList();
}
static FbaAppointmentDetails normalized(FbaAppointmentDetails d) {
return new FbaAppointmentDetails(d.plannedAt()==null?d.plannedDate():d.plannedAt().toLocalDate(),d.plannedAt(),d.plannedEndAt(),d.confirmationNo()==null?null:d.confirmationNo().trim(),d.appointmentAt(),d.appointmentEndAt(),d.appointmentStatus(),d.appointmentRemark()==null?null:d.appointmentRemark().trim(),d.startOffset(),d.endOffset());
}
private FbaReceivingMapper.GroupRow row(Agreement a) { return new FbaReceivingMapper.GroupRow(a.agreementKey(),a.version(),a.status(),a.destinationKey(),"ACTIVE".equals(a.status())?a.details().confirmationNo():null,write(a)); }
public String write(Object value) {try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException("预约记录无法保存",e);} }
public <T>T read(String value,Class<T> type) {try{return json.readValue(value,type);}catch(Exception e){throw new IllegalStateException("预约记录无法读取",e);} }
static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT,message); }
}
@@ -31,6 +31,8 @@ public class InboundFbaAppointmentService {
@Resource private AppointmentBatchPreviewMapper flags;
@Resource private AppointmentCargoVerificationService verification;
@Resource private ObjectMapper json;
@Resource private com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving;
@Resource private com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort receivingWork;
@Resource private com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges workChanges;
@Transactional(readOnly=true)
@@ -38,7 +40,7 @@ public class InboundFbaAppointmentService {
if(from==null || to==null || !to.isAfter(from) || ChronoUnit.DAYS.between(from,to)>42) throw bad("日历查询范围必须为1至42天");
var query=new LambdaQueryWrapper<InboundFbaAppointment>()
.isNull(InboundFbaAppointment::getReplacementNosJson)
.notIn(InboundFbaAppointment::getAppointmentStatus,List.of("CANCELLED","FAILED"))
.ne(InboundFbaAppointment::getAppointmentStatus,"CANCELLED")
.apply("COALESCE(appointment_at,planned_at,planned_date)<{1} AND (COALESCE(appointment_at,planned_at,planned_date)>={0} OR CASE WHEN appointment_at IS NOT NULL THEN appointment_end_at WHEN planned_at IS NOT NULL THEN planned_end_at ELSE NULL END>{0})",from.atStartOfDay(),to.atStartOfDay())
.orderByAsc(InboundFbaAppointment::getId);
return responses(appointmentMapper.selectList(query));
@@ -49,8 +51,7 @@ public class InboundFbaAppointmentService {
var query=new LambdaQueryWrapper<InboundFbaAppointment>()
.isNull(InboundFbaAppointment::getReplacementNosJson)
.isNull(InboundFbaAppointment::getAppointmentAt).isNull(InboundFbaAppointment::getPlannedAt)
.isNull(InboundFbaAppointment::getPlannedDate)
.notIn(InboundFbaAppointment::getAppointmentStatus,List.of("CANCELLED","FAILED"));
.isNull(InboundFbaAppointment::getPlannedDate);
long total=appointmentMapper.selectCount(query);
var rows=appointmentMapper.selectList(query.orderByDesc(InboundFbaAppointment::getCreatedAt)
.last("LIMIT "+size+" OFFSET "+(long)(page-1)*size));
@@ -116,12 +117,17 @@ public class InboundFbaAppointmentService {
try {id=Long.parseLong(key.substring("FBA_APPOINTMENT:".length()));if(id<=0 || !key.equals(batchKey(id))) throw new IllegalArgumentException();}
catch(RuntimeException error) {throw bad("批次关联键无效");}
var appointment=appointmentMapper.selectById(id);if(appointment==null) throw missing();
var rows=items(id);var snapshot=response(appointment,rows);
var rows=items(id);if(appointment.getReplacementNosJson()==null) rows=rows.stream().filter(i->Boolean.TRUE.equals(i.getActive())).toList();var snapshot=response(appointment,rows);
var originalRefs=originalSourceRefs(snapshot);
var reasons=new ArrayList<String>();var actions=new ArrayList<String>();
if(appointment.getReplacementNosJson()!=null) reasons.add("原批次已被分批或合并,请进入替代批次");
if(!"PENDING".equals(appointment.getAppointmentStatus())) reasons.add("仅未取得正式确认的活动批次可分批或合并");
if(queryMapper.countActiveDispatches(id)>0) reasons.add("已关联排车,请先处理运输安排");
if(!reasons.isEmpty()) return new FbaAppointmentBatchContext(snapshot,List.of(),List.of(),List.copyOf(reasons));
try {receiving.assertRepartitionable(key);} catch(ResponseStatusException error) {
if(error.getStatusCode().value()!=409) throw error;
reasons.add(error.getReason());
}
if(!reasons.isEmpty()) return new FbaAppointmentBatchContext(snapshot,List.of(),List.of(),List.copyOf(reasons),currentBatches(snapshot,originalRefs),originalRefs);
var refs=rows.stream().map(item->new AppointmentCoverageService.SourceRef(item.getCustomerId(),item.getSourceKey(),"FBA:"+appointment.getFbaWarehouseCode())).toList();
var resolved=sources.resolveBatch(refs);var evidence=verification(resolved);
if(resolved.stream().anyMatch(s->!Objects.equals(s.originWarehouseId(),appointment.getOriginWarehouseId()) || !Objects.equals(s.destinationWarehouseCode(),appointment.getFbaWarehouseCode()))) reasons.add("原来源起讫点已变化,请先复核原批次");
@@ -143,7 +149,42 @@ public class InboundFbaAppointmentService {
if(allocations.stream().mapToLong(a->a.packageCount()).sum()>1) actions.add("SPLIT");
actions.add("MERGE");
}
return new FbaAppointmentBatchContext(snapshot,List.copyOf(allocations),List.copyOf(actions),reasons.stream().distinct().toList());
return new FbaAppointmentBatchContext(snapshot,List.copyOf(allocations),List.copyOf(actions),reasons.stream().distinct().toList(),currentBatches(snapshot,originalRefs),originalRefs);
}
private List<FbaAppointmentBatchContext.SourceRef> originalSourceRefs(FbaAppointmentResponse original) {
var refs=new LinkedHashSet<FbaAppointmentBatchContext.SourceRef>();
original.items().forEach(item->refs.add(new FbaAppointmentBatchContext.SourceRef(item.customerId(),item.sourceKey(),item.destinationKey())));
receiving.originalSources(original.batchKey()).forEach(ref->refs.add(new FbaAppointmentBatchContext.SourceRef(ref.customerId(),ref.sourceKey(),ref.destinationKey())));
return List.copyOf(refs);
}
/** Follow all replacement branches. Shared merge descendants are returned once, never just the first branch. */
private List<FbaAppointmentResponse> currentBatches(FbaAppointmentResponse original,List<FbaAppointmentBatchContext.SourceRef> originalRefs) {
var sourceRefs=new HashSet<>(originalRefs);
var graph=new LinkedHashMap<String,FbaAppointmentResponse>();graph.put(original.batchKey(),original);
var pending=new LinkedHashSet<>(original.replacementBatchKeys());
pending.addAll(receiving.successorKeys(original.batchKey()));
while(!pending.isEmpty()) {
if(graph.size()+pending.size()>1000) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"批次去向超过读取范围,请缩小范围后核对");
var keys=new ArrayList<>(pending);pending.clear();
for(int start=0;start<keys.size();start+=200) {
for(var batch:batchSnapshots(keys.subList(start,Math.min(start+200,keys.size())))) graph.put(batch.batchKey(),batch);
}
for(var batch:graph.values()) {
var nextKeys=new ArrayList<>(batch.replacementBatchKeys());nextKeys.addAll(receiving.successorKeys(batch.batchKey()));
for(var next:nextKeys) if(!graph.containsKey(next)) pending.add(next);
}
}
var visiting=new HashSet<String>();var visited=new HashSet<String>();
verifyReplacementGraph(original.batchKey(),graph,visiting,visited);
return graph.values().stream().filter(batch->batch.replacementAppointmentNos().isEmpty())
.filter(batch->batch.items().stream().anyMatch(item->sourceRefs.contains(new FbaAppointmentBatchContext.SourceRef(item.customerId(),item.sourceKey(),item.destinationKey()))))
.toList();
}
private static void verifyReplacementGraph(String key,Map<String,FbaAppointmentResponse> graph,Set<String> visiting,Set<String> visited) {
if(visited.contains(key)) return;
if(!visiting.add(key)) throw conflict("批次去向存在循环,请核对原业务记录");
for(var next:graph.get(key).replacementBatchKeys()) verifyReplacementGraph(next,graph,visiting,visited);
visiting.remove(key);visited.add(key);
}
/** Original persisted appointment facts only; reading work status must not run split/merge eligibility. */
@Transactional(readOnly=true)
@@ -165,15 +206,18 @@ public class InboundFbaAppointmentService {
return command("UPDATE:"+appointmentNo,operatorId,request.idempotencyKey(),request,()->{
var appointment=find(appointmentNo,true);if(appointment==null) throw missing();
requireCurrent(appointment);
receiving.assertLegacyEditable(batchKey(appointment.getId()));
if(!Objects.equals(appointment.getVersion(),request.version())) throw conflict("预约已更新,请重新读取后操作");
if(queryMapper.countActiveDispatches(appointment.getId())>0) throw conflict("已关联有效排车,请先核对并处理运输安排后再改约");
var current=items(appointment.getId());
var current=items(appointment.getId()).stream().filter(i->Boolean.TRUE.equals(i.getActive())).toList();
var before=response(appointment,current);
String status=status(request.details().appointmentStatus());
if("SUCCESS".equals(appointment.getAppointmentStatus()) && !"SUCCESS".equals(status)) throw bad("已确认预约不能直接退回待确认或未约成,请使用取消操作并记录原因");
if("FAILED".equals(status)) {
if(clean(request.details().appointmentRemark())==null) throw bad("未约成时请填写原因");
// Keep the last planned and confirmed times as historical facts.
// Failure releases receiving coverage, not the cargo allocation owned by this batch.
appointment.setAppointmentStatus(status);appointment.setAppointmentRemark(request.details().appointmentRemark().trim());
current.forEach(item->{item.setActive(false);itemMapper.updateById(item);});
clearConfirmation(appointment);
} else {
var refs=current.stream().map(item->new AppointmentCoverageService.SourceRef(item.getCustomerId(),item.getSourceKey(),"FBA:"+appointment.getFbaWarehouseCode())).toList();
var resolved=resolveForWrite(refs);
@@ -183,6 +227,7 @@ public class InboundFbaAppointmentService {
apply(appointment,request.details(),resolved,evidence);
writeItems(appointment,resolved,current,evidence,Map.of());
}
if(before.equals(response(appointment,items(appointment.getId())))) return before;
appointment.setVersion(request.version()+1);appointment.setUpdatedAt(now());
if(appointmentMapper.update(appointment,new LambdaQueryWrapper<InboundFbaAppointment>().eq(InboundFbaAppointment::getId,appointment.getId()).eq(InboundFbaAppointment::getVersion,request.version()))!=1) throw conflict("预约版本已变化");
return response(appointment,items(appointment.getId()));
@@ -196,15 +241,26 @@ public class InboundFbaAppointmentService {
command("CANCEL:"+appointmentNo,operatorId,"CANCEL:"+appointmentNo+":"+version,cancellation,()->{
var appointment=find(appointmentNo,true);if(appointment==null) throw missing();
requireCurrent(appointment);
receiving.assertLegacyEditable(batchKey(appointment.getId()));
if(!Objects.equals(appointment.getVersion(),version)) throw conflict("预约已更新,请重新读取后操作");
if(queryMapper.countActiveDispatches(appointment.getId())>0) throw conflict("预约货物已关联排车,不允许取消,请先处理关联排车");
appointment.setAppointmentStatus("CANCELLED");
appointment.setVersion(version+1);appointment.setUpdatedAt(now());appointmentMapper.updateById(appointment);
var rows=items(appointment.getId());rows.forEach(item->{item.setActive(false);itemMapper.updateById(item);});
return response(appointment,rows);
appointment.setVersion(version+1);appointment.setUpdatedAt(now());
var rows=items(appointment.getId());
// Persist the cancelled plan in the immutable command history; the active demand has no valid date/confirmation.
var cancelledSnapshot=response(appointment,rows);
clearConfirmation(appointment);
appointment.setPlannedDate(null);appointment.setPlannedAt(null);appointment.setPlannedEndAt(null);
appointmentMapper.updateById(appointment);
return cancelledSnapshot;
});
}
private static void clearConfirmation(InboundFbaAppointment appointment) {
appointment.setConfirmationNo(null);appointment.setAppointmentAt(null);appointment.setAppointmentEndAt(null);
appointment.setAppointmentStartUtc(null);appointment.setAppointmentEndUtc(null);
}
@Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public RepartitionFbaAppointmentsResponse repartition(String operator,RepartitionFbaAppointmentsRequest request) {
validateRepartition(request);
@@ -213,7 +269,11 @@ public class InboundFbaAppointmentService {
var originals=new ArrayList<InboundFbaAppointment>();
var originalItems=new LinkedHashMap<Long,List<InboundFbaAppointmentItem>>();
var refs=new LinkedHashMap<String,AppointmentCoverageService.SourceRef>();
for(var intent:request.originals().stream().sorted(Comparator.comparing(RepartitionFbaAppointmentsRequest.Original::appointmentNo)).toList()) {
var intents=request.originals().stream().collect(Collectors.toMap(RepartitionFbaAppointmentsRequest.Original::appointmentNo,i->i));
var ordered=request.originals().stream().map(i->{var row=find(i.appointmentNo(),false);if(row==null)throw missing();return row;})
.sorted(Comparator.comparing(row->batchKey(row.getId()))).toList();
for(var candidate:ordered) {
var intent=intents.get(candidate.getAppointmentNo());
var row=find(intent.appointmentNo(),true);if(row==null) throw missing();
requireCurrent(row);
if(!Objects.equals(row.getVersion(),intent.version())) throw conflict("原预约已更新,请重新核对分批资料");
@@ -222,7 +282,8 @@ public class InboundFbaAppointmentService {
if(queryMapper.countActiveDispatches(row.getId())>0) throw conflict("原批次已有关联排车,请先处理运输安排");
if(!originals.isEmpty() && (!Objects.equals(originals.get(0).getOriginWarehouseId(),row.getOriginWarehouseId())
|| !Objects.equals(originals.get(0).getFbaWarehouseCode(),row.getFbaWarehouseCode()))) throw bad("仅同一起运仓、同一目的仓可以合并");
var items=items(row.getId());
receiving.assertRepartitionable(batchKey(row.getId()));
var items=items(row.getId()).stream().filter(i->Boolean.TRUE.equals(i.getActive())).toList();
if(items.isEmpty() || items.stream().anyMatch(item->!Boolean.TRUE.equals(item.getActive()))) throw conflict("原批次货物已变化,请重新核对");
for(var item:items) {
var ref=new AppointmentCoverageService.SourceRef(item.getCustomerId(),item.getSourceKey(),"FBA:"+row.getFbaWarehouseCode());
@@ -284,6 +345,9 @@ public class InboundFbaAppointmentService {
row.setReplacementNosJson(replacementsJson);row.setReplacementKeysJson(replacementKeysJson);row.setRepartitionReason(request.reason().trim());row.setVersion(row.getVersion()+1);row.setUpdatedAt(now());
if(appointmentMapper.updateById(row)!=1) throw conflict("原批次替代关系未保存");
}
var oldMembers=originalItems.entrySet().stream().flatMap(e->e.getValue().stream().map(i->new com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort.Member(batchKey(e.getKey()),i.getCustomerId()))).distinct().toList();
var newMembers=replacements.stream().flatMap(b->b.items().stream().map(i->new com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort.Member(b.batchKey(),i.customerId()))).distinct().toList();
receivingWork.inheritFollowup(oldMembers,newMembers,operator);
return new RepartitionFbaAppointmentsResponse(originals.stream().map(row->batchKey(row.getId())).toList(),originals.stream().map(InboundFbaAppointment::getAppointmentNo).toList(),List.copyOf(replacements));
});
}
@@ -304,6 +368,164 @@ public class InboundFbaAppointmentService {
}
}
/** Receiving orchestration acquires transport locks first, then invokes this boundary. */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public FbaAppointmentResponse lockReceivingBatch(String key) {
long id=parseBatchKey(key);
var row=appointmentMapper.selectOne(new LambdaQueryWrapper<InboundFbaAppointment>().eq(InboundFbaAppointment::getId,id).last("FOR UPDATE"));
if(row==null) throw missing();requireCurrent(row);
return response(row,items(id));
}
/** Explicit adoption updates only the chosen customer's evidence, never their allocated amount. */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public FbaAppointmentResponse refreshReceivingCargo(String key,String customerId,List<com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.CargoVersion> expected) {
var snapshot=lockReceivingBatch(key);var row=appointmentMapper.selectById(snapshot.id());
var current=items(row.getId()).stream().filter(i->Boolean.TRUE.equals(i.getActive())).toList();
var selected=current.stream().filter(i->Objects.equals(customerId,i.getCustomerId())).toList();
if(selected.isEmpty() || expected==null || expected.size()!=selected.size() || expected.size()>100) throw bad("请完整核对本客户当前批次的每票来源资料");
var versions=new HashMap<String,com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.CargoVersion>();
for(var value:expected) {
if(value==null || clean(value.sourceKey())==null || clean(value.destinationKey())==null || clean(value.expectedSourceVersion())==null || value.expectedVerificationVersion()!=null && value.expectedVerificationVersion()<1 || versions.putIfAbsent(value.sourceKey(),value)!=null)
throw bad("来源不能重复,必须明确当前来源及核实版本");
}
var refs=selected.stream().map(i->new AppointmentCoverageService.SourceRef(i.getCustomerId(),i.getSourceKey(),"FBA:"+i.getFbaWarehouseCode())).toList();
var resolved=resolveForWrite(refs);var evidence=verification(resolved);
var bySource=resolved.stream().collect(Collectors.toMap(AppointmentSourceService.Source::sourceKey,s->s));
for(var item:selected) {
var requested=versions.get(item.getSourceKey());var source=bySource.get(item.getSourceKey());
var verified=source==null?null:evidence.get(source.sourceKey());
if(requested==null || source==null || !Objects.equals(customerId,source.customerId()) || !Objects.equals("FBA:"+item.getFbaWarehouseCode(),source.destinationKey())
|| !Objects.equals(source.destinationKey(),requested.destinationKey()) || !Objects.equals(row.getOriginWarehouseId(),source.originWarehouseId())
|| !Objects.equals(source.sourceVersion(),requested.expectedSourceVersion()) || !Objects.equals(verified==null?null:verified.version(),requested.expectedVerificationVersion()))
throw conflict("本票归属、目的地或最新资料版本已变化,请重新预览后确认");
var total=quantity(source,evidence);var count=item.getPackageCount();
var other=queryMapper.allocation(customerId,source.sourceKey(),source.orderId(),row.getId());
if(count==null || count<=0 || total==null || other.unknownCount()>0 || (long)count+other.packages()>total) throw conflict("最新核实量不足以覆盖当前全部批次,请先处理数量差异,不能直接采用资料");
item.setSourceVersion(source.sourceVersion());item.setCargoVerificationVersion(verified==null?null:verified.version());
try {item.setShipmentReferencesJson(verified==null?null:json.writeValueAsString(verified.shipmentReferences()));}
catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException("核实货件资料无法保存",error);}
}
if(snapshot.equals(response(row,current))) return snapshot;
selected.forEach(itemMapper::updateById);
row.setVersion(row.getVersion()+1);row.setUpdatedAt(now());appointmentMapper.updateById(row);
var result=response(row,current);workChanges.changed(result);return result;
}
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public String validateReceiving(List<com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Member> members,FbaAppointmentDetails details) {
if(members==null || members.isEmpty() || members.size()>100 || details==null || !"SUCCESS".equals(details.appointmentStatus())) throw bad("请填写明确覆盖货物的正式收货确认");
return validateReceivingDetails(members,details);
}
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public String validateReceivingPlan(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Member member,FbaAppointmentDetails details) {
if(details==null || !Set.of("PENDING","FAILED").contains(details.appointmentStatus())) throw bad("客户计划只允许待确认或未约成,正式确认请使用登记确认操作");
if(details.confirmationNo()!=null || details.appointmentAt()!=null || details.appointmentEndAt()!=null) throw bad("客户计划不能填写外部约号或确认时段");
if(details.plannedAt()==null && (clean(details.startOffset())!=null || clean(details.endOffset())!=null)) throw bad("未填写计划开始时刻,不能指定计划时差");
if("FAILED".equals(details.appointmentStatus()) && clean(details.appointmentRemark())==null) throw bad("未约成时请填写原因");
var timezone=validateReceivingDetails(List.of(member),details);
if(details.plannedDate()!=null || details.plannedAt()!=null) {
if(timezone==null || !ZoneId.getAvailableZoneIds().contains(timezone)) throw bad("填写目标日期前请补齐目的仓IANA时区");
try {ZoneId.of(timezone);} catch(DateTimeException error) {throw bad("目的仓IANA时区无效");}
}
if(details.plannedAt()!=null) {
try {AppointmentTimePolicy.resolve(details.plannedAt(),details.plannedEndAt(),timezone,offset(details.startOffset()),offset(details.endOffset()));}
catch(IllegalArgumentException | DateTimeException error) {throw bad(error.getMessage());}
}
return timezone;
}
private String validateReceivingDetails(List<com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Member> members,FbaAppointmentDetails details) {
var refs=members.stream().flatMap(m->m.sources().stream().map(s->new AppointmentCoverageService.SourceRef(m.customerId(),s.sourceKey(),s.destinationKey()))).distinct().toList();
var resolved=resolveForWrite(refs);var evidence=verification(resolved);var bySource=resolved.stream().collect(Collectors.toMap(AppointmentSourceService.Source::sourceKey,s->s));
for(var m:members) for(var s:m.sources()) {
var current=bySource.get(s.sourceKey());var verified=evidence.get(s.sourceKey());
if(current==null || !Objects.equals(current.customerId(),m.customerId()) || !Objects.equals(current.destinationKey(),s.destinationKey())
|| !Objects.equals(current.sourceVersion(),s.sourceVersion()) || !Objects.equals(verified==null?null:verified.version(),s.verificationVersion())) throw conflict("原来源或核实版本已变化,请先核对货物");
}
var grouped=group(resolved);var row=new InboundFbaAppointment();row.setOriginWarehouseId(grouped.originWarehouseId());row.setFbaWarehouseCode(resolved.get(0).destinationWarehouseCode());
apply(row,details,resolved,evidence);return row.getTimezone();
}
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public List<FbaAppointmentResponse> moveReceivingCustomer(String operator,com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.ChangeIntent intent) {
var original=find(lockReceivingBatch(intent.batchKey()).appointmentNo(),false);
if(!Objects.equals(original.getVersion(),intent.batchVersion())) throw conflict("原批次版本已变化,请重新核对调整范围");
var all=items(original.getId()).stream().filter(i->Boolean.TRUE.equals(i.getActive())).toList();
receiving.freezeUnconfirmedPlans(response(original,all));
var selected=all.stream().filter(i->intent.customerId().equals(i.getCustomerId())).toList();
if(selected.isEmpty() || selected.size()!=intent.quantities().size()) throw bad("请完整选择本客户的各票货物");
var amounts=new HashMap<String,com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Quantity>();
for(var q:intent.quantities()) if(amounts.put(q.sourceKey(),q)!=null) throw bad("逐票来源不能重复");
var refs=selected.stream().map(i->new AppointmentCoverageService.SourceRef(i.getCustomerId(),i.getSourceKey(),"FBA:"+i.getFbaWarehouseCode())).toList();
var resolved=resolveForWrite(refs);var evidence=verification(resolved);var bySource=resolved.stream().collect(Collectors.toMap(AppointmentSourceService.Source::sourceKey,s->s));
var laterAmounts=new LinkedHashMap<String,Integer>();
for(var item:selected) {
var q=amounts.get(item.getSourceKey());var src=bySource.get(item.getSourceKey());var v=evidence.get(item.getSourceKey());
if(q==null || src==null || !Objects.equals(src.destinationKey(),q.destinationKey()) || !Objects.equals(src.sourceVersion(),q.expectedSourceVersion())
|| !Objects.equals(v==null?null:v.version(),q.expectedVerificationVersion()) || !Objects.equals(src.sourceVersion(),item.getSourceVersion())) throw conflict("原来源或核实资料版本已变化");
Integer total=quantity(src,evidence),count=item.getPackageCount();
var other=queryMapper.allocation(src.customerId(),src.sourceKey(),src.orderId(),original.getId());
if(count==null || total==null || other.unknownCount()>0 || (long)count+other.packages()>total || q.retainedPackages()<0 || q.retainedPackages()>count) throw conflict("本次保留量及原来源分配总量不一致");
int later=count-q.retainedPackages();if(later>0) laterAmounts.put(item.getSourceKey(),later);
item.setPackageCount(q.retainedPackages());item.setActive(q.retainedPackages()>0);itemMapper.updateById(item);
}
if(laterAmounts.isEmpty()) throw bad("本次货量没有变化,无需创建变化记录");
var laterRows=resolved.stream().filter(s->laterAmounts.containsKey(s.sourceKey())).toList();
var later=new InboundFbaAppointment();later.setId(IdWorker.getId());later.setAppointmentNo("AP-"+LocalDate.now(ZoneOffset.UTC).toString().replace("-","")+"-"+UUID.randomUUID().toString().replace("-","").substring(0,12).toUpperCase(Locale.ROOT));
later.setOriginWarehouseId(original.getOriginWarehouseId());later.setFbaWarehouseCode(original.getFbaWarehouseCode());later.setVersion(0);later.setSelectedCount(laterRows.size());later.setCreatedBy(operator);later.setCreatedAt(now());later.setUpdatedAt(now());
apply(later,new FbaAppointmentDetails(intent.laterDate(),null,null,null,null,null,"PENDING",intent.reason(),null,null),laterRows,evidence);
appointmentMapper.insert(later);writeItems(later,laterRows,List.of(),evidence,laterAmounts);
var retained=all.stream().filter(i->Boolean.TRUE.equals(i.getActive())).toList();
if(retained.stream().noneMatch(i->intent.customerId().equals(i.getCustomerId()))) receiving.deletePlan(intent.batchKey(),intent.customerId());
original.setSelectedCount(retained.size());original.setVersion(original.getVersion()+1);original.setUpdatedAt(now());
if(retained.isEmpty()) {original.setReplacementNosJson(receiving.write(List.of(later.getAppointmentNo())));original.setReplacementKeysJson(receiving.write(List.of(batchKey(later.getId()))));original.setRepartitionReason(intent.reason());}
appointmentMapper.updateById(original);
return List.of(response(original,retained),response(later,items(later.getId())));
}
/** Header is only an aggregate projection; per-customer receiving identity remains authoritative. */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public FbaAppointmentResponse projectReceiving(String key,boolean cancelled) {
var row=appointmentMapper.selectById(parseBatchKey(key));var rows=items(row.getId()).stream().filter(i->Boolean.TRUE.equals(i.getActive())).toList();
var groups=rows.stream().map(InboundFbaAppointmentItem::getCustomerId).distinct().map(c->receiving.currentAgreement(key,c)).toList();
var active=groups.stream().filter(Objects::nonNull).toList();
clearConfirmation(row);
if(!groups.isEmpty() && groups.size()==active.size() && active.stream().map(a->a.agreementKey()).distinct().count()==1) {
var a=active.get(0);var d=a.details();row.setAppointmentStatus("SUCCESS");row.setConfirmationNo(d.confirmationNo());row.setAppointmentAt(d.appointmentAt());row.setAppointmentEndAt(d.appointmentEndAt());row.setTimezone(a.timezone());
var window=AppointmentTimePolicy.resolve(d.appointmentAt(),d.appointmentEndAt(),a.timezone(),offset(d.startOffset()),offset(d.endOffset()));
row.setAppointmentStartUtc(LocalDateTime.ofInstant(window.start(),ZoneOffset.UTC));row.setAppointmentEndUtc(window.end()==null?null:LocalDateTime.ofInstant(window.end(),ZoneOffset.UTC));
row.setPlannedDate(d.plannedDate());row.setPlannedAt(d.plannedAt());row.setPlannedEndAt(d.plannedEndAt());
} else {
var plans=receiving.plans(key).stream().collect(Collectors.toMap(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.CustomerPlan::customerId,p->p));
var customerIds=rows.stream().map(InboundFbaAppointmentItem::getCustomerId).distinct().toList();
var details=new ArrayList<FbaAppointmentDetails>();var zones=new ArrayList<String>();
for(int i=0;i<customerIds.size();i++) {
var agreement=groups.get(i);var plan=plans.get(customerIds.get(i));
if(agreement!=null) {details.add(agreement.details());zones.add(agreement.timezone());}
else if(plan!=null) {details.add(plan.details());zones.add(plan.timezone());}
}
boolean complete=details.size()==customerIds.size() && !details.isEmpty();
boolean failed=complete && active.isEmpty() && details.stream().allMatch(d->"FAILED".equals(d.appointmentStatus()));
row.setAppointmentStatus(failed?"FAILED":cancelled && active.isEmpty() && plans.isEmpty()?"CANCELLED":"PENDING");
row.setPlannedDate(null);row.setPlannedAt(null);row.setPlannedEndAt(null);row.setAppointmentRemark(null);
if(complete) {
var first=details.get(0);var zone=zones.get(0);
if(details.stream().allMatch(d->Objects.equals(d.plannedDate(),first.plannedDate()) && Objects.equals(d.plannedAt(),first.plannedAt()) && Objects.equals(d.plannedEndAt(),first.plannedEndAt())) && zones.stream().allMatch(z->Objects.equals(z,zone))) {
row.setPlannedDate(first.plannedDate());row.setPlannedAt(first.plannedAt());row.setPlannedEndAt(first.plannedEndAt());row.setTimezone(zone);
}
if(details.stream().allMatch(first::equals)) row.setAppointmentRemark(first.appointmentRemark());
}
}
row.setVersion(row.getVersion()+1);row.setUpdatedAt(now());appointmentMapper.updateById(row);
var snapshot=response(row,rows);workChanges.changed(snapshot);return snapshot;
}
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public void receivingChanged(List<FbaAppointmentResponse> snapshots) {snapshots.forEach(workChanges::changed);}
private static long parseBatchKey(String key) {
try {long id=Long.parseLong(key.substring("FBA_APPOINTMENT:".length()));if(id>0 && key.equals(batchKey(id)))return id;} catch(RuntimeException ignored) { }
throw bad("批次关联键无效");
}
private List<AppointmentSourceService.Source> resolveForWrite(List<AppointmentCoverageService.SourceRef> refs) {
sources.lockForWrite(refs);
var resolved=sources.resolveBatch(refs);
@@ -405,6 +627,7 @@ public class InboundFbaAppointmentService {
try {commands.begin(key,hash,operation,operator,now());}
catch(DuplicateKeyException duplicate){return replay(commands.find(key),hash,type);}
var response=action.get();
receiving.synchronize(response);
workChanges.changed(response);
try {if(commands.finish(key,appointmentNumber.apply(response),commandSnapshot(response,request))!=1)throw conflict("预约命令结果未能保存");}
catch(com.fasterxml.jackson.core.JsonProcessingException error){throw new IllegalStateException("无法保存预约命令结果",error);}
@@ -460,6 +683,7 @@ public class InboundFbaAppointmentService {
return rows.stream().map(row->response(row,grouped.getOrDefault(row.getId(),List.of()))).toList();
}
private FbaAppointmentResponse response(InboundFbaAppointment row,List<InboundFbaAppointmentItem> items) {
if(row.getReplacementNosJson()==null) items=items.stream().filter(i->Boolean.TRUE.equals(i.getActive())).toList();
return new FbaAppointmentResponse(row.getId(),row.getAppointmentNo(),row.getConfirmationNo(),row.getPlannedDate(),row.getPlannedAt(),row.getPlannedEndAt(),row.getAppointmentAt(),row.getAppointmentEndAt(),
utc(row.getAppointmentStartUtc()),utc(row.getAppointmentEndUtc()),row.getTimezone(),row.getAppointmentStatus(),row.getAppointmentRemark(),row.getOriginWarehouseId(),row.getFbaWarehouseCode(),row.getSelectedCount(),row.getCreatedBy(),row.getCreatedAt(),row.getUpdatedAt(),
items.stream().map(item->new FbaAppointmentResponse.Item(item.getId(),item.getSourceKey(),item.getSourceVersion(),item.getOrderId(),item.getInboundCode(),item.getFbaWarehouseCode(),item.getCustomerId(),item.getPackageCount(),item.getCargoVerificationVersion(),shipmentReferences(item),"FBA:"+item.getFbaWarehouseCode())).toList(),row.getVersion(),stringList(row.getReplacementNosJson()),row.getRepartitionReason(),batchKey(row.getId()),stringList(row.getReplacementKeysJson()));
@@ -4,6 +4,8 @@ import com.baiyue.lastleg.appointmentwork.*;
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse;
import com.baiyue.lastleg.inboundplan.dto.RepartitionFbaAppointmentsResponse;
import org.springframework.stereotype.Component;
import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore;
import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
@@ -12,7 +14,8 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
public class AppointmentWorkAppointmentChanges {
private final AppointmentWorkCommitmentService commitments;
private final AppointmentWorkChanges changes;
public AppointmentWorkAppointmentChanges(AppointmentWorkCommitmentService commitments,AppointmentWorkChanges changes) { this.commitments=commitments; this.changes=changes; }
private final FbaReceivingStore receiving;
public AppointmentWorkAppointmentChanges(AppointmentWorkCommitmentService commitments,AppointmentWorkChanges changes,FbaReceivingStore receiving) { this.commitments=commitments; this.changes=changes;this.receiving=receiving; }
public void changed(Object response) {
changes.requirementsChanged();
if(response instanceof FbaAppointmentResponse appointment) record(appointment);
@@ -20,9 +23,43 @@ public class AppointmentWorkAppointmentChanges {
else throw new IllegalArgumentException("预约命令缺少原业务响应");
}
private void record(FbaAppointmentResponse a) {
boolean cancelled=Set.of("CANCELLED","FAILED").contains(a.appointmentStatus());
var cargo=a.items().stream().map(i->List.of(i.customerId(),i.sourceKey(),Objects.toString(i.destinationKey(),""),Objects.toString(i.packageCount(),""),Objects.toString(i.shipmentReferences(),""))).sorted(Comparator.comparing(Object::toString)).toList();
commitments.record(a.batchKey(),new AppointmentWorkCommitmentService.Commitment(cancelled?null:target(a),cancelled?null:confirmed(a),null,AppointmentWorkPolicy.digest(cargo.toString()),cancelled));
var snapshot=receiving.snapshots(List.of(a.batchKey())).get(0);
for(var customer:a.items().stream().map(i->i.customerId()).distinct().sorted().toList()) {
var view=receiving(a,customer,snapshot);
var cargo=a.items().stream().filter(i->customer.equals(i.customerId())).map(i->List.of(i.customerId(),i.sourceKey(),Objects.toString(i.destinationKey(),""),Objects.toString(i.packageCount(),""),Objects.toString(i.shipmentReferences(),""))).sorted(Comparator.comparing(Object::toString)).toList();
commitments.record(AppointmentWorkPolicy.customerRequirement(a.batchKey(),customer),new AppointmentWorkCommitmentService.Commitment(view.target(),view.confirmed(),null,AppointmentWorkPolicy.digest(cargo.toString()),view.cancelled()));
}
}
record Receiving(Agreement agreement,Pending pending,Slot target,Slot confirmed,String timezone,boolean cancelled,String status) { }
static Receiving receiving(FbaAppointmentResponse batch,String customer,BatchReceiving snapshot) {
if(snapshot==null || !batch.batchKey().equals(snapshot.batchKey())) throw AppointmentWorkPolicy.unavailable("收货预约批量读取不完整");
var groups=snapshot.agreements().stream().filter(a->"ACTIVE".equals(a.status()) && a.members().stream().anyMatch(m->batch.batchKey().equals(m.batchKey()) && customer.equals(m.customerId()))).toList();
if(groups.size()>1) throw AppointmentWorkPolicy.unavailable("同一客户批次存在多个有效收货预约");
var waiting=snapshot.pendingChanges().stream().filter(p->"WAITING".equals(p.status()) && batch.batchKey().equals(p.intent().batchKey()) && customer.equals(p.intent().customerId())).toList();
if(waiting.size()>1) throw AppointmentWorkPolicy.unavailable("同一客户批次存在多个待确认变化");
var agreement=groups.isEmpty()?null:groups.get(0);
var plans=snapshot.plans().stream().filter(p->batch.batchKey().equals(p.batchKey()) && customer.equals(p.customerId())).toList();
if(plans.size()>1) throw AppointmentWorkPolicy.unavailable("同一客户批次存在多个独立计划");
boolean independentlyCovered=snapshot.agreements().stream().anyMatch(a->"ACTIVE".equals(a.status()) && a.members().stream().anyMatch(m->batch.batchKey().equals(m.batchKey())));
String status=null,timezone=null;Slot target=null,confirmed=null;
if(agreement!=null) {
var details=agreement.details();timezone=agreement.timezone();
if(details==null || !"SUCCESS".equals(details.appointmentStatus()) || details.appointmentAt()==null) throw AppointmentWorkPolicy.unavailable("有效收货预约缺少正式确认时间");
target=target(details,timezone);status=details.appointmentStatus();
confirmed=slot(details.appointmentAt(),details.appointmentEndAt(),timezone);
} else if(!plans.isEmpty()) {
var plan=plans.get(0);var details=plan.details();timezone=plan.timezone();
if(details==null || !Set.of("PENDING","FAILED").contains(Objects.toString(details.appointmentStatus(),"")) || details.confirmationNo()!=null || details.appointmentAt()!=null || details.appointmentEndAt()!=null)
throw AppointmentWorkPolicy.unavailable("客户独立计划包含无效状态或正式确认字段");
target=target(details,timezone);status=details.appointmentStatus();
} else if(!independentlyCovered) {
// Only a batch still without independent receiving coverage can supply its original shared plan.
status=batch.appointmentStatus();timezone=batch.timezone();target="CANCELLED".equals(status)?null:target(batch);
}
return new Receiving(agreement,waiting.isEmpty()?null:waiting.get(0),target,confirmed,timezone,"CANCELLED".equals(status),status);
}
private static Slot target(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails details,String timezone) {
return details.plannedAt()!=null?slot(details.plannedAt(),details.plannedEndAt(),timezone):details.plannedDate()==null?null:new Slot(details.plannedDate(),null,null,null,timezone);
}
public static Slot target(FbaAppointmentResponse a) {
return a.plannedAt()!=null?slot(a.plannedAt(),a.plannedEndAt(),a.timezone()):a.plannedDate()==null?null:new Slot(a.plannedDate(),null,null,null,a.timezone());
@@ -4,6 +4,8 @@ import com.baiyue.lastleg.appointmentplanning.*;
import com.baiyue.lastleg.appointmentwork.*;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse;
import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore;
import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.BatchReceiving;
import com.baiyue.lastleg.security.OverseasPermissions;
import org.springframework.stereotype.Component;
import java.util.*;
@@ -19,8 +21,9 @@ public class AppointmentWorkDeliveryProvider implements AppointmentWorkFactGatew
private final AppointmentWorkCommitmentService commitments;
private final AppointmentWorkDeliveryMapper deliveries;
private final AppointmentWorkSourceMapper sourceMapping;
public AppointmentWorkDeliveryProvider(InboundFbaAppointmentService appointments,AppointmentCargoVerificationService cargo,AppointmentWorkCommitmentService commitments,AppointmentWorkDeliveryMapper deliveries,AppointmentWorkSourceMapper sourceMapping) {
this.appointments=appointments; this.cargo=cargo; this.commitments=commitments; this.deliveries=deliveries; this.sourceMapping=sourceMapping;
private final FbaReceivingStore receiving;
public AppointmentWorkDeliveryProvider(InboundFbaAppointmentService appointments,AppointmentCargoVerificationService cargo,AppointmentWorkCommitmentService commitments,AppointmentWorkDeliveryMapper deliveries,AppointmentWorkSourceMapper sourceMapping,FbaReceivingStore receiving) {
this.appointments=appointments; this.cargo=cargo; this.commitments=commitments; this.deliveries=deliveries; this.sourceMapping=sourceMapping;this.receiving=receiving;
}
public boolean supports(Type type,Flow flow) { return type==Type.DELIVERY && flow==Flow.TRANSFER; }
public List<Fact> read(List<FactRequest> requests) {
@@ -42,44 +45,55 @@ public class AppointmentWorkDeliveryProvider implements AppointmentWorkFactGatew
var snapshots=new HashMap<String,FbaAppointmentResponse>();
var deliveryRows=new HashMap<String,List<AppointmentWorkDeliveryMapper.Delivery>>();
Map<String,String> impacts=Map.of();
var receivingSnapshots=new HashMap<String,BatchReceiving>();
if(!batches.isEmpty()) {
appointments.batchSnapshots(batches).forEach(a->snapshots.put(a.batchKey(),a));
deliveries.batches(batches).forEach(d->deliveryRows.computeIfAbsent(d.batchKey(),ignored->new ArrayList<>()).add(d.delivery()));
impacts=commitments.impactTokens(batches);
receiving.snapshots(batches).forEach(s->receivingSnapshots.put(s.batchKey(),s));
impacts=commitments.impactTokens(requests.stream().filter(r->r.batchKey()!=null && r.batchKey().startsWith("FBA_APPOINTMENT:")).map(r->customerRequirement(r.batchKey(),deliveryCustomer(r.sources()))).distinct().toList());
}
var context=new BatchFacts(snapshots,deliveryRows,impacts);
var context=new BatchFacts(snapshots,deliveryRows,impacts,receivingSnapshots);
return requests.stream().map(r->read(r,privateSources.get(r.workItemKey()),inspections,context)).toList();
}
private record BatchFacts(Map<String,FbaAppointmentResponse> appointments,Map<String,List<AppointmentWorkDeliveryMapper.Delivery>> deliveries,Map<String,String> impacts) { }
private record BatchFacts(Map<String,FbaAppointmentResponse> appointments,Map<String,List<AppointmentWorkDeliveryMapper.Delivery>> deliveries,Map<String,String> impacts,Map<String,BatchReceiving> receiving) { }
private Fact read(FactRequest request,List<AppointmentWorkSourceMapper.Source> privateSources,Map<AppointmentCoverageService.SourceRef,AppointmentCargoVerificationService.Inspection> inspections,BatchFacts context) {
if(request.batchKey()==null) return privateSources!=null?customer(request,privateSources):unbatched(request,inspections);
if(request.batchKey().startsWith("DISPATCH:")) return customerBatch(request);
var a=context.appointments().get(request.batchKey());
var impact=context.impacts().get(request.batchKey());
var customer=deliveryCustomer(request.sources());
var impact=context.impacts().get(customerRequirement(request.batchKey(),customer));
if(a==null || impact==null) throw unavailable("预约批次事实批量读取不完整");
boolean cancelled=Set.of("CANCELLED","FAILED").contains(a.appointmentStatus());
var target=cancelled?null:target(a); var confirmed=cancelled?null:confirmed(a);
var rows=context.deliveries().getOrDefault(a.batchKey(),List.of());
if(rows.size()>1000) throw unavailable("批次派送事实超过完整读取上限");
var receiving=receiving(a,customer,context.receiving().get(a.batchKey()));
boolean unsuccessful=receiving.agreement()==null && (receiving.cancelled() || "FAILED".equals(receiving.status()));
var target=receiving.target();var confirmed=receiving.confirmed();
var customerItems=a.items().stream().filter(i->customer.equals(i.customerId())).toList();
var sourceRefs=customerItems.stream().map(i->new SourceReference(i.customerId(),i.sourceKey(),i.destinationKey())).distinct().toList();
if(customerItems.isEmpty() || !new HashSet<>(sourceRefs).equals(new HashSet<>(request.sources()))) throw conflict("当前客户批次来源已变化,请先同步工作清单");
var batchRows=context.deliveries().getOrDefault(a.batchKey(),List.of());
if(batchRows.size()>1000) throw unavailable("批次派送事实超过完整读取上限");
var rows=batchRows.stream().filter(d->customer.equals(d.customerId())).toList();
boolean complete=!rows.isEmpty() && rows.stream().allMatch(d->d.status()!=null && Set.of("DELIVERED","RETURN_PENDING","RETURNED").contains(d.status()) && d.signedAt()!=null && d.packages()!=null && d.packages().equals(d.signedPackages()));
// Every original source allocation must have real dispatch coverage, not just one signed fragment.
for(var item:a.items()) {
for(var item:customerItems) {
long allocated=rows.stream().filter(d->item.customerId().equals(d.customerId()) && item.sourceKey().equals(d.sourceKey()) && item.destinationKey().equals(d.destinationKey())).mapToLong(d->d.allocatedPackages()).sum();
if(item.packageCount()==null || allocated!=item.packageCount()) complete=false;
}
if(rows.stream().anyMatch(d->sourceRefs.stream().noneMatch(s->Objects.equals(s.sourceKey(),d.sourceKey()) && Objects.equals(s.destinationKey(),d.destinationKey())))) complete=false;
var actual=complete?new Actual(null,rows.stream().map(AppointmentWorkDeliveryMapper.Delivery::signedAt).max(Comparator.naturalOrder()).orElseThrow(),null):null;
var execution=complete?ExecutionStatus.COMPLETED:rows.stream().anyMatch(d->Set.of("DEPARTED","PARTIAL_DEPARTED").contains(d.dispatchStatus()))?ExecutionStatus.IN_PROGRESS:ExecutionStatus.PENDING;
var execution=complete?ExecutionStatus.COMPLETED:rows.stream().anyMatch(d->"DEPARTED".equals(d.dispatchStatus()) || "PARTIAL_DEPARTED".equals(d.dispatchStatus()))?ExecutionStatus.IN_PROGRESS:ExecutionStatus.PENDING;
var blocks=new ArrayList<String>();
if(cancelled) blocks.add("原预约已取消或失败,需求须重新安排");
if(unsuccessful) blocks.add("原预约已取消或未约成,需求须继续安排");
if(!a.replacementBatchKeys().isEmpty()) blocks.add("原批次已替代,请进入新批次");
var sourceRefs=a.items().stream().map(i->new SourceReference(i.customerId(),i.sourceKey(),i.destinationKey())).toList();
var action=complete?null:new Action(confirmed==null?ActionCode.RECORD_CONFIRMATION:ActionCode.CHANGE_BOOKING,confirmed==null?"维护原预约并登记确认":"核对或变更原预约",request.workItemKey(),new AppointmentTarget(a.id().toString(),a.batchKey(),sourceRefs));
if(receiving.pending()!=null) blocks.add("本客户收货范围变化待仓库明确回复,暂不可发车");
if(execution==ExecutionStatus.IN_PROGRESS) blocks.add("本客户运输已执行,普通改约已停止,请从原运输异常处理");
var action=execution!=ExecutionStatus.PENDING?null:new Action(receiving.pending()!=null?ActionCode.CHANGE_BOOKING:confirmed==null?ActionCode.RECORD_CONFIRMATION:ActionCode.CHANGE_BOOKING,receiving.pending()!=null?"继续处理待确认变化":unsuccessful?"沿原批次重新预约":confirmed==null?"维护原预约并登记确认":"核对或变更原预约",request.workItemKey(),new AppointmentTarget(a.id().toString(),a.batchKey(),sourceRefs));
var references=new ArrayList<BusinessReference>(); references.add(new BusinessReference("FBA_APPOINTMENT",a.batchKey(),a.appointmentNo(),"送仓预约"));
if(receiving.pending()!=null) references.add(new BusinessReference("RECEIVING_CHANGE",receiving.pending().changeKey(),null,"待仓库确认的变化"));
rows.stream().filter(d->d.deliveryNo()!=null).map(d->new BusinessReference("DELIVERY",d.deliveryNo(),d.deliveryNo(),"实际派送")).distinct().forEach(references::add);
return new Fact(request.workItemKey(),digest(a.batchKey()+":"+a.version()+":"+rows),impact,true,
confirmed!=null?PlanStatus.CONFIRMED:target!=null?PlanStatus.TARGET_SET:PlanStatus.UNPLANNED,execution,target,confirmed,actual,a.timezone(),a.appointmentNo(),null,
List.of(ORDER_READ),action==null?List.of():List.of(OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT),action,blocks,
actual!=null?List.of("SOURCE_TIMEZONE"):List.of(),List.copyOf(references),null);
return new Fact(request.workItemKey(),digest(customerItems+":"+target+":"+confirmed+":"+receiving.pending()+":"+receiving.status()+":"+rows),impact,true,
receiving.pending()!=null?PlanStatus.AWAITING_REPLY:confirmed!=null?PlanStatus.CONFIRMED:target!=null?PlanStatus.TARGET_SET:PlanStatus.UNPLANNED,execution,target,confirmed,actual,receiving.timezone(),a.appointmentNo(),null,
List.of(ORDER_READ),action==null?List.of():receiving.pending()!=null?List.of(OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT,"platform:appointment:delete"):List.of(OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT),action,blocks,
actual!=null?List.of("SOURCE_TIMEZONE"):List.of(),List.copyOf(references),null,null);
}
private Fact unbatched(FactRequest request,Map<AppointmentCoverageService.SourceRef,AppointmentCargoVerificationService.Inspection> inspections) {
var blocks=new ArrayList<String>(); var missing=new ArrayList<String>(); Action action=null;
@@ -102,7 +116,7 @@ public class AppointmentWorkDeliveryProvider implements AppointmentWorkFactGatew
}
return new Fact(request.workItemKey(),digest(request.requirementKey()+":"+fingerprints),"UNBATCHED",false,PlanStatus.UNPLANNED,ExecutionStatus.PENDING,
null,null,null,null,null,null,List.of(ORDER_READ),action==null?List.of():List.of(OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT),action,
blocks.stream().distinct().toList(),missing,List.of(),null);
blocks.stream().distinct().toList(),missing,List.of(),null,null);
}
private Fact customer(FactRequest request,List<AppointmentWorkSourceMapper.Source> sources) {
var rows=deliveries.customer(request.containerBookingId(),request.requirementKey().substring(8));
@@ -117,7 +131,7 @@ public class AppointmentWorkDeliveryProvider implements AppointmentWorkFactGatew
// removal explicitly so compensation can advance to that page and restore the original demand.
if(sources.isEmpty() || sources.stream().anyMatch(s->"CANCELLED".equals(s.status())))
return new Fact(request.workItemKey(),digest("REMOVED:"+sources),"REMOVED",false,PlanStatus.UNPLANNED,ExecutionStatus.PENDING,null,null,null,null,
sources.isEmpty()?null:sources.get(0).dispatchNo(),null,List.of(ORDER_READ),List.of(),null,List.of("原客户仓排车已取消或移除实物,待完整需求同步"),List.of(),List.of(),null);
sources.isEmpty()?null:sources.get(0).dispatchNo(),null,List.of(ORDER_READ),List.of(),null,List.of("原客户仓排车已取消或移除实物,待完整需求同步"),List.of(),List.of(),null,null);
return customerFact(request,deliveries.privateBatch(request.batchKey()),sources.stream().map(s->new Expected(s.orderId(),s.customerId(),s.packages())).toList(),sources.get(0).dispatchNo());
}
private Fact customerFact(FactRequest request,List<AppointmentWorkDeliveryMapper.CustomerDelivery> rows,List<Expected> sources,String dispatchNo) {
@@ -139,6 +153,6 @@ public class AppointmentWorkDeliveryProvider implements AppointmentWorkFactGatew
return new Fact(request.workItemKey(),digest(sources+":"+rows),digest(sources+":"+dates),!rows.isEmpty(),target==null?PlanStatus.UNPLANNED:PlanStatus.TARGET_SET,
complete?ExecutionStatus.COMPLETED:rows.isEmpty()?ExecutionStatus.PENDING:ExecutionStatus.IN_PROGRESS,target,null,actual,null,dispatchNo!=null?dispatchNo:rows.size()==1?rows.get(0).deliveryNo():null,null,
List.of(ORDER_READ),action==null?List.of():List.of("platform:overseas-warehouse:delivery:list"),action,
dates.size()>1?List.of("同一收货需求存在多个派送日期,请在原派送单核对"):List.of(),target!=null || actual!=null?List.of("SOURCE_TIMEZONE"):List.of(),List.copyOf(refs),null);
dates.size()>1?List.of("同一收货需求存在多个派送日期,请在原派送单核对"):List.of(),target!=null || actual!=null?List.of("SOURCE_TIMEZONE"):List.of(),List.copyOf(refs),null,null);
}
}
@@ -12,12 +12,33 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@Component
@Transactional(propagation=Propagation.MANDATORY)
public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPort {
@Transactional(readOnly=true,propagation=Propagation.SUPPORTS)
public Map<Long,List<BatchReference>> references(List<Long> dispatchIds) {
if(dispatchIds==null || dispatchIds.isEmpty()) return Map.of();
if(dispatchIds.stream().anyMatch(id->id==null || id<=0)) throw bad("排车引用缺少真实排车身份");
var rows=mapper.references(dispatchIds.stream().distinct().sorted().toList());
if(rows.size()>20000) throw unavailable("排车批次引用超过完整读取上限");
record Group(long dispatchId,String batchKey,String batchNo,String customerId) { }
var groups=new LinkedHashMap<Group,Map<List<String>,Integer>>();
for(var row:rows) {
if(!dispatchIds.contains(row.dispatchId()) || row.batchKey()==null || row.customerId()==null || row.sourceKey()==null || row.destinationKey()==null || row.packages()<=0)
throw unavailable("原排车逐票批次引用不完整");
groups.computeIfAbsent(new Group(row.dispatchId(),row.batchKey(),row.batchNo(),row.customerId()),ignored->new LinkedHashMap<>())
.merge(List.of(row.sourceKey(),row.destinationKey()),row.packages(),Math::addExact);
}
var result=new LinkedHashMap<Long,List<BatchReference>>();dispatchIds.forEach(id->result.put(id,new ArrayList<>()));
groups.forEach((group,sources)->result.get(group.dispatchId()).add(new BatchReference(group.batchKey(),group.batchNo(),group.customerId(),
sources.entrySet().stream().map(e->new BatchSource(e.getKey().get(0),e.getKey().get(1),e.getValue())).toList())));
result.replaceAll((id,refs)->List.copyOf(refs));return Collections.unmodifiableMap(result);
}
private final AppointmentWorkDispatchMapper mapper;
private final InboundFbaAppointmentService appointments;
private final AppointmentSourceService sources;
private final AppointmentWorkChanges changes;
public AppointmentWorkDispatchBinding(AppointmentWorkDispatchMapper mapper,InboundFbaAppointmentService appointments,AppointmentSourceService sources,AppointmentWorkChanges changes) {
this.mapper=mapper; this.appointments=appointments; this.sources=sources; this.changes=changes;
private final com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving;
public AppointmentWorkDispatchBinding(AppointmentWorkDispatchMapper mapper,InboundFbaAppointmentService appointments,AppointmentSourceService sources,AppointmentWorkChanges changes,
com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving) {
this.mapper=mapper; this.appointments=appointments; this.sources=sources; this.changes=changes;this.receiving=receiving;
}
public void bind(String customer,long dispatch,List<PhysicalItem> items,List<Allocation> allocations) {
if(allocations==null) throw bad("批次实物分配清单必填");
@@ -51,14 +72,9 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
}
for(var batch:batches) {
var context=appointments.batchContext(batch); var appointment=context.appointment();
if(Set.of("CANCELLED","FAILED").contains(appointment.appointmentStatus()) || !appointment.replacementBatchKeys().isEmpty()) throw conflict("不能绑定取消或被替代的预约批次");
var grouped=allocations.stream().filter(a->batch.equals(a.batchKey())).collect(Collectors.groupingBy(a->a.sourceKey()+"/"+a.destinationKey()));
for(var group:grouped.values()) {
var ref=group.get(0);
var original=appointment.items().stream().filter(i->customer.equals(i.customerId()) && ref.sourceKey().equals(i.sourceKey()) && ref.destinationKey().equals(i.destinationKey())).findFirst().orElseThrow(()->conflict("实物来源不属于所选预约批次"));
long count=group.stream().mapToLong(Allocation::packageCount).sum();
if(original.packageCount()==null || mapper.allocated(batch,customer,ref.sourceKey(),ref.destinationKey(),dispatch)+count>original.packageCount()) throw conflict("所选实物超过该批次逐票分配量");
}
// Pre-arrangement reserves real cargo only. requireReceiving separately gates actual departure per customer.
if(!appointment.replacementBatchKeys().isEmpty()) throw conflict("不能绑定已被替代的预约批次");
requireCurrentAllocation(appointment,customer,dispatch,allocations.stream().filter(a->batch.equals(a.batchKey())).toList());
}
mapper.remove(dispatch); persisted.forEach(mapper::insert); changes.requirementsChanged();
}
@@ -82,7 +98,15 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
if(remaining.values().stream().anyMatch(n->n!=0)) throw conflict("批次逐票分配必须与所选托盘实际货物完全一致");
if(item.packages()!=null && selected.stream().mapToLong(Allocation::packageCount).sum()!=item.packages()) throw conflict("逐票分配与所选托盘箱数不一致");
}
public void requireDeparture(String dispatchNo) {
public void requireDeparture(String dispatchNo) { checkDeparture(dispatchNo,true); }
public String departureBlockReason(String dispatchNo) {
try { checkDeparture(dispatchNo,false);return null; }
catch(org.springframework.web.server.ResponseStatusException error) {
if(error.getStatusCode().value()!=409) throw error;
return error.getReason();
}
}
private void checkDeparture(String dispatchNo,boolean locked) {
var rows=mapper.bindings(dispatchNo);
var linked=rows.stream().map(AppointmentWorkDispatchMapper.Bound::palletId).collect(Collectors.toSet());
for(var item:mapper.dispatchPhysical(dispatchNo)) if(item.planPalletId()!=null && !linked.contains(item.planPalletId())) {
@@ -91,8 +115,17 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
}
for(var batch:rows.stream().map(AppointmentWorkDispatchMapper.Bound::batchKey).distinct().sorted().toList()) {
if(batch.startsWith("DISPATCH:")) continue;
lock(batch); var a=appointments.batchContext(batch).appointment();
if(!"SUCCESS".equals(a.appointmentStatus()) || a.appointmentAt()==null || !a.replacementBatchKeys().isEmpty()) throw conflict("关联预约尚未有效确认或已被替代,请重新核对批次后发车");
if(locked) lock(batch);
var a=appointments.batchContext(batch).appointment();
if(!a.replacementBatchKeys().isEmpty()) throw conflict("关联批次已被替代,请重新核对实物批次后发车");
var allocated=rows.stream().filter(row->batch.equals(row.batchKey())).toList();
var dispatchIds=allocated.stream().map(AppointmentWorkDispatchMapper.Bound::dispatchId).distinct().toList();
if(dispatchIds.size()!=1) throw conflict("排车批次关联不一致,请重新核对实物分配");
for(var customer:allocated.stream().map(AppointmentWorkDispatchMapper.Bound::customerId).distinct().toList()) {
requireReceiving(batch,customer,allocated.stream().filter(row->customer.equals(row.customerId())).toList());
requireCurrentAllocation(a,customer,dispatchIds.get(0),allocated.stream().filter(row->customer.equals(row.customerId()))
.map(row->new Allocation(row.palletId(),row.batchKey(),row.customerId(),row.sourceKey(),row.destinationKey(),row.packages())).toList());
}
}
for(var group:rows.stream().collect(Collectors.groupingBy(AppointmentWorkDispatchMapper.Bound::palletId)).values()) {
var first=group.get(0);
@@ -100,11 +133,39 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
group.stream().map(a->new Allocation(a.palletId(),a.batchKey(),a.customerId(),a.sourceKey(),a.destinationKey(),a.packages())).toList(),true,true);
}
}
private void requireReceiving(String batch,String customer,List<AppointmentWorkDispatchMapper.Bound> rows) {
if(receiving.hasPending(batch,customer)) throw conflict("本客户有待仓库确认的变化,保留原安排但禁止发车,请先完成明确回复");
var agreement=receiving.currentAgreement(batch,customer);
if(agreement==null || !"ACTIVE".equals(agreement.status()) || agreement.details()==null || !"SUCCESS".equals(agreement.details().appointmentStatus())
|| agreement.details().appointmentAt()==null) throw conflict("本客户尚无有效收货确认,请先完成当前预约后发车");
var members=agreement.members().stream().filter(m->batch.equals(m.batchKey()) && customer.equals(m.customerId())).toList();
if(members.size()!=1) throw conflict("当前收货确认未唯一覆盖本客户批次,请重新核对");
for(var group:rows.stream().collect(Collectors.groupingBy(r->List.of(r.sourceKey(),r.destinationKey()))).values()) {
var first=group.get(0);
var sources=members.get(0).sources().stream().filter(s->first.sourceKey().equals(s.sourceKey()) && first.destinationKey().equals(s.destinationKey())).toList();
if(sources.size()!=1 || mapper.allocated(batch,customer,first.sourceKey(),first.destinationKey(),first.dispatchId())
+group.stream().mapToLong(AppointmentWorkDispatchMapper.Bound::packages).sum()>sources.get(0).packageCount())
throw conflict("当前收货确认未完整覆盖本次逐票实物,请重新核对");
}
}
private void requireCurrentAllocation(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse appointment,String customer,long dispatch,List<Allocation> allocations) {
var grouped=allocations.stream().collect(Collectors.groupingBy(a->List.of(a.sourceKey(),a.destinationKey())));
for(var group:grouped.values()) {
var ref=group.get(0);
var originals=appointment.items().stream().filter(i->customer.equals(i.customerId()) && ref.sourceKey().equals(i.sourceKey()) && ref.destinationKey().equals(i.destinationKey())).toList();
if(originals.size()!=1) throw conflict("实物来源不属于当前预约批次的唯一分配,请重新核对");
var original=originals.get(0);
long count=group.stream().mapToLong(Allocation::packageCount).sum();
if(group.stream().anyMatch(a->a.packageCount()<=0) || original.packageCount()==null
|| mapper.allocated(appointment.batchKey(),customer,ref.sourceKey(),ref.destinationKey(),dispatch)+count>original.packageCount())
throw conflict("所选实物超过当前批次逐票分配量,请重新核对");
}
}
private void requireNoBatch(String customer,long pallet) {
var physical=mapper.physical(pallet,customer).stream().filter(p->p.destinationKey()!=null && p.destinationKey().startsWith("FBA:")).toList();
var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources);
var refs=physical.stream().map(p->new AppointmentCoverageService.SourceRef(customer,AppointmentWorkPhysicalSources.ref(canonical,p).sourceKey(),p.destinationKey())).distinct().toList();
if(!refs.isEmpty() && appointments.relatedBatchSnapshots(refs).stream().anyMatch(a->!Set.of("CANCELLED","FAILED").contains(a.appointmentStatus()) && a.replacementBatchKeys().isEmpty()))
if(!refs.isEmpty() && appointments.relatedBatchSnapshots(refs).stream().anyMatch(a->a.replacementBatchKeys().isEmpty()))
throw conflict("所选实物已有预约批次,必须明确逐批逐票分配后才能排车或发车");
}
public void removeItem(long dispatchId,long itemId) { mapper.removeItem(dispatchId,itemId); changes.requirementsChanged(); }
@@ -19,7 +19,8 @@ public class AppointmentWorkDispatchCandidates {
@Transactional(readOnly=true)
public Result read(String batch) {
var appointment=appointments.batchContext(batch).appointment();
if(Set.of("CANCELLED","FAILED").contains(appointment.appointmentStatus()) || !appointment.replacementBatchKeys().isEmpty()) return new Result(batch,appointment.version(),List.of(),List.of("原预约取消或替代,不能选择实物"));
// Receiving outcome does not release this batch's cargo or decide another customer's physical eligibility.
if(!appointment.replacementBatchKeys().isEmpty()) return new Result(batch,appointment.version(),List.of(),List.of("原批次已替代,不能选择实物"));
var candidates=mapper.candidates(appointment.id());
if(candidates.size()>500) throw conflict("本批实物超过500托,请先按原预约分批");
var output=new ArrayList<Candidate>();
@@ -10,6 +10,54 @@ public interface AppointmentWorkDispatchMapper {
record Bound(long dispatchId,long itemId,long palletId,String batchKey,String customerId,String sourceKey,String destinationKey,int packages) { }
record Candidate(long planPalletId,String palletNo,long planId,String planNo,String customerId,String sourceStage,boolean locked) { }
record DispatchPhysical(long itemId,Long planPalletId,String customerId,String sourceStage,Integer packages) { }
record TransportRow(long dispatchId,String dispatchNo,String customerId,String dispatchStatus,Integer pickedPackages,Integer shippedPackages,
java.time.LocalDateTime updatedAt,long itemId,long palletId,Integer itemPackages,String sourceStage,
String batchKey,String sourceKey,String destinationKey,int packages,String tripNo,Long tripVersion,String tripStatus,
Long outboundId,String outboundStatus,String taskStatus) { }
record ReceivingSource(String batchKey,String customerId,String sourceKey,String destinationKey,Integer packages) { }
record ReferenceRow(long dispatchId,String batchKey,String batchNo,String customerId,String sourceKey,String destinationKey,int packages) { }
@Select("""
<script>
SELECT a.dispatch_id,a.batch_key,CASE WHEN a.batch_key=CONCAT('DISPATCH:',d.id) THEN d.dispatch_no ELSE p.appointment_no END batch_no,
a.customer_id,a.source_key,a.destination_key,a.package_count packages
FROM ll_dispatch_appointment_allocation a JOIN ll_dispatch_order d ON d.id=a.dispatch_id AND d.customer_id=a.customer_id
LEFT JOIN ll_inbound_fba_appointment p ON a.batch_key=CONCAT('FBA_APPOINTMENT:',p.id)
WHERE a.dispatch_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>
ORDER BY a.dispatch_id,a.batch_key,a.customer_id,a.source_key,a.destination_key,a.dispatch_item_id LIMIT 20001
</script>
""")
List<ReferenceRow> references(@Param("ids") List<Long> ids);
@Select("""
<script>
SELECT CONCAT('FBA_APPOINTMENT:',i.appointment_id) batch_key,i.customer_id,i.source_key,
CONCAT('FBA:',i.fba_warehouse_code) destination_key,i.package_count packages
FROM ll_inbound_fba_appointment_item i
WHERE i.active=1 AND CONCAT('FBA_APPOINTMENT:',i.appointment_id) IN
<foreach collection='batches' item='batch' open='(' separator=',' close=')'>#{batch}</foreach>
ORDER BY i.appointment_id,i.customer_id,i.source_key LIMIT 20001
</script>
""")
@Options(flushCache=Options.FlushCachePolicy.TRUE,useCache=false)
List<ReceivingSource> receivingSources(@Param("batches") List<String> batches);
@Select("""
<script>
SELECT a.dispatch_id,d.dispatch_no,a.customer_id,d.status dispatch_status,d.picked_package_count picked_packages,
d.shipped_package_count shipped_packages,d.updated_at,a.dispatch_item_id item_id,a.plan_pallet_id pallet_id,
i.package_count item_packages,i.source_stage,a.batch_key,a.source_key,a.destination_key,a.package_count packages,
t.trip_no,t.record_version trip_version,t.status trip_status,o.id outbound_id,o.status outbound_status,p.status task_status
FROM ll_dispatch_appointment_allocation a JOIN ll_dispatch_order d ON d.id=a.dispatch_id AND d.customer_id=a.customer_id
JOIN ll_dispatch_item i ON i.id=a.dispatch_item_id AND i.dispatch_order_id=d.id AND i.customer_id=d.customer_id
LEFT JOIN ll_fleet_trip_dispatch b ON b.dispatch_id=d.id LEFT JOIN ll_fleet_trip t ON t.id=b.trip_id
LEFT JOIN ll_outbound_order o ON o.dispatch_order_id=d.id AND o.customer_id=d.customer_id
LEFT JOIN ll_dispatch_pda_task p ON p.dispatch_order_id=d.id AND p.customer_id=d.customer_id
WHERE d.status&lt;&gt;'CANCELLED' AND (
a.dispatch_id IN (SELECT selected.dispatch_id FROM ll_dispatch_appointment_allocation selected WHERE selected.batch_key IN <foreach collection='batches' item='key' open='(' separator=',' close=')'>#{key}</foreach>)
<if test='trips != null and !trips.isEmpty()'>OR t.trip_no IN <foreach collection='trips' item='trip' open='(' separator=',' close=')'>#{trip}</foreach></if>
) ORDER BY a.dispatch_id,a.dispatch_item_id,a.batch_key,a.customer_id,a.source_key,a.destination_key LIMIT 1001
</script>
""")
@Options(flushCache=Options.FlushCachePolicy.TRUE,useCache=false)
List<TransportRow> transportRows(@Param("batches") List<String> batches,@Param("trips") List<String> trips);
@Select("SELECT i.id item_id,i.plan_pallet_id,i.customer_id,i.source_stage,i.package_count packages FROM ll_dispatch_item i JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id WHERE d.dispatch_no=#{number}") List<DispatchPhysical> dispatchPhysical(String number);
@Select("SELECT DISTINCT p.id plan_pallet_id,p.pallet_no,p.plan_id,n.plan_no,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END source_stage,EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock k WHERE k.plan_pallet_id=p.id AND k.status='ACTIVE') locked FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan n ON n.id=p.plan_id JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id JOIN ll_inbound_fba_appointment_item i ON i.order_id=l.order_id AND i.customer_id=l.customer_id WHERE i.appointment_id=#{id} ORDER BY p.id LIMIT 501")
List<Candidate> candidates(long id);
@@ -40,6 +40,6 @@ public class AppointmentWorkReceivingProvider implements AppointmentWorkFactGate
String impact=digest(dates.toString());
return new Fact(request.workItemKey(),fingerprint,impact,!plans.isEmpty(),target==null?PlanStatus.UNPLANNED:PlanStatus.TARGET_SET,
execution,target,null,actual,null,plans.size()==1?plans.get(0).getPlanNo():null,null,List.of(OverseasPermissions.INBOUND_ORDER_LIST),
action==null?List.of():List.of(permission),action,List.copyOf(blocks),target!=null || actual!=null?List.of("SOURCE_TIMEZONE"):List.of(),refs,null);
action==null?List.of():List.of(permission),action,List.copyOf(blocks),target!=null || actual!=null?List.of("SOURCE_TIMEZONE"):List.of(),refs,null,null);
}
}
@@ -69,7 +69,20 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa
batch.fbaWarehouseCode(),batch.timezone(),references.stream().distinct().toList(),List.copyOf(replacementKeys),batchSources));
}
}
return List.copyOf(result.values());
var scoped=new ArrayList<Requirement>();
for(var requirement:result.values()) {
if(requirement.batchKey()==null || !requirement.batchKey().startsWith("FBA_APPOINTMENT:")) { scoped.add(requirement);continue; }
var customers=new TreeMap<String,List<SourceReference>>();
requirement.sources().forEach(s->customers.computeIfAbsent(s.customerId(),ignored->new ArrayList<>()).add(s));
if(customers.isEmpty()) throw unavailable("实物批次缺少客户来源,不能生成送仓事项");
customers.forEach((customer,refs)->{
var replacements=new LinkedHashSet<>(requirement.replacesRequirementKeys());
requirement.replacesRequirementKeys().stream().filter(k->k.startsWith("FBA_APPOINTMENT:")).map(k->customerRequirement(k,customer)).forEach(replacements::add);
scoped.add(new Requirement(customerRequirement(requirement.batchKey(),customer),requirement.batchKey(),requirement.batchNo(),requirement.destinationKind(),
requirement.originName(),requirement.destinationName(),requirement.timezone(),requirement.references(),List.copyOf(replacements),List.copyOf(refs)));
});
}
return List.copyOf(scoped);
}
private void customer(Requirement requirement,List<AppointmentWorkSourceMapper.Source> orders,List<AppointmentWorkSourceMapper.DispatchSource> dispatches,Map<String,Requirement> result) {
if(orders.stream().anyMatch(s->!"PRIVATE".equals(s.destinationType()))) throw conflict("客户仓需求与原订单目的地不一致");
@@ -106,6 +119,8 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa
return result.stream().distinct().toList();
}
private static boolean active(FbaAppointmentResponse batch) {
return !Set.of("CANCELLED","FAILED").contains(batch.appointmentStatus()) && (batch.replacementBatchKeys()==null || batch.replacementBatchKeys().isEmpty());
// Cancelling a booking clears its schedule, not its physical allocation. Only an
// authoritative replacement retires the batch and its delivery/vehicle obligations.
return batch.replacementBatchKeys()==null || batch.replacementBatchKeys().isEmpty();
}
}
@@ -0,0 +1,116 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.fleet.trip.*;
import com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentExecutionPort;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
/** Original allocations and signed/returned delivery evidence; never reads work projections. */
@Component
public class FbaAppointmentExecutionAdapter implements FbaAppointmentExecutionPort {
private final AppointmentWorkDispatchMapper allocations;
private final FleetTripMapper trips;
private final FleetDispatchGateway dispatches;
private record Facts(List<String> batches,List<AppointmentWorkDispatchMapper.ReceivingSource> sources,
List<AppointmentWorkDispatchMapper.TransportRow> rows,
Map<String,FleetCompletionModels.DispatchCompletion> outcomes,
Map<String,FleetDispatchGateway.TerminalEvidence> evidence,Map<String,String> invalid) { }
public FbaAppointmentExecutionAdapter(AppointmentWorkDispatchMapper allocations,FleetTripMapper trips,FleetDispatchGateway dispatches) {
this.allocations=allocations;this.trips=trips;this.dispatches=dispatches;
}
@Transactional(readOnly=true)
public Scope inspect(List<Member> input) {
var members=members(input);return scope(members,read(batches(members)));
}
@Transactional(propagation=Propagation.MANDATORY)
public Scope lockAndInspect(List<Member> input,String expectedVersion) {
var members=members(input);if(members.isEmpty()) throw bad("请选择需要重新预约的客户");
var before=read(batches(members));var selected=selected(before,members);
var tripNos=tripNos(selected);var numbers=dispatchNos(selected);
for(var trip:tripNos) if(trips.lockByNo(trip)==null) throw conflict("原车次关系已变化,请重新核对");
for(var number:numbers) {
dispatches.completion(number,true);
dispatches.lockForPlanning(number);
}
var current=read(before.batches());var scope=scope(members,current);
if(!numbers.equals(dispatchNos(selected(current,members))) || !tripNos.equals(tripNos(selected(current,members)))
|| expectedVersion==null || !expectedVersion.equals(scope.version())) throw conflict("客户执行范围已变化,请重新读取预约候选");
for(var member:scope.members()) if(!member.canRebook()) throw conflict(member.reason());
return scope;
}
private Facts read(List<String> batches) {
if(batches.isEmpty()) return new Facts(batches,List.of(),List.of(),Map.of(),Map.of(),Map.of());
var sources=allocations.receivingSources(batches);var rows=allocations.transportRows(batches,List.of());
if(sources.size()>20000 || rows.size()>1000) throw unavailable("客户执行事实超过完整读取上限");
var outcomes=new TreeMap<String,FleetCompletionModels.DispatchCompletion>();var evidence=new TreeMap<String,FleetDispatchGateway.TerminalEvidence>();var invalid=new TreeMap<String,String>();
for(var number:dispatchNos(rows)) {
var completion=dispatches.completion(number,false);
if(completion==null || !number.equals(completion.dispatchNo()) || completion.outcomes()==null) throw unavailable("原派送执行事实读取不完整");
outcomes.put(number,completion);
if(rows.stream().anyMatch(r->number.equals(r.dispatchNo()) && "DEPARTED".equals(r.dispatchStatus()))) {
try {
var fact=dispatches.terminalEvidence(number,false);
if(fact==null || !number.equals(fact.dispatchNo()) || fact.deliveries()==null) throw unavailable("原发运签收退回事实缺失");
evidence.put(number,fact);
} catch(ResponseStatusException error) {
if(error.getStatusCode().value()!=409) throw error;
invalid.put(number,Objects.toString(error.getReason(),"原发运证据不完整"));
}
}
}
return new Facts(batches,List.copyOf(sources),List.copyOf(rows),outcomes,evidence,invalid);
}
private Scope scope(List<Member> members,Facts facts) {
// The selected customer subset is deliberately absent from the version. All facts for the
// same batch set participate, including siblings omitted from the candidate selection.
return new Scope(digest(facts.toString()),members.stream().map(m->eligibility(m,facts)).toList());
}
private Eligibility eligibility(Member member,Facts facts) {
var expected=facts.sources().stream().filter(s->member.batchKey().equals(s.batchKey()) && member.customerId().equals(s.customerId())).toList();
if(expected.isEmpty() || expected.stream().anyMatch(s->s.packages()==null || s.packages()<=0)) return denied(member,false,"客户已移出本批或来源箱数尚未核实");
var rows=selected(facts,List.of(member));
boolean complete=!rows.isEmpty();
var expectedAmounts=new HashMap<List<String>,Long>();
for(var source:expected) if(source.sourceKey()==null || source.destinationKey()==null || expectedAmounts.put(List.of(source.sourceKey(),source.destinationKey()),source.packages().longValue())!=null)
return denied(member,false,"原客户逐票分配存在重复或缺失");
var allocated=rows.stream().collect(Collectors.groupingBy(r->List.of(r.sourceKey(),r.destinationKey()),Collectors.summingLong(AppointmentWorkDispatchMapper.TransportRow::packages)));
if(rows.stream().anyMatch(r->r.packages()<=0) || allocated.entrySet().stream().anyMatch(e->!expectedAmounts.containsKey(e.getKey()) || e.getValue()>expectedAmounts.get(e.getKey())))
return denied(member,false,"排车逐票数量或来源不属于当前客户批次,请先核对原实物关联");
if(!expectedAmounts.equals(allocated)) complete=false;
for(var row:rows) {
var proof=facts.evidence().get(row.dispatchNo());
if(proof==null || !member.customerId().equals(proof.customerId())) { complete=false;continue; }
var delivery=proof.deliveries().stream().filter(d->member.customerId().equals(d.customerId()) && row.destinationKey().equals(d.destinationKey())).toList();
long dispatched=facts.rows().stream().filter(r->row.dispatchNo().equals(r.dispatchNo()) && row.customerId().equals(r.customerId()) && row.destinationKey().equals(r.destinationKey())).mapToLong(AppointmentWorkDispatchMapper.TransportRow::packages).sum();
if(delivery.size()!=1 || delivery.get(0).packages()!=dispatched || delivery.get(0).terminalAt()==null || delivery.get(0).terminalAt().isAfter(Instant.now())) complete=false;
}
if(complete) return denied(member,true,"本客户货物已实际签收或完成退回入仓,不再重新预约");
for(var row:rows) {
if(facts.invalid().containsKey(row.dispatchNo())) return denied(member,false,facts.invalid().get(row.dispatchNo()));
var outcomes=facts.outcomes().get(row.dispatchNo()).outcomes();
if(outcomes.stream().anyMatch(o->o.terminalAt()!=null || o.signedPackages()>0 || o.returnedPackages()>0 || Set.of("DELIVERING","DELIVERED","REJECTED","RETURN_PENDING","RETURNED").contains(Objects.toString(o.status(),""))))
return denied(member,false,"本客户已有实际派送执行,请从原运输异常处理");
if(!"PENDING_DEPARTURE".equals(row.dispatchStatus()) || positive(row.pickedPackages()) || positive(row.shippedPackages())
|| row.taskStatus()!=null && !"PDA_PENDING".equals(row.taskStatus()) || row.outboundStatus()!=null && !"WAIT_OUTBOUND".equals(row.outboundStatus())
|| row.tripNo()!=null && !"PLANNED".equals(row.tripStatus())) return denied(member,false,"本客户运输已开始执行,不能普通重新预约");
}
return new Eligibility(member,false,true,null);
}
private static Eligibility denied(Member member,boolean completed,String reason) { return new Eligibility(member,completed,false,reason); }
private static boolean positive(Integer value) { return value!=null && value>0; }
private static List<AppointmentWorkDispatchMapper.TransportRow> selected(Facts facts,List<Member> members) {
var scopes=new HashSet<>(members);return facts.rows().stream().filter(r->scopes.contains(new Member(r.batchKey(),r.customerId()))).toList();
}
private static List<String> dispatchNos(List<AppointmentWorkDispatchMapper.TransportRow> rows) { return rows.stream().map(AppointmentWorkDispatchMapper.TransportRow::dispatchNo).distinct().sorted().toList(); }
private static List<String> tripNos(List<AppointmentWorkDispatchMapper.TransportRow> rows) { return rows.stream().map(AppointmentWorkDispatchMapper.TransportRow::tripNo).filter(Objects::nonNull).distinct().sorted().toList(); }
private static List<String> batches(List<Member> members) { return members.stream().map(Member::batchKey).distinct().sorted().toList(); }
private static List<Member> members(List<Member> members) {
if(members==null || members.size()>200 || new HashSet<>(members).size()!=members.size() || members.stream().anyMatch(m->m==null || m.batchKey()==null || !m.batchKey().matches("FBA_APPOINTMENT:[1-9][0-9]*") || m.customerId()==null || m.customerId().isBlank())) throw bad("客户执行范围须为最多200个不重复真实客户批次");
return members.stream().sorted(Comparator.comparing(Member::batchKey).thenComparing(Member::customerId)).toList();
}
}
@@ -0,0 +1,159 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.fleet.trip.*;
import com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentTransportPort;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.*;
import org.springframework.transaction.support.*;
import java.util.*;
import java.util.stream.Collectors;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
/** Mutates original dispatch rows and fleet bindings only; owns no cargo quantity ledger. */
@Component
public class FbaAppointmentTransportAdapter implements FbaAppointmentTransportPort {
private final AppointmentWorkDispatchMapper allocations;
private final FleetTripMapper trips;
private final FleetDispatchGateway dispatches;
private final FleetTripService fleet;
private final FleetBookingReservations reservations;
private final com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges changes;
private final Object lockContextKey=new Object();
private record Locked(List<String> batches,List<String> tripNos,List<AppointmentWorkDispatchMapper.TransportRow> rows,Scope scope) { }
private record Source(String batch,String customer,String source,String destination) { }
public FbaAppointmentTransportAdapter(AppointmentWorkDispatchMapper allocations,FleetTripMapper trips,
FleetDispatchGateway dispatches,@Lazy FleetTripService fleet,FleetBookingReservations reservations,
com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges changes) {
this.allocations=allocations;this.trips=trips;this.dispatches=dispatches;this.fleet=fleet;this.reservations=reservations;this.changes=changes;
}
@Transactional(readOnly=true)
public Scope inspect(List<String> batchKeys) {
var batches=keys(batchKeys);var first=read(batches,List.of());
return scope(batches,read(batches,tripNos(first)));
}
@Transactional(propagation=Propagation.MANDATORY)
public Scope lockAndInspect(List<String> batchKeys,String expectedVersion) {
var batches=keys(batchKeys);var initial=read(batches,List.of());var tripNos=tripNos(initial);
for(var trip:tripNos) if(trips.lockByNo(trip)==null) throw conflict("原车次已变化,请重新核对");
var all=read(batches,tripNos);
var targets=all.stream().filter(r->batches.contains(r.batchKey())).map(AppointmentWorkDispatchMapper.TransportRow::dispatchNo).distinct().sorted().toList();
for(var number:targets) dispatches.lockForPlanning(number);
var lockedRows=read(batches,tripNos);
if(!tripNos(lockedRows).equals(tripNos)) throw conflict("排车车次关联已变化,请重新核对");
var scope=scope(batches,lockedRows);
if(expectedVersion==null || !expectedVersion.equals(scope.version())) throw conflict("运输范围或版本已变化,请重新读取调整范围");
if(!scope.blockReasons().isEmpty()) throw conflict(String.join(";",scope.blockReasons()));
if(TransactionSynchronizationManager.hasResource(lockContextKey)) throw conflict("同一事务不能重复打开运输调整范围");
TransactionSynchronizationManager.bindResource(lockContextKey,new Locked(batches,tripNos,lockedRows,scope));
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
public void afterCompletion(int status) { TransactionSynchronizationManager.unbindResourceIfPossible(lockContextKey); }
});
return scope;
}
@Transactional(propagation=Propagation.MANDATORY)
public Result apply(Scope locked,List<Move> moves,String operator,String reason) {
var context=(Locked)TransactionSynchronizationManager.getResource(lockContextKey);
if(context==null || !context.scope().equals(locked)) throw conflict("运输调整必须使用本事务锁定的权威范围");
if(operator==null || operator.isBlank() || reason==null || reason.isBlank()) throw bad("运输调整须提供操作人和原因");
var current=read(context.batches(),context.tripNos());
if(!scope(context.batches(),current).version().equals(locked.version())) throw conflict("锁定后的排车事实已变化,请重新核对");
var intents=new LinkedHashMap<Source,Move>();
if(moves==null || moves.size()>1000) throw bad("运输调整来源清单无效");
for(var move:moves) {
if(move==null || !context.batches().contains(move.batchKey()) || move.originalPackages()<=0 || move.retainedPackages()<0 || move.retainedPackages()>move.originalPackages()
|| move.customerId()==null || move.sourceKey()==null || move.destinationKey()==null) throw bad("运输调整必须对应原批次及0至当前量的真实逐票分配");
if(move.retainedPackages()<move.originalPackages() && (move.laterBatchKey()==null || !move.laterBatchKey().startsWith("FBA_APPOINTMENT:"))) throw bad("移出货物缺少核心生成的后送批次");
var source=new Source(move.batchKey(),move.customerId(),move.sourceKey(),move.destinationKey());
if(intents.putIfAbsent(source,move)!=null) throw bad("运输调整来源重复");
}
var targetRows=current.stream().filter(r->intents.containsKey(source(r))).toList();
var counts=targetRows.stream().collect(Collectors.groupingBy(this::source,Collectors.summingLong(AppointmentWorkDispatchMapper.TransportRow::packages)));
var remove=new LinkedHashMap<Source,Integer>();
for(var entry:intents.entrySet()) {
long bound=counts.getOrDefault(entry.getKey(),0L);
if(bound>entry.getValue().originalPackages()) throw conflict("原排车实物超过核心批次当前量,不能调整");
remove.put(entry.getKey(),Math.toIntExact(Math.max(0,bound-entry.getValue().retainedPackages())));
}
var byItem=current.stream().collect(Collectors.groupingBy(AppointmentWorkDispatchMapper.TransportRow::itemId,TreeMap::new,Collectors.toList()));
var selected=selectWholeItems(byItem,remove);
var affected=targetRows.stream().filter(r->selected.contains(r.itemId())).map(AppointmentWorkDispatchMapper.TransportRow::dispatchNo).distinct().sorted().toList();
// Validate every partial edit before invoking any original command. Transaction rollback is still mandatory.
for(var number:affected) {
var rows=current.stream().filter(r->number.equals(r.dispatchNo())).toList();
boolean whole=rows.stream().allMatch(r->selected.contains(r.itemId()));
var physical=allocations.dispatchPhysical(number);
if(physical.stream().anyMatch(p->!byItem.containsKey(p.itemId()))) throw conflict("原排车含未纳入逐票批次的实物,请先核对完整关联");
for(var item:physical) if(item.packages()==null || byItem.get(item.itemId()).stream().mapToLong(AppointmentWorkDispatchMapper.TransportRow::packages).sum()!=item.packages())
throw conflict("逐票批次分配未完整核平原排车实物,不能调整");
if(!whole && rows.stream().anyMatch(r->r.outboundId()!=null)) throw conflict("部分移出前须先在原业务处理未执行出库单");
}
var next=new LinkedHashSet<String>();
for(var number:affected) {
var rows=current.stream().filter(r->number.equals(r.dispatchNo())).toList();var first=rows.get(0);
boolean whole=rows.stream().allMatch(r->selected.contains(r.itemId()));
if(first.tripNo()!=null) fleet.unbindDispatch(first.tripNo(),operator,new FleetTripModels.BindingCommand(trips.findByNo(first.tripNo()).getRecordVersion(),number,reason));
if(whole) {
allocations.remove(first.dispatchId());
for(var row:rows) allocations.insert(new AppointmentWorkDispatchMapper.Bound(row.dispatchId(),row.itemId(),row.palletId(),intents.get(source(row)).laterBatchKey(),row.customerId(),row.sourceKey(),row.destinationKey(),row.packages()));
next.add("沿原排车重新安排后送车辆");
} else {
for(var item:rows.stream().filter(r->selected.contains(r.itemId())).map(AppointmentWorkDispatchMapper.TransportRow::itemId).distinct().sorted().toList()) dispatches.removeItem(number,item);
if(first.tripNo()!=null) fleet.bindDispatch(first.tripNo(),operator,new FleetTripModels.BindingCommand(trips.findByNo(first.tripNo()).getRecordVersion(),number,reason));
next.add("按后送批次重新选择已移出的实物");
}
}
for(var number:context.tripNos()) {
var detail=fleet.detail(number);
if(detail.dispatches().isEmpty() && !reservations.hasConfirmed(trips.findByNo(number).getId())) {
// The original cancellation command also checks external reservations and releases vehicle/driver resources.
fleet.cancel(number,operator,new FleetTripModels.Cancel(detail.trip().recordVersion(),reason));
next.add("原车无有效货物,已按车辆业务取消");
} else next.add("核对原车剩余货物及发车安排");
}
if(!affected.isEmpty()) changes.requirementsChanged();
var follow=new LinkedHashSet<>(context.batches());intents.values().stream().map(Move::laterBatchKey).filter(Objects::nonNull).forEach(follow::add);
return new Result(vehicles(read(List.copyOf(follow),context.tripNos())),List.copyOf(next));
}
private Set<Long> selectWholeItems(Map<Long,List<AppointmentWorkDispatchMapper.TransportRow>> byItem,Map<Source,Integer> wanted) {
var candidates=byItem.entrySet().stream().filter(e->e.getValue().stream().allMatch(r->wanted.getOrDefault(source(r),0)>0)).toList();
var selected=new LinkedHashSet<Long>();var budget=new int[]{10000};
if(!select(candidates,0,new HashMap<>(wanted),selected,budget)) throw conflict("移出数量不能精确匹配完整实物明细,请先在原库存业务按实际货物拆托后重试");
return selected;
}
private boolean select(List<Map.Entry<Long,List<AppointmentWorkDispatchMapper.TransportRow>>> items,int index,Map<Source,Integer> remaining,Set<Long> chosen,int[] budget) {
if(remaining.values().stream().allMatch(n->n==0)) return true;
if(index>=items.size() || --budget[0]<0) return false;
var item=items.get(index);var amounts=item.getValue().stream().collect(Collectors.groupingBy(this::source,Collectors.summingInt(AppointmentWorkDispatchMapper.TransportRow::packages)));
if(amounts.entrySet().stream().allMatch(e->e.getValue()<=remaining.getOrDefault(e.getKey(),0))) {
amounts.forEach((k,v)->remaining.compute(k,(ignored,n)->n-v));chosen.add(item.getKey());
if(select(items,index+1,remaining,chosen,budget)) return true;
chosen.remove(item.getKey());amounts.forEach((k,v)->remaining.compute(k,(ignored,n)->n+v));
}
return select(items,index+1,remaining,chosen,budget);
}
private Source source(AppointmentWorkDispatchMapper.TransportRow row) { return new Source(row.batchKey(),row.customerId(),row.sourceKey(),row.destinationKey()); }
private List<String> keys(List<String> keys) {
if(keys==null || keys.isEmpty() || keys.size()>100 || keys.stream().anyMatch(k->k==null || !k.matches("FBA_APPOINTMENT:[1-9][0-9]*"))) throw bad("请选择1至100个真实预约批次");
return keys.stream().distinct().sorted().toList();
}
private List<AppointmentWorkDispatchMapper.TransportRow> read(List<String> batches,List<String> tripNos) {
var rows=allocations.transportRows(batches,tripNos);if(rows.size()>1000) throw conflict("运输实物范围超过完整核对上限,请缩小范围");return rows;
}
private List<String> tripNos(List<AppointmentWorkDispatchMapper.TransportRow> rows) { return rows.stream().map(AppointmentWorkDispatchMapper.TransportRow::tripNo).filter(Objects::nonNull).distinct().sorted().toList(); }
private Scope scope(List<String> batches,List<AppointmentWorkDispatchMapper.TransportRow> rows) {
var reasons=new LinkedHashSet<String>();
for(var row:rows) if(batches.contains(row.batchKey())) {
if(!"PENDING_DEPARTURE".equals(row.dispatchStatus()) || row.pickedPackages()!=null && row.pickedPackages()>0 || row.shippedPackages()!=null && row.shippedPackages()>0
|| row.taskStatus()!=null && !"PDA_PENDING".equals(row.taskStatus()) || row.outboundStatus()!=null && !"WAIT_OUTBOUND".equals(row.outboundStatus())) reasons.add("排车已开始执行,不能普通移出;请进入原运输异常处理");
if(row.tripNo()!=null && !"PLANNED".equals(row.tripStatus())) reasons.add("原车次已开始执行,不能普通移出");
if(row.itemPackages()==null || row.packages()<=0) reasons.add("原排车实物数量不完整");
}
return new Scope(digest(batches+":"+rows),vehicles(rows),List.copyOf(reasons));
}
private List<Vehicle> vehicles(List<AppointmentWorkDispatchMapper.TransportRow> rows) {
return rows.stream().collect(Collectors.groupingBy(r->List.of(r.dispatchNo(),r.batchKey()),LinkedHashMap::new,Collectors.toList())).values().stream().map(group->{
var r=group.get(0);return new Vehicle(r.tripNo(),r.tripVersion()==null?null:Math.toIntExact(r.tripVersion()),r.dispatchNo(),r.customerId(),r.batchKey(),r.dispatchStatus(),group.stream().mapToInt(AppointmentWorkDispatchMapper.TransportRow::packages).sum());
}).toList();
}
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkFollowupInheritance;
import com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.util.List;
@Component
public class FbaAppointmentWorkAdapter implements FbaAppointmentWorkPort {
private final AppointmentWorkFollowupInheritance work;
public FbaAppointmentWorkAdapter(AppointmentWorkFollowupInheritance work) { this.work=work; }
public Followup resolveFollowup(String ownerId,LocalDate date) {
var resolved=work.resolve(ownerId,date);
return new Followup(resolved.owner().id(),resolved.owner().name(),resolved.nextFollowupDate());
}
public void seedFollowup(Member original,Member later,Followup followup,String operatorId) {
work.seed(member(original),member(later),new com.baiyue.lastleg.appointmentwork.AppointmentWorkStore.Followup(
new com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.Owner(followup.ownerId(),followup.ownerName()),followup.nextFollowupDate()),operatorId);
}
public void inheritFollowup(List<Member> originals,List<Member> successors,String operatorId) {
work.inherit(originals.stream().map(FbaAppointmentWorkAdapter::member).toList(),successors.stream().map(FbaAppointmentWorkAdapter::member).toList(),operatorId);
}
private static AppointmentWorkFollowupInheritance.Member member(Member member) { return new AppointmentWorkFollowupInheritance.Member(member.batchKey(),member.customerId()); }
}
@@ -0,0 +1,49 @@
-- V13 receiving coverage and pending changes; baseline only, no test data migration.
CREATE TABLE IF NOT EXISTS ll_fba_receiving_agreement (
agreement_key VARCHAR(64) NOT NULL COMMENT '收货预约独立业务键',
version INT NOT NULL COMMENT '预约覆盖范围与确认资料版本',
status VARCHAR(24) NOT NULL COMMENT '有效、取消或已替代状态',
destination_key VARCHAR(128) NOT NULL COMMENT '权威目的仓关联键',
confirmation_no VARCHAR(128) NULL COMMENT '当前有效外部约号,失效后清空以释放覆盖',
snapshot_json LONGTEXT NOT NULL COMMENT '客户逐来源覆盖、确认时区及仓库依据快照',
PRIMARY KEY (agreement_key),
UNIQUE KEY uk_fba_receiving_confirmation (destination_key, confirmation_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='独立于送货批次的收货预约';
CREATE TABLE IF NOT EXISTS ll_fba_receiving_member (
agreement_key VARCHAR(64) NOT NULL COMMENT '所属收货预约业务键',
batch_key VARCHAR(128) NOT NULL COMMENT '稳定送货批次键',
customer_id VARCHAR(128) NOT NULL COMMENT '货物所属客户标识',
active_ref VARCHAR(260) NULL COMMENT '有效批次与客户唯一引用,退出后清空且历史保留',
PRIMARY KEY (agreement_key,batch_key,customer_id),
UNIQUE KEY uk_fba_receiving_active_member (active_ref),
KEY idx_fba_receiving_batch (batch_key,customer_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收货预约当前覆盖关联及退出历史';
CREATE TABLE IF NOT EXISTS ll_fba_receiving_change (
change_key VARCHAR(64) NOT NULL COMMENT '客户预约变化业务键',
version INT NOT NULL COMMENT '变化续办版本',
status VARCHAR(24) NOT NULL COMMENT '待仓库确认、已应用或已撤回状态',
batch_key VARCHAR(128) NOT NULL COMMENT '受影响原送货批次键',
customer_id VARCHAR(128) NOT NULL COMMENT '本次变化所属客户',
snapshot_json LONGTEXT NOT NULL COMMENT '原覆盖快照、逐来源拟保留量、负责人及跟进资料',
result_json LONGTEXT NULL COMMENT '确认后的实际批次去向及逐步骤结果',
active_ref VARCHAR(260) NULL COMMENT '同批同客户唯一未解决变化引用',
PRIMARY KEY (change_key),
UNIQUE KEY uk_fba_receiving_pending (active_ref),
KEY idx_fba_receiving_pending_batch (batch_key,status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='保留原安排且阻止误发车的客户变化';
CREATE TABLE IF NOT EXISTS ll_fba_receiving_revision (
agreement_key VARCHAR(64) NOT NULL COMMENT '收货预约业务键',
version INT NOT NULL COMMENT '不可变覆盖与确认版本',
snapshot_json LONGTEXT NOT NULL COMMENT '该版本完整客户覆盖、确认时段及回复依据',
PRIMARY KEY (agreement_key,version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收货预约覆盖与确认不可变历史';
CREATE TABLE IF NOT EXISTS ll_fba_receiving_plan (
batch_key VARCHAR(128) NOT NULL COMMENT '稳定送货批次键',
customer_id VARCHAR(128) NOT NULL COMMENT '计划所属客户标识',
snapshot_json LONGTEXT NOT NULL COMMENT '本客户未确认计划、目标窗口及业务时区,不含有效预约确认',
PRIMARY KEY (batch_key,customer_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='按批次及客户保存的未确认收货计划';
@@ -1649,6 +1649,57 @@ CREATE TABLE IF NOT EXISTS ll_appointment_followup (
CONSTRAINT chk_appointment_followup_stage CHECK (stage IN ('DRAYAGE_CONTACT','TERMINAL_APPOINTMENT','WAREHOUSE_RECEIVING','FBA_RECEIVING'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='真实在途货源的预约筹划联系记录,追加留痕';
-- V13 receiving coverage and pending changes; baseline only, no test data migration.
CREATE TABLE IF NOT EXISTS ll_fba_receiving_agreement (
agreement_key VARCHAR(64) NOT NULL COMMENT '收货预约独立业务键',
version INT NOT NULL COMMENT '预约覆盖范围与确认资料版本',
status VARCHAR(24) NOT NULL COMMENT '有效、取消或已替代状态',
destination_key VARCHAR(128) NOT NULL COMMENT '权威目的仓关联键',
confirmation_no VARCHAR(128) NULL COMMENT '当前有效外部约号,失效后清空以释放覆盖',
snapshot_json LONGTEXT NOT NULL COMMENT '客户逐来源覆盖、确认时区及仓库依据快照',
PRIMARY KEY (agreement_key),
UNIQUE KEY uk_fba_receiving_confirmation (destination_key, confirmation_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='独立于送货批次的收货预约';
CREATE TABLE IF NOT EXISTS ll_fba_receiving_member (
agreement_key VARCHAR(64) NOT NULL COMMENT '所属收货预约业务键',
batch_key VARCHAR(128) NOT NULL COMMENT '稳定送货批次键',
customer_id VARCHAR(128) NOT NULL COMMENT '货物所属客户标识',
active_ref VARCHAR(260) NULL COMMENT '有效批次与客户唯一引用,退出后清空且历史保留',
PRIMARY KEY (agreement_key,batch_key,customer_id),
UNIQUE KEY uk_fba_receiving_active_member (active_ref),
KEY idx_fba_receiving_batch (batch_key,customer_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收货预约当前覆盖关联及退出历史';
CREATE TABLE IF NOT EXISTS ll_fba_receiving_change (
change_key VARCHAR(64) NOT NULL COMMENT '客户预约变化业务键',
version INT NOT NULL COMMENT '变化续办版本',
status VARCHAR(24) NOT NULL COMMENT '待仓库确认、已应用或已撤回状态',
batch_key VARCHAR(128) NOT NULL COMMENT '受影响原送货批次键',
customer_id VARCHAR(128) NOT NULL COMMENT '本次变化所属客户',
snapshot_json LONGTEXT NOT NULL COMMENT '原覆盖快照、逐来源拟保留量、负责人及跟进资料',
result_json LONGTEXT NULL COMMENT '确认后的实际批次去向及逐步骤结果',
active_ref VARCHAR(260) NULL COMMENT '同批同客户唯一未解决变化引用',
PRIMARY KEY (change_key),
UNIQUE KEY uk_fba_receiving_pending (active_ref),
KEY idx_fba_receiving_pending_batch (batch_key,status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='保留原安排且阻止误发车的客户变化';
CREATE TABLE IF NOT EXISTS ll_fba_receiving_revision (
agreement_key VARCHAR(64) NOT NULL COMMENT '收货预约业务键',
version INT NOT NULL COMMENT '不可变覆盖与确认版本',
snapshot_json LONGTEXT NOT NULL COMMENT '该版本完整客户覆盖、确认时段及回复依据',
PRIMARY KEY (agreement_key,version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收货预约覆盖与确认不可变历史';
CREATE TABLE IF NOT EXISTS ll_fba_receiving_plan (
batch_key VARCHAR(128) NOT NULL COMMENT '稳定送货批次键',
customer_id VARCHAR(128) NOT NULL COMMENT '计划所属客户标识',
snapshot_json LONGTEXT NOT NULL COMMENT '本客户未确认计划、目标窗口及业务时区,不含有效预约确认',
PRIMARY KEY (batch_key,customer_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='按批次及客户保存的未确认收货计划';
CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment (
id BIGINT NOT NULL COMMENT '主键ID',
appointment_no VARCHAR(64) NOT NULL COMMENT '系统预约业务单号,不是外部约号',
@@ -1675,7 +1726,6 @@ CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment (
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '更新UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_fba_appointment_no (appointment_no),
UNIQUE KEY uk_ll_fba_external_confirmation (fba_warehouse_code,confirmation_no),
KEY idx_ll_fba_appointment_list (appointment_at,appointment_status,fba_warehouse_code),
KEY idx_ll_fba_appointment_planned (planned_at,appointment_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='FBA预约安排与正式确认唯一主记录';
@@ -1692,7 +1742,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment_item (
package_count INT NULL COMMENT '本批分配箱数,未知保持空;同一原来源总量守恒',
cargo_verification_version INT NULL COMMENT '采用的预约货物核实版本,未核实时为空',
shipment_references_json LONGTEXT NULL COMMENT '本次采用的货件或追踪号核实快照',
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '取消或失败后释放货源占用',
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前批次数量分配有效;取消、未约成和完成均保留,仅替代时释放',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_fba_item_source (appointment_id,source_key),
@@ -12,7 +12,11 @@ paths:
summary: 按不可变批次键读取分批资料和操作条件
description: >-
仅平台,需预约列表及尾程订单列表权限。batchKey为FBA_APPOINTMENT加内部不可变主键,仅供关联,不作为展示编号。
返回appointment原快照、sources当前逐来源可用于重新分配的本批数量、allowedActions和blockReasons。
返回appointment原快照、sources当前逐来源可用于重新分配的本批数量、allowedActions、blockReasons及currentBatches全部当前去向。
currentBatches沿所有替代及客户后送分支读取到当前批次,按稳定键去重,并按原客户、来源、目的地排除无关分支。
originalSourceRefs是本批当前及已移出货物的客户/原来源/目的地范围,消费者用它过滤全部当前去向,不能用当前items漏掉已全部移出的客户;它不代表可重新分配的货量。
每项为当前批次完整权威快照(合并后可能含其他来源),不是旧批次逐箱归属拆账;消费者按所查看的客户和来源展示对应明细,不把整批合计当成本客户数量。
取消和未约成仍保留批次分配及当前去向;缺失关系、循环或超过1000节点明确报错,不返回截断成功或空货量。
原批次箱数未知且已独立核实总量时,本批数量由核实总量减其他活动批次分配量计算;不修改旧快照。
已替代、已确认或关联排车时不开放分批合并。原来源或核实事实读取失败明确报错,不以空列表冒充无货。
allowedActions表示业务资格,前端与FBA预约写权限取交集;实际写入仍在事务内重验。
@@ -31,6 +35,20 @@ paths:
type: object
properties:
appointment: {$ref: './fba-appointment-calendar-v1.yaml#/components/schemas/Appointment'}
currentBatches:
type: array
description: 全部当前批次快照,包含未约成、取消后仍需继续安排的批次,不仅返回第一个替代批次
items: {$ref: './fba-appointment-calendar-v1.yaml#/components/schemas/Appointment'}
originalSourceRefs:
type: array
description: 本批当前及历史移出来源,仅供当前去向过滤,不授予重新分配资格
items:
type: object
required: [customerId, sourceKey, destinationKey]
properties:
customerId: {type: string}
sourceKey: {type: string}
destinationKey: {type: string}
sources:
type: array
items:
@@ -59,7 +77,7 @@ paths:
仅平台,需platform:appointment:list、platform:overseas-warehouse:last-mile-order:list及platform:overseas-warehouse:inbound-order:fba-appointment。
originals为一条且batches多条表示分批;originals多条且batches一条表示合并,其他组合拒绝。
原批次必须同起运仓、同目的仓,尚未正式确认、未被替代且没有有效关联排车。
先按业务号有序锁原预约,再锁原BOOKING/订单;版本、来源资格、客户归属及数量均在锁内重新核对。
先按稳定批次键有序锁原预约,再锁原BOOKING/订单;版本、来源资格、客户归属及数量均在锁内重新核对。
按每个稳定来源分别比较调整前后总量,不能只比较合计箱数或跨客户挪量;逐来源预期版本及核实版本必须与最新资料一致。
原批次箱数未知时可采用刚保存的独立核实总量,无需先登记预约确认;仍未知则拒绝。
全部旧明细释放、新批次和来源明细建立、替代关系及幂等结果在同一事务内;任一失败全部回滚。
@@ -83,7 +83,7 @@ paths:
仅平台;同时需要platform:appointment:list、platform:overseas-warehouse:last-mile-order:list及platform:overseas-warehouse:inbound-order:fba-appointment。
与入仓明细入口共用同一FBA预约聚合;系统生成AP单号,不创建入仓计划、库存或车次。
PENDING允许日期、时段、外部约号及箱数暂缺。SUCCESS必须有权威目的仓时区、真实货件材料、确定箱数及外部约号和确认时段。
实际客户、起运仓、目的仓及原BOOKING由服务端核对;锁原来源后按剩余未分配箱数建立新安排,分批总量不能超过独立核实或原来源总量。取消只释放本批数量并保留历史。
实际客户、起运仓、目的仓及原BOOKING由服务端核对;锁原来源后按剩余未分配箱数建立新安排,分批总量不能超过独立核实或原来源总量。取消仅释放有效预约覆盖,仍需履约的本批分配保留,不重新作为整票余量;历史保留。
命令与主单、明细同事务;同键同操作人同内容重试返回原快照,不受后来编辑影响。异内容或来源占用冲突拒绝。
requestBody:
required: true
@@ -0,0 +1,852 @@
openapi: 3.0.3
info:
title: V13 收货预约覆盖、客户变化及重新预约
version: 1.0.0
description: 稳定批次与有效收货预约身份分离;Agreement.version覆盖资料和成员范围。新共约必须新agreementKey,不以约号相等推断成员。同一次移出为Lastleg本地事务:transport锁序车次→排车→出库/PDA→批次,任一步失败全部回滚,不返回批次部分成功。成功Result持久化且同键重放返回原快照;异内容冲突。后续约仓/配车独立续办使用成功batchKey,不重建已成功货物。WAITING保存原安排和数量,只有CONFIRM按原scope/source/verification及batch版本复验后应用;WITHDRAW保留原安排。实际发车另由原Dispatch/Fleet检查pending及有效收货范围。未知量、版本冲突、来源不可用均拒绝,不伪造0项。
负责人提交ownerId并由平台人员目录校验;后送及再次分批继承权威负责人和跟进资料。重约使用按客户核对的executionVersion,不让同批无关已完成客户阻断未完成员;已完成成员不得再次预约。WITHDRAW只撤回未生效变化,不改原货物/车辆,运输开始或版本变化不阻断撤回;仍须变化版本与幂等校验。
paths:
/api/platform/v1/last-leg/appointment-receiving/batches/{batchKey}:
get:
summary: 读取本批真实收货覆盖和待确认变化
description: '平台身份 tenantId=0。Required authority: platform:appointment:list'
security:
- bearerAuth: []
responses:
'200':
description: ApiResponse:成功code="200";业务错误按统一封装返回,不把HTTP 200当业务成功。
content:
application/json:
schema:
type: object
properties:
code: &id001
type: string
message: *id001
data:
$ref: '#/components/schemas/Context'
parameters:
- name: batchKey
in: path
required: true
schema: *id001
/api/platform/v1/last-leg/appointment-receiving/agreements/{agreementKey}:
get:
summary: 读取当前范围和取消组重约候选及排除原因
description: '平台身份 tenantId=0。Required authority: platform:appointment:list'
security:
- bearerAuth: []
responses:
'200':
description: ApiResponse:成功code="200";业务错误按统一封装返回,不把HTTP 200当业务成功。
content:
application/json:
schema:
type: object
properties:
code: *id001
message: *id001
data:
$ref: '#/components/schemas/GroupContext'
parameters:
- name: agreementKey
in: path
required: true
schema: *id001
/api/platform/v1/last-leg/appointment-receiving/changes:
post:
summary: 保存客户待确认变化,保留原安排并阻发
description: '平台身份 tenantId=0。Required authority: platform:overseas-warehouse:inbound-order:fba-appointment AND platform:appointment:delete'
security:
- bearerAuth: []
responses:
'200':
description: ApiResponse:成功code="200";业务错误按统一封装返回,不把HTTP 200当业务成功。
content:
application/json:
schema:
type: object
properties:
code: *id001
message: *id001
data:
$ref: '#/components/schemas/Result'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Propose'
/api/platform/v1/last-leg/appointment-receiving/changes/{changeKey}/resolve:
post:
summary: 确认并原子移出,或撤回本次变化
description: '平台身份 tenantId=0。Required authority: platform:overseas-warehouse:inbound-order:fba-appointment AND platform:appointment:delete'
security:
- bearerAuth: []
responses:
'200':
description: ApiResponse:成功code="200";业务错误按统一封装返回,不把HTTP 200当业务成功。
content:
application/json:
schema:
type: object
properties:
code: *id001
message: *id001
data:
$ref: '#/components/schemas/Result'
parameters:
- name: changeKey
in: path
required: true
schema: *id001
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Resolve'
/api/platform/v1/last-leg/appointment-receiving/bookings:
post:
summary: 沿用现有批次创建共同或独立收货预约
description: '平台身份 tenantId=0。Required authority: platform:overseas-warehouse:inbound-order:fba-appointment'
security:
- bearerAuth: []
responses:
'200':
description: ApiResponse:成功code="200";业务错误按统一封装返回,不把HTTP 200当业务成功。
content:
application/json:
schema:
type: object
properties:
code: *id001
message: *id001
data:
$ref: '#/components/schemas/Result'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Book'
/api/platform/v1/last-leg/appointment-receiving/agreements/{agreementKey}/reschedule:
post:
summary: 整份当前收货预约改期
description: '平台身份 tenantId=0。Required authority: platform:overseas-warehouse:inbound-order:fba-appointment'
security:
- bearerAuth: []
responses:
'200':
description: ApiResponse:成功code="200";业务错误按统一封装返回,不把HTTP 200当业务成功。
content:
application/json:
schema:
type: object
properties:
code: *id001
message: *id001
data:
$ref: '#/components/schemas/Result'
parameters:
- name: agreementKey
in: path
required: true
schema: *id001
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/GroupChange'
/api/platform/v1/last-leg/appointment-receiving/agreements/{agreementKey}/cancel:
post:
summary: 整份当前收货预约取消并保留货量及车辆
description: '平台身份 tenantId=0。Required authority: platform:appointment:delete'
security:
- bearerAuth: []
responses:
'200':
description: ApiResponse:成功code="200";业务错误按统一封装返回,不把HTTP 200当业务成功。
content:
application/json:
schema:
type: object
properties:
code: *id001
message: *id001
data:
$ref: '#/components/schemas/Result'
parameters:
- name: agreementKey
in: path
required: true
schema: *id001
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/GroupChange'
/api/platform/v1/last-leg/appointment-receiving/plans:
post:
summary: 只保存本客户未确认的送仓计划,不改变其他客户正式确认
description: '平台身份 tenantId=0。Required authority: platform:overseas-warehouse:inbound-order:fba-appointment。已正式确认、待变化确认或已执行客户不可直接改计划;清目标仍保留需求。计划不创建预约确认、不重新占量。'
security:
- bearerAuth: []
responses:
'200':
description: ApiResponse:成功code="200";业务错误按统一封装返回,不把HTTP 200当业务成功。
content:
application/json:
schema:
type: object
properties:
code: &id002
type: string
message: *id002
data:
$ref: '#/components/schemas/Result'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Plan'
/api/platform/v1/last-leg/appointment-receiving/execution-scope:
post:
summary: 按实际选择的客户批次只读核对执行资格和提交版本
description: '平台身份 tenantId=0。Required authority: platform:appointment:list。只读,不保存计划或预约。跨批次取消组只选部分成员时先读取本范围执行版本,前端重新确认选择;写入仍独立校验批次及取消组版本。'
security:
- bearerAuth: []
responses:
'200':
description: ApiResponse:成功code="200";业务错误按统一封装返回,不把HTTP 200当业务成功。
content:
application/json:
schema:
type: object
properties:
code: &id003
type: string
message: *id003
data:
$ref: '#/components/schemas/ExecutionScope'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ExecutionQuery'
/api/platform/v1/last-leg/appointment-receiving/cargo-refresh:
post:
summary: 明确核对并采用本客户最新货物资料
description: '平台身份 tenantId=0。Required authority: platform:overseas-warehouse:inbound-order:fba-appointment。仅未确认且未执行客户可采用,清晰核对本批量、最新核实总量及货件号;来源目的地或客户改变不得采用,不修改原订单。'
security:
- bearerAuth: []
responses:
'200':
description: ApiResponse:成功code="200";业务错误按统一封装返回,不把HTTP 200当业务成功。
content:
application/json:
schema:
type: object
properties:
code: &id004
type: string
message: *id004
data:
$ref: '#/components/schemas/Result'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RefreshCargo'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
schemas:
Source:
type: object
properties:
sourceKey: *id001
destinationKey: *id001
packageCount:
type: integer
minimum: 1
sourceVersion: *id001
verificationVersion:
type: integer
nullable: true
inboundCode:
type: string
nullable: true
description: 原来源入仓业务号;未知显示—
required:
- sourceKey
- destinationKey
- packageCount
- sourceVersion
- verificationVersion
Member:
type: object
properties:
batchKey: *id001
appointmentNo: *id001
batchVersion: &id005
type: integer
minimum: 0
customerId: *id001
sources:
type: array
items:
$ref: '#/components/schemas/Source'
customerName:
type: string
nullable: true
description: 原订单真实客户名称;未知显示—,不用内部标识回退
required:
- batchKey
- appointmentNo
- batchVersion
- customerId
- sources
Details:
type: object
properties:
plannedDate: &id006
type: string
format: date
nullable: true
plannedAt:
type: string
format: date-time
nullable: true
plannedEndAt:
type: string
format: date-time
nullable: true
confirmationNo: *id001
appointmentAt:
type: string
format: date-time
appointmentEndAt:
type: string
format: date-time
nullable: true
appointmentStatus:
type: string
enum:
- SUCCESS
appointmentRemark:
type: string
nullable: true
startOffset:
type: string
nullable: true
endOffset:
type: string
nullable: true
required:
- confirmationNo
- appointmentAt
- appointmentStatus
Agreement:
type: object
properties:
agreementKey: *id001
version: *id005
status:
type: string
enum:
- ACTIVE
- CANCELLED
- SUPERSEDED
destinationKey: *id001
details:
$ref: '#/components/schemas/Details'
timezone: *id001
members:
type: array
items:
$ref: '#/components/schemas/Member'
warehouseEvidence: *id001
required:
- agreementKey
- version
- status
- destinationKey
- details
- timezone
- members
- warehouseEvidence
Vehicle:
type: object
properties:
tripNo:
type: string
nullable: true
version:
type: integer
nullable: true
dispatchNo: *id001
customerId: *id001
batchKey: *id001
status: *id001
packageCount: *id005
required:
- tripNo
- version
- dispatchNo
- customerId
- batchKey
- status
- packageCount
Transport:
type: object
properties:
version: *id001
vehicles:
type: array
items:
$ref: '#/components/schemas/Vehicle'
blockReasons:
type: array
items: *id001
required:
- version
- vehicles
- blockReasons
Selection:
type: object
properties:
batchKey: *id001
batchVersion: *id005
customerId: *id001
required:
- batchKey
- batchVersion
- customerId
Quantity:
type: object
properties:
sourceKey: *id001
destinationKey: *id001
retainedPackages: *id005
expectedSourceVersion: *id001
expectedVerificationVersion:
type: integer
nullable: true
required:
- sourceKey
- destinationKey
- retainedPackages
- expectedSourceVersion
- expectedVerificationVersion
ChangeIntent:
type: object
properties:
batchKey: *id001
batchVersion: *id005
customerId: *id001
quantities:
type: array
items:
$ref: '#/components/schemas/Quantity'
laterDate: *id006
nextFollowupDate: *id006
reason: *id001
transportVersion: *id001
ownerId: *id001
required:
- batchKey
- batchVersion
- customerId
- quantities
- ownerId
- reason
- transportVersion
Pending:
type: object
properties:
changeKey: *id001
version: *id005
status:
type: string
enum:
- WAITING
- APPLIED
- WITHDRAWN
intent:
$ref: '#/components/schemas/ChangeIntent'
originalMembers:
type: array
items:
$ref: '#/components/schemas/Member'
originalAgreements:
type: array
items:
$ref: '#/components/schemas/Agreement'
followup:
$ref: '#/components/schemas/Followup'
required:
- changeKey
- version
- status
- intent
- originalMembers
- originalAgreements
- followup
Context:
type: object
properties:
batch:
$ref: ./fba-appointment-calendar-v1.yaml#/components/schemas/Appointment
agreements:
type: array
items:
$ref: '#/components/schemas/Agreement'
pendingChanges:
type: array
items:
$ref: '#/components/schemas/Pending'
transport:
$ref: '#/components/schemas/Transport'
allowedActions:
type: array
items:
type: string
enum:
- BOOK_RECEIVING
- PROPOSE_CUSTOMER_CHANGE
blockReasons:
type: array
items: *id001
executionScope:
$ref: '#/components/schemas/ExecutionScope'
plans:
type: array
items:
$ref: '#/components/schemas/CustomerPlan'
required:
- batch
- agreements
- pendingChanges
- transport
- allowedActions
- blockReasons
- executionScope
- plans
Excluded:
type: object
properties:
member:
$ref: '#/components/schemas/Member'
reason: *id001
currentAgreementKey:
type: string
nullable: true
required:
- member
- reason
- currentAgreementKey
GroupContext:
type: object
properties:
agreement:
$ref: '#/components/schemas/Agreement'
eligibleMembers:
type: array
items:
$ref: '#/components/schemas/Member'
excludedMembers:
type: array
items:
$ref: '#/components/schemas/Excluded'
transport:
$ref: '#/components/schemas/Transport'
executionScope:
$ref: '#/components/schemas/ExecutionScope'
required:
- agreement
- eligibleMembers
- excludedMembers
- transport
- executionScope
Propose:
type: object
properties:
idempotencyKey: *id001
intent:
$ref: '#/components/schemas/ChangeIntent'
required:
- idempotencyKey
- intent
Resolve:
type: object
properties:
idempotencyKey: *id001
version: *id005
decision:
type: string
enum:
- CONFIRM
- WITHDRAW
warehouseEvidence:
type: string
nullable: true
transportVersion: *id001
required:
- idempotencyKey
- version
- decision
- transportVersion
Booking:
type: object
properties:
members:
type: array
items:
$ref: '#/components/schemas/Selection'
details:
$ref: '#/components/schemas/Details'
warehouseEvidence: *id001
required:
- members
- details
- warehouseEvidence
Book:
type: object
properties:
idempotencyKey: *id001
bookings:
type: array
items:
$ref: '#/components/schemas/Booking'
cancelledAgreementKey:
type: string
nullable: true
cancelledAgreementVersion:
type: integer
nullable: true
executionVersion: *id001
required:
- idempotencyKey
- bookings
- executionVersion
GroupChange:
type: object
properties:
idempotencyKey: *id001
version: *id005
action:
type: string
enum:
- RESCHEDULE
- CANCEL
details:
$ref: '#/components/schemas/Details'
nullable: true
warehouseEvidence: *id001
transportVersion: *id001
required:
- idempotencyKey
- version
- action
- warehouseEvidence
- transportVersion
Step:
type: object
properties:
step: *id001
status:
type: string
enum:
- SUCCESS
targetKey: *id001
message: *id001
required:
- step
- status
- targetKey
- message
Result:
type: object
properties:
changeKey:
type: string
nullable: true
status:
type: string
enum:
- WAITING
- APPLIED
- WITHDRAWN
- UNCHANGED
batches:
type: array
items:
$ref: ./fba-appointment-calendar-v1.yaml#/components/schemas/Appointment
agreements:
type: array
items:
$ref: '#/components/schemas/Agreement'
steps:
type: array
items:
$ref: '#/components/schemas/Step'
nextActions:
type: array
items:
type: string
description: 主要下一步及原车辆业务返回的实际后续动作,沿Result中的稳定batchKey继续,不重复创建货物
required:
- changeKey
- status
- batches
- agreements
- steps
- nextActions
Followup:
type: object
properties:
ownerId:
type: string
ownerName:
type: string
nextFollowupDate:
type: string
format: date
nullable: true
required:
- ownerId
- ownerName
- nextFollowupDate
ExecutionMember:
type: object
properties:
batchKey:
type: string
customerId:
type: string
required:
- batchKey
- customerId
ExecutionEligibility:
type: object
properties:
member:
$ref: '#/components/schemas/ExecutionMember'
completed:
type: boolean
canRebook:
type: boolean
reason:
type: string
nullable: true
required:
- member
- completed
- canRebook
- reason
ExecutionScope:
type: object
description: 原业务按客户、批次及逐票履约事实核对;version绑定涉及批次的完整执行事实,资格仅针对所选客户。
properties:
version:
type: string
members:
type: array
items:
$ref: '#/components/schemas/ExecutionEligibility'
required:
- version
- members
CustomerPlan:
type: object
required:
- batchKey
- customerId
- details
properties:
batchKey:
type: string
customerId:
type: string
details:
$ref: ./fba-appointment-calendar-v1.yaml#/components/schemas/AppointmentDetails
timezone:
type: string
nullable: true
description: 按稳定批次及客户保存的未确认收货计划;仅PENDING/FAILED,目标可空,不包含有效确认或货物分配。
Plan:
type: object
required:
- idempotencyKey
- member
- details
- executionVersion
properties:
idempotencyKey:
type: string
maxLength: 64
member:
$ref: '#/components/schemas/Selection'
details:
$ref: ./fba-appointment-calendar-v1.yaml#/components/schemas/AppointmentDetails
executionVersion:
type: string
description: 仅PENDING/FAILED,confirmationNo、appointmentAt、appointmentEndAt必须为空;FAILED必须有原因。服务端校验客户实际执行资格、批次及来源/核实版本,同键幂等。 非空目标须目的仓有效IANA时区;计划时刻按DST与跨日规则校验。此命令中details.startOffset/endOffset对应计划窗口;仅日期或目标为空不携带offset。
ExecutionQuery:
type: object
required:
- members
properties:
members:
type: array
minItems: 1
maxItems: 200
uniqueItems: true
items:
$ref: '#/components/schemas/ExecutionMember'
CargoVersion:
type: object
required:
- sourceKey
- destinationKey
- expectedSourceVersion
properties:
sourceKey:
type: string
destinationKey:
type: string
expectedSourceVersion:
type: string
expectedVerificationVersion:
type: integer
minimum: 1
nullable: true
RefreshCargo:
type: object
required:
- idempotencyKey
- member
- sources
- executionVersion
properties:
idempotencyKey:
type: string
maxLength: 64
member:
$ref: '#/components/schemas/Selection'
sources:
type: array
minItems: 1
maxItems: 100
items:
$ref: '#/components/schemas/CargoVersion'
executionVersion:
type: string
description: 操作者先读取并核对本客户各原来源最新核实资料,再显式采用。只更新原批次本客户明细的源/核实版本和货件快照,原分配数量、来源身份和其他客户确认保持。已确认、待变化确认、已执行、源/核实版本变化或数量不守恒拒绝;同键幂等,不自动采用。
@@ -7,6 +7,43 @@ security:
-
bearerAuth: []
paths:
/api/platform/v1/last-leg/appointment-work/items/resolve:
get:
operationId: appointmentWorkResolveBatch
summary: 按当前权威批次精确进入送仓事项
description: "要求platform:appointment:list及原来源读取资格。batchKey使用原业务返回的完整稳定键;customerId来自所选客户货源。精确匹配返回detail,未传客户且存在多个客户时detail为空并返回candidates,必须选择后重新解析;不得选首项或回退已替代旧批次。需求关系未同步返回409,未生成返回404。"
parameters:
- name: batchKey
in: query
required: true
schema:
type: string
pattern: '^(FBA_APPOINTMENT|DISPATCH):[1-9][0-9]*$'
- name: customerId
in: query
required: false
schema: {type: string, minLength: 1}
responses:
200:
description: data为客户送仓精确解析结果或可见客户候选
content:
application/json:
schema:
type: object
required: [code, data]
properties:
code: {type: string}
message: {type: string, nullable: true}
data:
$ref: '#/components/schemas/BatchWorkResolution'
400:
description: 批次键无效
403:
description: 缺少平台或原来源读取资格
404:
description: 当前批次未生成活动送仓事项
409:
description: 需求关系尚未同步或存在不唯一的事项关系
/api/platform/v1/last-leg/appointment-work/calendar:
get:
operationId: "appointmentWorkCalendar"
@@ -874,12 +911,14 @@ components:
nextAction:
"$ref": "#/components/schemas/Action"
nullable: true
description: "按当前权限和白名单目标引导;已确认送仓预约可指向本批车辆安排或本车复核,不代表授权发车。取消预约沿原批次重新预约,不恢复旧确认。"
blockReasons:
type: "array"
items:
type: "string"
missingFields:
type: "array"
description: "未完成事项分别提示负责人、下次跟进日期和作业日期等缺口;跟进日期不能作为作业日历日期。"
items:
type: "string"
Review:
@@ -954,6 +993,25 @@ components:
type: "string"
summary:
type: "string"
BatchWorkResolution:
type: object
required: [batchKey, candidates]
properties:
batchKey: {type: string}
customerId: {type: string, nullable: true}
detail:
nullable: true
allOf:
- $ref: '#/components/schemas/WorkDetail'
candidates:
type: array
items:
type: object
required: [customerId, workItemKey]
properties:
customerId: {type: string}
workItemKey: {type: string}
batchNo: {type: string, nullable: true}
WorkDetail:
type: "object"
additionalProperties: false
@@ -1237,6 +1295,7 @@ components:
maxLength: 1000
RESOLVE_REVIEWRequest:
type: "object"
description: "须匹配当前影响版本,并核对本事项及关联安排的最新权威影响;需求关系未同步或出现新影响时拒绝清除。车次货物成员变化仅要求相关客户及车辆核对,未变化客户预约不连带复核;车辆、司机、时间变化影响当前关联预约。复核不恢复取消的确认、不修改原预约和车辆,也不制造新的业务影响。"
additionalProperties: false
required:
- "command"
@@ -33,8 +33,8 @@ paths:
required: true
schema: {type: string}
delete:
summary: 取消未关联有效排车的预约并释放占用
description: '平台专用,权限platform:appointment:delete。校验版本后保留主单、确认事实及明细历史,设置CANCELLED并释放货源占用;不删除客户货物或回退库存。已有关联有效排车时拒绝。取消原因必填,独立保存于取消命令历史,不覆盖原业务备注;按系统单号、版本、操作者及原因校验幂等,异内容拒绝。'
summary: 取消未关联有效排车的预约覆盖并保留本批货量
description: '平台专用,权限platform:appointment:delete。校验版本后设置CANCELLED,清除当前有效计划日期、确认号及时段;旧计划与确认保存在不可变取消命令历史。货物分配保持有效,不能再次从整票创建相同货量;不删除客户货物或回退库存。已有关联有效排车时拒绝。取消原因必填,独立保存于取消命令历史,不覆盖原业务备注;按系统单号、版本、操作者及原因校验幂等,异内容拒绝。'
requestBody:
required: true
content:
@@ -56,7 +56,7 @@ paths:
'403': {description: 无读取权限}
put:
summary: 按版本更新预约
description: '权限:platform:overseas-warehouse:inbound-order:fba-appointment。平台限定,版本及持久化幂等校验;已有有效排车时先处理运输安排。未约成必须写原因并保留原确认时间。禁止通过更新设置CANCELLED,取消必须调用DELETE并通过独立取消权限、实际原因和历史规则。恢复时重新核对来源、目的仓和占用;不能直接将SUCCESS退回PENDING。'
description: '权限:platform:overseas-warehouse:inbound-order:fba-appointment。平台限定,版本及持久化幂等校验;已有有效排车时先处理运输安排。未约成必须写原因,清除无效确认,但保留本批货物分配。禁止通过更新设置CANCELLED,取消必须调用DELETE并通过独立取消权限、实际原因和历史规则。重新安排时重新核对来源、目的仓和占用,沿原批次数量继续;不能直接将SUCCESS退回PENDING。'
requestBody:
required: true
content:
@@ -358,6 +358,14 @@ components:
properties:
trip: {$ref: '#/components/schemas/Trip'}
dispatches: {type: array, items: {$ref: '#/components/schemas/DispatchBinding'}}
previousDispatches:
type: array
description: 原绑定历史中已不在当前绑定的批次、客户及来源引用,按最新有效证据去重;历史数量不作为当前装载或可分配数量
items: {$ref: '#/components/schemas/PreviousDispatch'}
appointmentHistoryUnavailableReason:
type: string
nullable: true
description: 原绑定历史缺少引用证据或超过1000条读取上限时的明确原因;仅历史引用不可完整使用,当前车次和绑定仍可读
loadingReview: {$ref: '#/components/schemas/ReviewState'}
driverQualification:
type: object
@@ -681,13 +689,24 @@ components:
recordVersion: {type: integer, format: int64, nullable: true, description: 本次审核所核对的车次版本}
DispatchBinding:
type: object
required: [appointmentBatches]
properties:
appointmentBatches: {type: array, items: {$ref: 'overseas-business-v1.yaml#/components/schemas/DispatchBatchReference'}}
dispatchNo: {type: string}
customerId: {type: string, description: 排车单自身客户归属,由服务端解析}
packageCount: {type: integer, description: 关联时待发运箱数,不是实物托位数}
boundBy: {type: string}
boundAt: {type: string, format: date-time}
review: {$ref: '#/components/schemas/ReviewState'}
PreviousDispatch:
type: object
required: [dispatchNo, customerId, recordVersion, recordedAt, appointmentBatches]
properties:
dispatchNo: {type: string}
customerId: {type: string}
recordVersion: {type: integer, format: int64, description: 原绑定历史的车次版本}
recordedAt: {type: string, format: date-time}
appointmentBatches: {type: array, items: {$ref: 'overseas-business-v1.yaml#/components/schemas/DispatchBatchReference'}}
Envelope:
type: object
properties:
@@ -3048,6 +3048,7 @@ components:
properties: { reason: { type: string, minLength: 1, maxLength: 1000 } }
DispatchResponse:
type: object
required: [appointmentBatches]
properties:
id: { type: string }
dispatchNo: { type: string }
@@ -3060,6 +3061,10 @@ components:
overseasWarehouseId: { type: string }
overseasWarehouseName: { type: string }
departAt: { type: string, format: date-time, nullable: true }
appointmentBatches:
type: array
description: 当前排车分配中的全部原批次、客户及来源引用;无预约分配时为空,不通过旧预约号推导
items: { $ref: '#/components/schemas/DispatchBatchReference' }
fbaAppointmentNo: { type: string, nullable: true }
fbaAppointmentStatus: { type: string }
vehicleAppointmentStatus: { type: string }
@@ -3082,6 +3087,21 @@ components:
type: array
description: 仅详情接口返回的BOL打印聚合行;分页接口返回空数组,避免列表查询放大
items: { $ref: '#/components/schemas/DispatchBolLine' }
DispatchBatchSource:
type: object
required: [sourceKey, destinationKey, packageCount]
properties:
sourceKey: { type: string }
destinationKey: { type: string }
packageCount: { type: integer, minimum: 1 }
DispatchBatchReference:
type: object
required: [batchKey, batchNo, customerId, sources]
properties:
batchKey: { type: string, description: 原分配持久化的稳定批次键 }
batchNo: { type: string, nullable: true, description: 原批次业务号;缺失不以内码代替 }
customerId: { type: string }
sources: { type: array, items: { $ref: '#/components/schemas/DispatchBatchSource' } }
DispatchWorkflow:
type: object
required: [tripNo, dispatchReview, loadingReview]
@@ -28,7 +28,7 @@ class AppointmentWorkContractTest {
var options=new org.yaml.snakeyaml.LoaderOptions();options.setAllowDuplicateKeys(false);
Map<String,Object> root; try(var input=getClass().getResourceAsStream("/openapi/appointment-work-v1.yaml")) { assertNotNull(input); root=new Yaml(new org.yaml.snakeyaml.constructor.SafeConstructor(options)).load(input); }
var paths=(Map<?,?>)root.get("paths"); var prefix="/api/platform/v1/last-leg/appointment-work";
for(var path:List.of("/calendar","/summary","/items","/items/{workItemKey}","/items/{workItemKey}/commands")) assertTrue(paths.containsKey(prefix+path));
for(var path:List.of("/calendar","/summary","/items","/items/resolve","/items/{workItemKey}","/items/{workItemKey}/commands")) assertTrue(paths.containsKey(prefix+path));
var schemas=(Map<?,?>)((Map<?,?>)root.get("components")).get("schemas");
var command=(Map<?,?>)schemas.get("CommandRequest"); assertEquals(6,((List<?>)command.get("oneOf")).size());
var item=(Map<?,?>)((Map<?,?>)schemas.get("WorkItem")).get("properties"); assertTrue(item.containsKey("containers")); assertFalse(item.containsKey("containerBookingId"));
@@ -23,6 +23,6 @@ class AppointmentWorkDirectTransportProviderTest {
assertFalse(provider.supports(Type.TRANSPORT,Flow.TRANSFER));
}
private Fact fact(String key,ExecutionStatus execution,Actual actual) {
return new Fact(key,"v1","impact",false,PlanStatus.UNPLANNED,execution,null,null,actual,null,"CNT1",null,List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of("SOURCE_TIMEZONE"),List.of(),null);
return new Fact(key,"v1","impact",false,PlanStatus.UNPLANNED,execution,null,null,actual,null,"CNT1",null,List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of("SOURCE_TIMEZONE"),List.of(),null,null);
}
}
@@ -0,0 +1,71 @@
package com.baiyue.lastleg.appointmentwork;
import org.junit.jupiter.api.Test;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFollowupInheritance.*;
class AppointmentWorkFollowupInheritanceTest {
@Test void repartitionSupportsTwentyBatchesWithOneHundredCustomerSources() {
var f=new WorkTestFixture();var service=new AppointmentWorkFollowupInheritance(f.store,mock(OwnerDirectory.class));
var originals=new ArrayList<Member>();var successors=new ArrayList<Member>();
for(int batch=1;batch<=20;batch++) for(int customer=1;customer<=100;customer++) {
originals.add(new Member("FBA_APPOINTMENT:"+batch,"C"+customer));
successors.add(new Member("FBA_APPOINTMENT:"+(batch+20),"C"+customer));
}
assertDoesNotThrow(()->service.inherit(originals,successors,"operator"));
successors.add(new Member("FBA_APPOINTMENT:41","C1"));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.inherit(originals,successors,"operator"));
}
@Test void missingFollowupDateStaysMissingAndMergeCannotSilentlyDiscardAnUnassignedSource() {
var f=new WorkTestFixture();var directory=mock(OwnerDirectory.class);when(directory.activePlatformOwner("17")).thenReturn(new Owner("17","负责人"));
var service=new AppointmentWorkFollowupInheritance(f.store,directory);var source=new Member("FBA_APPOINTMENT:1","B");var later=new Member("FBA_APPOINTMENT:2","B");
var followup=service.resolve("17",null);service.seed(source,later,followup,"operator");generate(f,later);
var state=f.store.get(workKey(later));assertNull(state.getFollowupDate());assertEquals("17",state.getOwner().id());
var item=project(state,WorkTestFixture.fact(state,null,null,ExecutionStatus.PENDING,null),WorkTestFixture.ACTOR,WorkTestFixture.CLOCK);
assertTrue(item.missingFields().contains("下次跟进日期待安排"));
var unassigned=new Member("FBA_APPOINTMENT:3","B");
var empty=f.state("1",Type.DELIVERY,Flow.TRANSFER,"empty");empty.setWorkItemKey(workKey(unassigned));f.add(empty);
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.inherit(List.of(later,unassigned),List.of(new Member("FBA_APPOINTMENT:4","B")),"operator"));
}
@Test void appliedFollowupSurvivesBeforeDiscoveryAndLaterRepartitionUsesLatestSavedWork() {
var f=new WorkTestFixture();var directory=mock(OwnerDirectory.class);
when(directory.activePlatformOwner("17")).thenReturn(new Owner("17","真实平台负责人"));
var service=new AppointmentWorkFollowupInheritance(f.store,directory);
var original=new Member("FBA_APPOINTMENT:1","B");var later=new Member("FBA_APPOINTMENT:2","B");
var date=LocalDate.of(2026,10,22);var followup=service.resolve("17",date);
service.seed(original,later,followup,"operator");
assertTrue(f.store.all().isEmpty()); // An audit seed does not invent a sailed source or physical batch.
var forty=new Member("FBA_APPOINTMENT:3","B");var twenty=new Member("FBA_APPOINTMENT:4","B");
service.inherit(List.of(later),List.of(forty,twenty),"operator");
assertEquals(followup,f.store.inheritedFollowup(workKey(forty)));assertEquals(followup,f.store.inheritedFollowup(workKey(twenty)));
generate(f,forty);var state=f.store.get(workKey(forty));
assertEquals(new Owner("17","真实平台负责人"),state.getOwner());assertEquals(date,state.getFollowupDate());
state.setOwner(new Owner("18","新负责人"));state.setFollowupDate(date.plusDays(1));f.store.save(state);
var next=new Member("FBA_APPOINTMENT:5","B");service.inherit(List.of(forty),List.of(next),"operator");generate(f,next);
assertEquals(state.getOwner(),f.store.get(workKey(next)).getOwner());assertEquals(date.plusDays(1),f.store.get(workKey(next)).getFollowupDate());
verify(directory).activePlatformOwner("17");
}
@Test void freeTextOwnerCannotBecomeAnAccountAndConflictingMergeDoesNotChooseFirst() {
var f=new WorkTestFixture();var directory=mock(OwnerDirectory.class);var service=new AppointmentWorkFollowupInheritance(f.store,directory);
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.resolve("客服张三",LocalDate.now()));
when(directory.activePlatformOwner("1")).thenReturn(new Owner("2","不匹配"));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.resolve("1",LocalDate.now()));
var first=new Member("FBA_APPOINTMENT:2","B");var second=new Member("FBA_APPOINTMENT:3","B");var merged=new Member("FBA_APPOINTMENT:4","B");
service.seed(new Member("FBA_APPOINTMENT:1","B"),first,new AppointmentWorkStore.Followup(new Owner("1","甲"),LocalDate.of(2026,10,20)),"operator");
service.seed(new Member("FBA_APPOINTMENT:1","B"),second,new AppointmentWorkStore.Followup(new Owner("2","乙"),LocalDate.of(2026,10,21)),"operator");
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.inherit(List.of(first,second),List.of(merged),"operator"));
assertNull(f.store.inheritedFollowup(workKey(merged)));
}
private static String workKey(Member m) {return key("BATCH:"+m.batchKey(),Type.DELIVERY,customerRequirement(m.batchKey(),m.customerId()));}
private static void generate(WorkTestFixture f,Member member) {
var requirement=new Requirement(customerRequirement(member.batchKey(),member.customerId()),member.batchKey(),"AP","FBA","仓","ONT8",null,List.of(),List.of(),List.of(new SourceReference(member.customerId(),"ORDER:2","FBA:ONT8")));
var source=new ContainerSource("1","柜",null,LocalDateTime.of(2026,10,1,12,0),null,"v1",Flow.TRANSFER,null,List.of(requirement));
f.sync.generate(source,Flow.TRANSFER,List.of(),WorkTestFixture.ACTOR);
}
}
@@ -11,11 +11,120 @@ import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*;
class AppointmentWorkPersistenceTest {
@Test void independentCustomerPlanChangesOnlyItsPersistedSemanticImpact() throws Exception {
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),database()));config.addMapper(AppointmentWorkMapper.class);
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession(true)) {
var mapper=session.getMapper(AppointmentWorkMapper.class);var json=new ObjectMapper().findAndRegisterModules();
var commitments=new AppointmentWorkCommitmentService(mapper,json);
var receiving=mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class);
var changes=new com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges(commitments,mock(AppointmentWorkChanges.class),receiving);
String key="FBA_APPOINTMENT:1",zone="America/Los_Angeles";
var batch=mock(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.class);
when(batch.batchKey()).thenReturn(key);when(batch.appointmentStatus()).thenReturn("SUCCESS");when(batch.plannedDate()).thenReturn(LocalDate.of(2026,10,19));
var items=List.of("A","B","D").stream().map(customer->{
var item=mock(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.Item.class);
when(item.customerId()).thenReturn(customer);when(item.sourceKey()).thenReturn("ORDER:1");when(item.destinationKey()).thenReturn("FBA:ONT8");when(item.packageCount()).thenReturn(100);return item;
}).toList();when(batch.items()).thenReturn(items);
var agreement=new Agreement("RA-A",1,"ACTIVE","FBA:ONT8",new com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails(null,null,null,"CONF-A",LocalDateTime.of(2026,10,20,10,0),null,"SUCCESS",null,null,null),zone,List.of(new Member(key,"AP-1",1,"A",List.of(),null)),"正式确认");
var plan=new java.util.concurrent.atomic.AtomicReference<>(new CustomerPlan(key,"B",customerPlanDetails(LocalDate.of(2026,10,21),"PENDING",null),zone));
when(receiving.snapshots(List.of(key))).thenAnswer(call->List.of(new BatchReceiving(key,List.of(agreement),List.of(),List.of(plan.get()))));
changes.changed(batch);
var beforeA=mapper.commitment(key+"|CUSTOMER:A").getStateJson();var beforeD=mapper.commitment(key+"|CUSTOMER:D").getStateJson();
plan.set(new CustomerPlan(key,"B",customerPlanDetails(LocalDate.of(2026,10,22),"FAILED","未约成"),zone));changes.changed(batch);
assertEquals(key+"|CUSTOMER:B:1",commitments.impactToken(key+"|CUSTOMER:B"));
changes.changed(batch);assertEquals(key+"|CUSTOMER:B:1",commitments.impactToken(key+"|CUSTOMER:B"));
plan.set(new CustomerPlan(key,"B",customerPlanDetails(LocalDate.of(2026,10,22),"PENDING","继续联系"),zone));changes.changed(batch);
assertEquals(key+"|CUSTOMER:B:1",commitments.impactToken(key+"|CUSTOMER:B")); // Status/notes do not invent a changed promised date.
plan.set(new CustomerPlan(key,"B",customerPlanDetails(null,"PENDING",null),zone));changes.changed(batch);changes.changed(batch);
assertEquals(key+"|CUSTOMER:B:2",commitments.impactToken(key+"|CUSTOMER:B"));
assertNull(json.readValue(mapper.commitment(key+"|CUSTOMER:B").getStateJson(),AppointmentWorkCommitmentService.Commitment.class).target());
assertEquals(key+"|CUSTOMER:A:0",commitments.impactToken(key+"|CUSTOMER:A"));assertEquals(beforeA,mapper.commitment(key+"|CUSTOMER:A").getStateJson());
assertEquals(key+"|CUSTOMER:D:0",commitments.impactToken(key+"|CUSTOMER:D"));assertEquals(beforeD,mapper.commitment(key+"|CUSTOMER:D").getStateJson());
}
}
private static com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails customerPlanDetails(LocalDate date,String status,String remark) {
return new com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails(date,null,null,null,null,null,status,remark,null,null);
}
@Test void followupPortPersistsFutureSeedAndRollsBackWithOriginalTransaction() throws Exception {
var ds=database();var jdbc=new org.springframework.jdbc.core.JdbcTemplate(ds);
jdbc.execute("CREATE TABLE core_probe(id INT PRIMARY KEY,quantity INT)");jdbc.update("INSERT INTO core_probe VALUES(1,160)");
var configuration=new MybatisConfiguration();configuration.setMapUnderscoreToCamelCase(true);configuration.addMapper(AppointmentWorkMapper.class);
var factory=new com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(configuration);
var session=new org.mybatis.spring.SqlSessionTemplate(factory.getObject());var json=new ObjectMapper().findAndRegisterModules();
var store=new AppointmentWorkStore(session.getMapper(AppointmentWorkMapper.class),json);
var directory=org.mockito.Mockito.mock(AppointmentWorkFactGateway.OwnerDirectory.class);
org.mockito.Mockito.when(directory.activePlatformOwner("17")).thenReturn(new Owner("17","平台负责人"));
var manager=new org.springframework.jdbc.datasource.DataSourceTransactionManager(ds);
var proxy=new org.springframework.aop.framework.ProxyFactory(new AppointmentWorkFollowupInheritance(store,directory));proxy.setProxyTargetClass(true);
proxy.addAdvice(new org.springframework.transaction.interceptor.TransactionInterceptor(manager,new org.springframework.transaction.annotation.AnnotationTransactionAttributeSource()));
var port=new com.baiyue.lastleg.inboundplan.work.FbaAppointmentWorkAdapter((AppointmentWorkFollowupInheritance)proxy.getProxy());
var original=new com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort.Member("FBA_APPOINTMENT:1","B");
var later=new com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort.Member("FBA_APPOINTMENT:2","B");
var followup=port.resolveFollowup("17",LocalDate.of(2026,10,22));
var key=AppointmentWorkPolicy.key("BATCH:"+later.batchKey(),Type.DELIVERY,AppointmentWorkPolicy.customerRequirement(later.batchKey(),later.customerId()));
var tx=new org.springframework.transaction.support.TransactionTemplate(manager);
assertThrows(IllegalStateException.class,()->tx.executeWithoutResult(status->{jdbc.update("UPDATE core_probe SET quantity=100 WHERE id=1");port.seedFollowup(original,later,followup,"operator");throw new IllegalStateException("后续原域命令失败");}));
assertNull(store.inheritedFollowup(key));assertEquals(160,jdbc.queryForObject("SELECT quantity FROM core_probe WHERE id=1",Integer.class));
tx.executeWithoutResult(status->port.seedFollowup(original,later,followup,"operator"));
var persisted=new AppointmentWorkStore(session.getMapper(AppointmentWorkMapper.class),json).inheritedFollowup(key);
assertEquals(new Owner("17","平台负责人"),persisted.owner());assertEquals(followup.nextFollowupDate(),persisted.nextFollowupDate());
var requirement=new AppointmentWorkFactGateway.Requirement(AppointmentWorkPolicy.customerRequirement(later.batchKey(),"B"),later.batchKey(),"AP-2","FBA","海外仓","ONT8",null,List.of(),List.of(),List.of(new SourceReference("B","ORDER:2","FBA:ONT8")));
var source=new AppointmentWorkFactGateway.ContainerSource("1","CNT",null,LocalDateTime.of(2026,10,1,12,0),null,"v1",Flow.TRANSFER,null,List.of(requirement));
var facts=org.mockito.Mockito.mock(AppointmentWorkFactGateway.class);
org.mockito.Mockito.when(facts.requirements(source,Flow.TRANSFER)).thenReturn(List.of(requirement));
tx.executeWithoutResult(status->new AppointmentWorkSyncService(store,facts).generate(source,Flow.TRANSFER,List.of(),ACTOR));
assertEquals(persisted.owner(),store.get(key).getOwner());assertEquals(persisted.nextFollowupDate(),store.get(key).getFollowupDate());
var third=new com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort.Member("FBA_APPOINTMENT:3","B");
tx.executeWithoutResult(status->port.seedFollowup(original,third,new com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort.Followup("17","平台负责人",null),"operator"));
var thirdKey=AppointmentWorkPolicy.key("BATCH:"+third.batchKey(),Type.DELIVERY,AppointmentWorkPolicy.customerRequirement(third.batchKey(),"B"));
assertNull(store.inheritedFollowup(thirdKey).nextFollowupDate());
org.mockito.Mockito.verify(directory).activePlatformOwner("17"); // Seeding trusted core output never repeats IAM under cargo locks.
}
@Test void failedBookingPersistsTargetAndClearsConfirmationWhileCancellationClearsBoth() throws Exception {
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),database()));config.addMapper(AppointmentWorkMapper.class);
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession(true)) {
var mapper=session.getMapper(AppointmentWorkMapper.class);var json=new ObjectMapper().findAndRegisterModules();
var commitments=new AppointmentWorkCommitmentService(mapper,json);
var receiving=org.mockito.Mockito.mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class);
var changes=new com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges(commitments,org.mockito.Mockito.mock(AppointmentWorkChanges.class),receiving);
var appointment=org.mockito.Mockito.mock(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.class);
var groupRevision=new java.util.concurrent.atomic.AtomicInteger(1);
var item=org.mockito.Mockito.mock(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.Item.class);
org.mockito.Mockito.when(item.customerId()).thenReturn("C");org.mockito.Mockito.when(item.sourceKey()).thenReturn("ORDER:1");
org.mockito.Mockito.when(appointment.items()).thenReturn(List.of(item));
org.mockito.Mockito.when(receiving.snapshots(List.of("FBA_APPOINTMENT:1"))).thenAnswer(call->{
var details=new com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails(appointment.plannedDate(),null,null,"CONF",appointment.appointmentAt(),null,"SUCCESS",null,null,null);
var member=new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Member(appointment.batchKey(),"AP-1",0,"C",List.of(),null);
var group=new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Agreement("RA-1",groupRevision.get(),"ACTIVE","FBA:ONT8",details,appointment.timezone(),List.of(member),"confirmed");
return List.of(new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.BatchReceiving(appointment.batchKey(),"SUCCESS".equals(appointment.appointmentStatus())?List.of(group):List.of(),List.of(),List.of()));
});
org.mockito.Mockito.when(appointment.batchKey()).thenReturn("FBA_APPOINTMENT:1");
org.mockito.Mockito.when(appointment.plannedDate()).thenReturn(LocalDate.of(2026,10,18));
org.mockito.Mockito.when(appointment.timezone()).thenReturn("America/Los_Angeles");
org.mockito.Mockito.when(appointment.appointmentAt()).thenReturn(LocalDateTime.of(2026,10,18,10,0));
org.mockito.Mockito.when(appointment.appointmentStatus()).thenReturn("SUCCESS");changes.changed(appointment);
groupRevision.set(8);changes.changed(appointment);
assertEquals("FBA_APPOINTMENT:1|CUSTOMER:C:0",commitments.impactToken("FBA_APPOINTMENT:1|CUSTOMER:C"));
org.mockito.Mockito.when(appointment.appointmentStatus()).thenReturn("FAILED");changes.changed(appointment);
var failed=json.readValue(mapper.commitment("FBA_APPOINTMENT:1|CUSTOMER:C").getStateJson(),AppointmentWorkCommitmentService.Commitment.class);
assertEquals(LocalDate.of(2026,10,18),failed.target().date());assertNull(failed.confirmed());assertFalse(failed.cancelled());
assertEquals("FBA_APPOINTMENT:1|CUSTOMER:C:1",commitments.impactToken("FBA_APPOINTMENT:1|CUSTOMER:C"));
changes.changed(appointment);assertEquals("FBA_APPOINTMENT:1|CUSTOMER:C:1",commitments.impactToken("FBA_APPOINTMENT:1|CUSTOMER:C"));
org.mockito.Mockito.when(appointment.appointmentStatus()).thenReturn("CANCELLED");changes.changed(appointment);
var cancelled=json.readValue(mapper.commitment("FBA_APPOINTMENT:1|CUSTOMER:C").getStateJson(),AppointmentWorkCommitmentService.Commitment.class);
assertNull(cancelled.target());assertNull(cancelled.confirmed());assertTrue(cancelled.cancelled());
assertEquals("FBA_APPOINTMENT:1|CUSTOMER:C:2",commitments.impactToken("FBA_APPOINTMENT:1|CUSTOMER:C"));
}
}
@Test void ddlAndMapperEnforceStableIdentityVersionAndGlobalCommandKey() throws Exception {
var ds=database();
var config=new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true);
@@ -12,6 +12,31 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
class AppointmentWorkQueryServiceTest {
@Test void sameBatchRequiresExplicitCustomerAndNeverChoosesTheFirstCandidate() {
var f=new WorkTestFixture();var expected=new HashMap<String,String>();
for(var customer:List.of("A","B")) {
var state=f.state("1",Type.DELIVERY,Flow.TRANSFER,"batch-"+customer);state.setBatchKey("FBA_APPOINTMENT:1");
state.setSources(List.of(new SourceReference(customer,"ORDER:"+customer,"FBA:ONT8")));f.add(state);expected.put(customer,state.getWorkItemKey());
}
var selection=f.queries.resolveBatch("FBA_APPOINTMENT:1",null,ACTOR);
assertNull(selection.detail());assertNull(selection.customerId());assertEquals(List.of("A","B"),selection.candidates().stream().map(BatchWorkCandidate::customerId).toList());
var chosen=f.queries.resolveBatch("FBA_APPOINTMENT:1","B",ACTOR);
assertEquals("B",chosen.customerId());assertEquals(expected.get("B"),chosen.detail().item().workItemKey());
assertEquals(404,assertThrows(ResponseStatusException.class,()->f.queries.resolveBatch("FBA_APPOINTMENT:1","C",ACTOR)).getStatusCode().value());
}
@Test void exactBatchResolutionSelectsCurrentFortyAndTwentyWithoutReturningOldSixty() {
var f=new WorkTestFixture();
var old=f.state("1",Type.DELIVERY,Flow.TRANSFER,"old-sixty");old.setBatchKey("FBA_APPOINTMENT:60");old.setRetired(true);f.add(old);
var forty=f.state("1",Type.DELIVERY,Flow.TRANSFER,"new-forty");forty.setBatchKey("FBA_APPOINTMENT:40");forty.setSources(List.of(new SourceReference("C","ORDER:1","FBA:ONT8")));f.add(forty);
var twenty=f.state("1",Type.DELIVERY,Flow.TRANSFER,"new-twenty");twenty.setBatchKey("FBA_APPOINTMENT:20");twenty.setSources(List.of(new SourceReference("C","ORDER:1","FBA:ONT8")));f.add(twenty);
old.setReplacementKeys(List.of(forty.getWorkItemKey(),twenty.getWorkItemKey()));f.store.save(old);
assertEquals(forty.getWorkItemKey(),f.queries.resolveBatch("FBA_APPOINTMENT:40",null,ACTOR).detail().item().workItemKey());
assertEquals(twenty.getWorkItemKey(),f.queries.resolveBatch("FBA_APPOINTMENT:20",null,ACTOR).detail().item().workItemKey());
assertEquals(404,assertThrows(ResponseStatusException.class,()->f.queries.resolveBatch("FBA_APPOINTMENT:60",null,ACTOR)).getStatusCode().value());
assertEquals(403,assertThrows(ResponseStatusException.class,()->f.queries.resolveBatch("FBA_APPOINTMENT:40",null,new Actor("no-source","只读",Set.of("platform:appointment:list")))).getStatusCode().value());
f.guard.setRequirementVersion(2);f.guard.setSynchronizedRequirementVersion(1);
assertEquals(409,assertThrows(ResponseStatusException.class,()->f.queries.resolveBatch("FBA_APPOINTMENT:40",null,ACTOR)).getStatusCode().value());
}
@Test void followTodayPageOneSizeEightDoesNotReadUnrelatedRuntimeFactsOrPaginateBeforeCompletionChecks() {
var f=new WorkTestFixture();
for(int index=0;index<1000;index++) f.add(f.state("unrelated-"+index,Type.WAREHOUSE_RECEIPT,Flow.TRANSFER,null));
@@ -12,6 +12,21 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
class AppointmentWorkSyncServiceTest {
@Test void onePhysicalBatchHasIndependentCustomerDeliveriesAndOneVehicleWithAllCustomerEdges() {
var f=new WorkTestFixture();String batch="FBA_APPOINTMENT:1";
var requirements=List.of("A","B").stream().map(customer->new Requirement(AppointmentWorkPolicy.customerRequirement(batch,customer),batch,"AP-1","FBA","海外仓","ONT8",null,List.of(),List.of(),List.of(new SourceReference(customer,"ORDER:"+customer,"FBA:ONT8")))).toList();
var source=new ContainerSource("1","CNT",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,requirements);
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(source),null,true,"scope"));
f.sync.synchronize(new SyncRequest(null,10));
var deliveries=f.store.all().stream().filter(s->s.getType()==Type.DELIVERY && !s.isRetired()).toList();
var vehicles=f.store.all().stream().filter(s->s.getType()==Type.TRANSPORT && !s.isRetired()).toList();
assertEquals(2,deliveries.size());assertEquals(1,vehicles.size());
var vehicle=vehicles.get(0);assertTrue(vehicle.getPrerequisites().containsAll(deliveries.stream().map(AppointmentWorkState::getWorkItemKey).toList()));
assertEquals(2,vehicle.getSources().size());assertTrue(deliveries.stream().allMatch(s->batch.equals(s.getBatchKey()) && s.getSources().size()==1 && s.getPrerequisites().contains(vehicle.getWorkItemKey())));
var keys=deliveries.stream().map(AppointmentWorkState::getWorkItemKey).sorted().toList();
f.sync.synchronize(new SyncRequest(null,10));
assertEquals(keys,f.store.all().stream().filter(s->s.getType()==Type.DELIVERY && !s.isRetired()).map(AppointmentWorkState::getWorkItemKey).sorted().toList());
}
@Test void removingPrivateCargoDetachesOldContainerAndRestoresDemandWithoutRetiringOtherCargo() {
var f=new WorkTestFixture();
var source1=new SourceReference("C1","ORDER:1","CUSTOMER:1");var source2=new SourceReference("C2","ORDER:2","CUSTOMER:2");
@@ -48,7 +63,7 @@ class AppointmentWorkSyncServiceTest {
@Test void cancellationReopensOriginalDemandAndRestoringTheSameAppointmentDoesNotDuplicateWork() {
var f=new WorkTestFixture();
var root=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of("FBA_APPOINTMENT:1"),List.of());
var batch=new Requirement("FBA_APPOINTMENT:1","FBA_APPOINTMENT:1","AP-1","FBA",null,"ONT8",null,List.of(),List.of("INBOUND:1"),List.of());
var batch=new Requirement("FBA_APPOINTMENT:1","FBA_APPOINTMENT:1","AP-1","FBA",null,"ONT8",null,List.of(),List.of("INBOUND:1"),List.of(new SourceReference("C","ORDER:1","FBA:ONT8")));
for(var requirement:List.of(root,batch,root,batch)) {
var source=new ContainerSource("1","CNT",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(requirement));
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(source),null,true,"scope"));
@@ -85,10 +100,10 @@ class AppointmentWorkSyncServiceTest {
var f=new WorkTestFixture(); var receipt=f.state("1",Type.WAREHOUSE_RECEIPT,Flow.TRANSFER,null); receipt.setTarget(slot("2026-10-15")); receipt.setObservedFingerprint("old"); receipt.setObservedImpactToken("same-impact"); receipt.setObservedArranged(true); f.add(receipt);
var delivery=f.state("1",Type.DELIVERY,Flow.TRANSFER,"r1"); delivery.setTarget(slot("2026-10-16")); delivery.setPrerequisites(List.of(receipt.getWorkItemKey())); f.add(delivery);
var unscheduled=f.state("1",Type.DELIVERY,Flow.TRANSFER,"r2"); unscheduled.setPrerequisites(List.of(receipt.getWorkItemKey())); f.add(unscheduled);
var first=new Fact(receipt.getWorkItemKey(),"first-confirm","same-impact",true,PlanStatus.CONFIRMED,ExecutionStatus.PENDING,slot("2026-10-15"),slot("2026-10-15"),null,"America/Los_Angeles",null,null,List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null);
var first=new Fact(receipt.getWorkItemKey(),"first-confirm","same-impact",true,PlanStatus.CONFIRMED,ExecutionStatus.PENDING,slot("2026-10-15"),slot("2026-10-15"),null,"America/Los_Angeles",null,null,List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null,null);
var facts=new HashMap<String,Fact>(); facts.put(receipt.getWorkItemKey(),first); facts.put(delivery.getWorkItemKey(),fact(delivery,null,null,ExecutionStatus.PENDING,null)); facts.put(unscheduled.getWorkItemKey(),fact(unscheduled,null,null,ExecutionStatus.PENDING,null));
f.sync.observe(f.store.all(),facts,ACTOR); assertFalse(f.store.get(delivery.getWorkItemKey()).isReviewRequired());
facts.put(receipt.getWorkItemKey(),new Fact(receipt.getWorkItemKey(),"rescheduled","new-impact",true,PlanStatus.CONFIRMED,ExecutionStatus.PENDING,slot("2026-10-18"),slot("2026-10-18"),null,"America/Los_Angeles",null,null,List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null));
facts.put(receipt.getWorkItemKey(),new Fact(receipt.getWorkItemKey(),"rescheduled","new-impact",true,PlanStatus.CONFIRMED,ExecutionStatus.PENDING,slot("2026-10-18"),slot("2026-10-18"),null,"America/Los_Angeles",null,null,List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null,null));
f.sync.observe(f.store.all(),facts,ACTOR);
assertTrue(f.store.get(delivery.getWorkItemKey()).isReviewRequired()); assertFalse(f.store.get(unscheduled.getWorkItemKey()).isReviewRequired());
int version=f.store.get(delivery.getWorkItemKey()).getReviewVersion(); f.sync.observe(f.store.all(),facts,ACTOR); assertEquals(version,f.store.get(delivery.getWorkItemKey()).getReviewVersion());
@@ -108,7 +123,7 @@ class AppointmentWorkSyncServiceTest {
var values=new HashMap<String,Fact>();
for(var state:call.<List<AppointmentWorkState>>getArgument(0)) if(state.getType()!=Type.ROUTE) {
if(state.getType()==Type.TRANSPORT) values.put(state.getWorkItemKey(),new Fact(state.getWorkItemKey(),"trip-v1","trip-impact-v1",true,PlanStatus.CONFIRMED,ExecutionStatus.PENDING,
slot("2026-10-16"),slot("2026-10-16"),null,"America/Los_Angeles","TR-1","trip-1",List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null));
slot("2026-10-16"),slot("2026-10-16"),null,"America/Los_Angeles","TR-1","trip-1",List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null,null));
else values.put(state.getWorkItemKey(),fact(state,null,null,ExecutionStatus.PENDING,null));
}
return values;
@@ -0,0 +1,175 @@
package com.baiyue.lastleg.appointmentwork;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
class AppointmentWorkV13WorkflowTest {
private record Shared(WorkTestFixture fixture,AppointmentWorkState a,AppointmentWorkState b,
AppointmentWorkState waiting,AppointmentWorkState trip) { }
private Fact planned(AppointmentWorkState state,String revision) {
return new Fact(state.getWorkItemKey(),"fact-"+revision,"impact-"+revision,true,PlanStatus.CONFIRMED,
ExecutionStatus.PENDING,slot("2026-10-17"),slot("2026-10-17"),null,"America/Los_Angeles",
null,state.getTripKey(),List.of(ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null,null);
}
private Shared shared() {
var f=new WorkTestFixture();
var a=f.state("1",Type.DELIVERY,Flow.TRANSFER,"customer-a");
var b=f.state("1",Type.DELIVERY,Flow.TRANSFER,"customer-b");
var waiting=f.state("1",Type.DELIVERY,Flow.TRANSFER,"not-arranged");
var trip=f.state("1",Type.TRANSPORT,Flow.TRANSFER,"shared-trip");trip.setTripKey("TRIP-1");
a.setSources(List.of(new SourceReference("A","ORDER:1","CODE:ONT8")));
b.setSources(List.of(new SourceReference("B","ORDER:2","CODE:ONT8")));
// Real graph after canonical shared-vehicle reconciliation: appointments and vehicle
// are mutually dependent, but sharing a vehicle does not change another appointment.
for(var delivery:List.of(a,b,waiting)) delivery.setPrerequisites(List.of(trip.getWorkItemKey()));
trip.setPrerequisites(List.of(a.getWorkItemKey(),b.getWorkItemKey(),waiting.getWorkItemKey()));
for(var state:List.of(a,b,trip)) { f.add(state);f.facts.put(state.getWorkItemKey(),planned(state,"1")); }
f.add(waiting);f.facts.put(waiting.getWorkItemKey(),fact(waiting,null,null,ExecutionStatus.PENDING,null));
f.sync.observe(f.store.all(),f.facts,ACTOR);
return new Shared(f,a,b,waiting,trip);
}
@Test void oneCustomerChangeReviewsItsVehicleWithoutReviewingOtherCustomersOrUnarrangedWork() {
var s=shared();var f=s.fixture();
f.facts.put(s.a().getWorkItemKey(),planned(s.a(),"2"));
f.sync.observe(f.store.all(),f.facts,ACTOR);
assertTrue(f.store.get(s.a().getWorkItemKey()).isReviewRequired());
assertTrue(f.store.get(s.trip().getWorkItemKey()).isReviewRequired());
assertFalse(f.store.get(s.b().getWorkItemKey()).isReviewRequired());
assertFalse(f.store.get(s.waiting().getWorkItemKey()).isReviewRequired());
assertEquals(List.of(s.trip().getWorkItemKey()),AppointmentWorkSyncService.impactPrerequisites(s.a(),f.store.all())
.stream().map(AppointmentWorkState::getWorkItemKey).toList());
var version=f.store.get(s.trip().getWorkItemKey()).getReviewVersion();
f.sync.observe(f.store.all(),f.facts,ACTOR);
assertEquals(version,f.store.get(s.trip().getWorkItemKey()).getReviewVersion());
}
@Test void realVehicleChangeStillReviewsEveryCurrentlyArrangedMember() {
var s=shared();var f=s.fixture();
f.facts.put(s.trip().getWorkItemKey(),planned(s.trip(),"2"));
f.sync.observe(f.store.all(),f.facts,ACTOR);
for(var state:List.of(s.a(),s.b(),s.trip())) assertTrue(f.store.get(state.getWorkItemKey()).isReviewRequired());
assertFalse(f.store.get(s.waiting().getWorkItemKey()).isReviewRequired());
}
private Fact vehicleImpact(AppointmentWorkState state,String own,String shared) {
return new Fact(state.getWorkItemKey(),"fact-"+own,"impact-"+own,true,PlanStatus.CONFIRMED,
ExecutionStatus.PENDING,slot("2026-10-17"),slot("2026-10-17"),null,"America/Los_Angeles",
null,state.getTripKey(),List.of(ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null,shared);
}
@Test void simultaneousCustomerAndVehicleMembershipChangeDoesNotReviewUnchangedCustomers() throws Exception {
var s=shared();var f=s.fixture();
f.facts.put(s.trip().getWorkItemKey(),vehicleImpact(s.trip(),"1","plan-1"));
f.sync.observe(f.store.all(),f.facts,ACTOR);
f.facts.put(s.a().getWorkItemKey(),planned(s.a(),"2"));
f.facts.put(s.trip().getWorkItemKey(),vehicleImpact(s.trip(),"2","plan-1"));
var unchanged=f.queries.detail(s.b().getWorkItemKey(),ACTOR).item();
assertFalse(unchanged.reviewRequired());assertTrue(unchanged.blockReasons().isEmpty());
f.sync.observe(f.store.all(),f.facts,ACTOR);
assertTrue(f.store.get(s.a().getWorkItemKey()).isReviewRequired());
assertTrue(f.store.get(s.trip().getWorkItemKey()).isReviewRequired());
assertFalse(f.store.get(s.b().getWorkItemKey()).isReviewRequired());
// A later cargo-only change must not invalidate B's review of a different impact.
var b=f.store.get(s.b().getWorkItemKey());b.setReviewRequired(true);b.setReviewVersion(1);f.store.save(b);
f.facts.put(s.trip().getWorkItemKey(),vehicleImpact(s.trip(),"3","plan-1"));
assertTrue(f.queries.detail(b.getWorkItemKey(),ACTOR).item().allowedActions().stream().anyMatch(a->a.code()==ActionCode.RESOLVE_REVIEW));
var review=f.command(Command.RESOLVE_REVIEW,b.getVersion(),"review-b","{\"reviewVersion\":1,\"note\":\"本客户时段不变\"}");
assertFalse(f.commands.execute(b.getWorkItemKey(),review,ACTOR).item().reviewRequired());
f.sync.observe(f.store.all(),f.facts,ACTOR);
assertFalse(f.store.get(b.getWorkItemKey()).isReviewRequired());
f.facts.put(s.trip().getWorkItemKey(),vehicleImpact(s.trip(),"4","plan-2"));
f.sync.observe(f.store.all(),f.facts,ACTOR);
assertTrue(f.store.get(b.getWorkItemKey()).isReviewRequired());
}
@Test void retiredTransportAliasKeepsTheSameCustomerImpactBoundary() {
var s=shared();var f=s.fixture();
var alias=f.state("1",Type.TRANSPORT,Flow.TRANSFER,"old-a-transport");
alias.setRetired(true);alias.setRetirementReason("TRIP_LINKED");alias.setReplacementKeys(List.of(s.trip().getWorkItemKey()));f.add(alias);
var a=f.store.get(s.a().getWorkItemKey());a.setPrerequisites(List.of(alias.getWorkItemKey()));f.store.save(a);
var affected=new LinkedHashSet<String>();AppointmentWorkSyncService.expandDependents(a.getWorkItemKey(),f.store.all(),affected);
assertTrue(affected.contains(s.trip().getWorkItemKey()));assertFalse(affected.contains(s.b().getWorkItemKey()));
assertEquals(List.of(s.trip().getWorkItemKey()),AppointmentWorkSyncService.impactPrerequisites(a,f.store.all())
.stream().map(AppointmentWorkState::getWorkItemKey).toList());
}
@Test void unsynchronizedNewUpstreamImpactCannotBeClearedByAnOldReviewAndReplayCannotClearLaterChanges() throws Exception {
var f=new WorkTestFixture();
var receipt=f.state("1",Type.WAREHOUSE_RECEIPT,Flow.TRANSFER,null);f.add(receipt);
var delivery=f.state("1",Type.DELIVERY,Flow.TRANSFER,"batch-a");delivery.setPrerequisites(List.of(receipt.getWorkItemKey()));f.add(delivery);
f.facts.put(receipt.getWorkItemKey(),planned(receipt,"1"));f.facts.put(delivery.getWorkItemKey(),planned(delivery,"1"));
f.sync.observe(f.store.all(),f.facts,ACTOR);
f.facts.put(receipt.getWorkItemKey(),planned(receipt,"2"));f.sync.observe(f.store.all(),f.facts,ACTOR);
var reviewing=f.store.get(delivery.getWorkItemKey());
var old=f.command(Command.RESOLVE_REVIEW,reviewing.getVersion(),"old-review","{\"reviewVersion\":1,\"note\":\"核对旧影响\"}");
f.facts.put(receipt.getWorkItemKey(),planned(receipt,"3"));
assertEquals(409,assertThrows(ResponseStatusException.class,()->f.commands.execute(delivery.getWorkItemKey(),old,ACTOR)).getStatusCode().value());
assertTrue(f.store.get(delivery.getWorkItemKey()).isReviewRequired());
f.sync.observe(f.store.all(),f.facts,ACTOR);
var current=f.store.get(delivery.getWorkItemKey());assertEquals(2,current.getReviewVersion());
var staleImpact=f.command(Command.RESOLVE_REVIEW,current.getVersion(),"old-impact","{\"reviewVersion\":1,\"note\":\"旧版本\"}");
assertEquals(409,assertThrows(ResponseStatusException.class,()->f.commands.execute(delivery.getWorkItemKey(),staleImpact,ACTOR)).getStatusCode().value());
var accepted=f.command(Command.RESOLVE_REVIEW,current.getVersion(),"current-review","{\"reviewVersion\":2,\"note\":\"核对最新安排\"}");
var response=f.commands.execute(delivery.getWorkItemKey(),accepted,ACTOR);assertFalse(response.item().reviewRequired());
f.sync.observe(f.store.all(),f.facts,ACTOR);assertFalse(f.store.get(delivery.getWorkItemKey()).isReviewRequired());
f.facts.put(receipt.getWorkItemKey(),planned(receipt,"4"));f.sync.observe(f.store.all(),f.facts,ACTOR);
assertEquals(response,f.commands.execute(delivery.getWorkItemKey(),accepted,ACTOR));
assertTrue(f.store.get(delivery.getWorkItemKey()).isReviewRequired());
assertEquals(3,f.store.get(delivery.getWorkItemKey()).getReviewVersion());
}
@Test void outstandingRequirementChangesPreventReviewUntilRelationshipsAreSynchronized() throws Exception {
var s=shared();var f=s.fixture();
var state=f.store.get(s.a().getWorkItemKey());state.setReviewRequired(true);state.setReviewVersion(1);f.store.save(state);
f.guard.setRequirementVersion(2);f.guard.setSynchronizedRequirementVersion(1);
var command=f.command(Command.RESOLVE_REVIEW,state.getVersion(),"dirty-scope","{\"reviewVersion\":1,\"note\":\"不能略过关系变化\"}");
assertEquals(409,assertThrows(ResponseStatusException.class,()->f.commands.execute(state.getWorkItemKey(),command,ACTOR)).getStatusCode().value());
assertTrue(f.store.get(state.getWorkItemKey()).isReviewRequired());
}
@Test void reminderDateIsNotWorkDateAndAnUnsetReminderRemainsAnExplicitMissingField() {
var f=new WorkTestFixture();var delivery=f.state("1",Type.DELIVERY,Flow.TRANSFER,"later-batch");
delivery.setFollowupDate(LocalDate.of(2026,10,16));f.add(delivery);
var item=f.queries.items(View.UNDATED,1,8,null,null,null,null,null,null,ACTOR).records().get(0);
assertNull(item.calendarDate());assertEquals(LocalDate.of(2026,10,16),item.followupDate());
assertEquals(1,f.queries.summary(ACTOR).all());assertEquals(1,f.queries.summary(ACTOR).undated());
delivery=f.store.get(delivery.getWorkItemKey());delivery.setFollowupDate(null);f.store.save(delivery);
assertTrue(f.queries.detail(delivery.getWorkItemKey(),ACTOR).item().missingFields().contains("下次跟进日期待安排"));
assertEquals(1,f.queries.summary(ACTOR).all());
}
private Fact actionable(AppointmentWorkState state,boolean confirmed,Action action,List<String> permissions) {
return new Fact(state.getWorkItemKey(),"fact-1","impact-1",true,confirmed?PlanStatus.CONFIRMED:PlanStatus.UNPLANNED,
ExecutionStatus.PENDING,null,confirmed?slot("2026-10-17"):null,null,"America/Los_Angeles",null,state.getTripKey(),
List.of(ORDER_READ),permissions,action,List.of(),List.of(),List.of(),null,null);
}
@Test void confirmedAppointmentGuidesExistingVehicleWhileUnconfirmedAppointmentKeepsBookingStep() {
var f=new WorkTestFixture();var delivery=f.state("1",Type.DELIVERY,Flow.TRANSFER,"batch-a");
var transport=f.state("1",Type.TRANSPORT,Flow.TRANSFER,"batch-a");
delivery.setPrerequisites(List.of(transport.getWorkItemKey()));f.add(delivery);f.add(transport);
var booking=new Action(ActionCode.CHANGE_BOOKING,"维护预约",delivery.getWorkItemKey(),new AppointmentTarget("1","batch-a",List.of()));
var vehicle=new Action(ActionCode.ARRANGE_TRANSPORT,"维护车辆",transport.getWorkItemKey(),new TripTarget(null,List.of("batch-a")));
f.facts.put(delivery.getWorkItemKey(),actionable(delivery,true,booking,List.of(MANAGE)));
f.facts.put(transport.getWorkItemKey(),actionable(transport,false,vehicle,List.of(MANAGE)));
var next=f.queries.detail(delivery.getWorkItemKey(),ACTOR).item().nextAction();
assertEquals(ActionCode.ARRANGE_TRANSPORT,next.code());assertEquals("继续安排本批车辆",next.label());
assertEquals(transport.getWorkItemKey(),next.targetWorkItemKey());assertEquals(vehicle.target(),next.target());
var existing=new Action(ActionCode.ARRANGE_TRANSPORT,"维护车辆",transport.getWorkItemKey(),new TripTarget("TRIP-1",List.of("batch-a")));
f.facts.put(transport.getWorkItemKey(),actionable(transport,true,existing,List.of(MANAGE)));
assertEquals("核对本车发车安排",f.queries.detail(delivery.getWorkItemKey(),ACTOR).item().nextAction().label());
var rebook=new Action(ActionCode.RECORD_CONFIRMATION,"重新约仓",delivery.getWorkItemKey(),booking.target());
f.facts.put(delivery.getWorkItemKey(),actionable(delivery,false,rebook,List.of(MANAGE)));
assertEquals(rebook,f.queries.detail(delivery.getWorkItemKey(),ACTOR).item().nextAction());
f.facts.put(delivery.getWorkItemKey(),actionable(delivery,true,booking,List.of(MANAGE)));
var reader=new Actor("read","只读",Set.of(LIST,ORDER_READ));
assertEquals(ActionCode.VIEW_DETAIL,f.queries.detail(delivery.getWorkItemKey(),reader).item().nextAction().code());
}
}
@@ -33,6 +33,7 @@ final class WorkTestFixture {
when(mapper.update(any())).thenAnswer(c->{ var row=c.getArgument(0,AppointmentWorkMapper.Row.class); var old=rows.get(row.getWorkItemKey()); if(old==null || old.getVersion()!=row.getVersion()-1) return 0; rows.put(row.getWorkItemKey(),row); return 1; });
when(mapper.insertHistory(any(AppointmentWorkMapper.History.class))).thenAnswer(c->{history.add(c.getArgument(0)); return 1;});
when(mapper.selectHistory(anyString())).thenAnswer(c->history.stream().filter(h->h.getWorkItemKey().equals(c.getArgument(0))).toList());
when(mapper.inheritedFollowup(anyString())).thenAnswer(c->history.stream().filter(h->h.getWorkItemKey().equals(c.getArgument(0)) && "FOLLOWUP_INHERITED".equals(h.getAction())).reduce((a,b)->b).orElse(null));
when(mapper.command(anyString(),anyString())).thenAnswer(c->history.stream().filter(h->h.getWorkItemKey().equals(c.getArgument(0)) && Objects.equals(h.getCommandKey(),c.getArgument(1))).findFirst().orElse(null));
when(gateway.facts(anyList())).thenAnswer(c->{ var result=new LinkedHashMap<String,Fact>(); for(var state:c.<List<AppointmentWorkState>>getArgument(0)) if(state.getType()!=Type.ROUTE) result.put(state.getWorkItemKey(),facts.getOrDefault(state.getWorkItemKey(),fact(state,null,null,ExecutionStatus.PENDING,null))); return result; });
when(gateway.discover(isNull(),anyInt())).thenReturn(new SourcePage(List.of(),null,true,"scope"));
@@ -49,6 +50,6 @@ final class WorkTestFixture {
static Slot slot(String date) { return new Slot(LocalDate.parse(date),null,null,null,"America/Los_Angeles"); }
static Fact fact(AppointmentWorkState state,Slot target,Slot confirmed,ExecutionStatus execution,Actual actual) {
return new Fact(state.getWorkItemKey(),"fact-v1","impact-v1",target!=null || confirmed!=null,confirmed!=null?PlanStatus.CONFIRMED:target!=null?PlanStatus.TARGET_SET:PlanStatus.UNPLANNED,
execution,target,confirmed,actual,"America/Los_Angeles",null,null,List.of(ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null);
execution,target,confirmed,actual,"America/Los_Angeles",null,null,List.of(ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null,null);
}
}
@@ -7,6 +7,19 @@ import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.*;
class AppointmentWorkSchemaContractTest {
@Test void receivingCoverageSchemaMatchesBothInitializationEntriesWithoutLegacyHeaderUniqueness() throws Exception {
String source=Files.readString(Path.of("src/main/resources/db/appointment-receiving.sql"));
for(String path:List.of("src/main/resources/db/lastleg.sql","../deploy/sql/海外区域统一增量-MySQL8.sql")) {
String target=Files.readString(Path.of(path));
for(String table:List.of("ll_fba_receiving_agreement","ll_fba_receiving_member","ll_fba_receiving_change","ll_fba_receiving_revision","ll_fba_receiving_plan"))
assertEquals(definition(source,table),definition(target,table),path+" differs for "+table);
assertFalse(target.contains("uk_ll_fba_external_confirmation"),"An aggregate header cannot own independently confirmed customer appointments");
}
String reset=Files.readString(Path.of("../deploy/sql/reset-test-fba-appointments.sql"));
int guardEnd=reset.indexOf("CALL assert_test_appointment_reset_preconditions()");
for(String table:List.of("ll_fba_receiving_agreement","ll_fba_receiving_member","ll_fba_receiving_change","ll_fba_receiving_revision","ll_fba_receiving_plan"))
assertTrue(reset.substring(0,guardEnd).contains("'"+table+"'"),"Old reset must refuse receiving history: "+table);
}
@Test void completeWorkAndPhysicalAllocationSchemaMatchesBothInitializationEntries() throws Exception {
String source=Files.readString(Path.of("src/main/resources/db/appointment-work.sql"));
for(String path:List.of("src/main/resources/db/lastleg.sql","../deploy/sql/海外区域统一增量-MySQL8.sql")) {
@@ -55,7 +55,7 @@ class DispatchBolPrintServiceTest {
null, null, null, LocalDateTime.of(2026, 9, 12, 2, 40, 20), "PENDING_DEPARTURE",
1, 1, 1, 0, 0, 1, null, LocalDateTime.of(2026, 9, 11, 10, 0), List.of(),
List.of(new DispatchResponse.BolLine("TSTU3539519", "DP-260911-0027", "ABE2", "ABE2",
"NYEXX2609110214", null, null, 1)), null);
"NYEXX2609110214", null, null, 1)), null, List.of());
var address = new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "California", "Rancho Cucamonga",
null, "91730", "10767 7th Street", null);
var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L,
@@ -87,7 +87,7 @@ class DispatchBolPrintServiceTest {
new DispatchResponse.BolLine("TSTU3539519", "DP-001", "ABE2", "ABE2 Warehouse",
"NYEXX2609110214", null, null, 1),
new DispatchResponse.BolLine("TSTU3539519", "DP-002", "ONT8", "ONT8 Warehouse",
"NYEXX2609110215", null, null, 1)), null);
"NYEXX2609110215", null, null, 1)), null, List.of());
var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L,
"US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE", null, null);
when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail);
@@ -51,6 +51,18 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DispatchServiceTest {
@Test void detailReturnsEveryOriginalBatchSourceWithoutLegacyAppointmentNumber() {
var dispatch=new DispatchOrder();dispatch.setId(1L);dispatch.setCustomerId("C");dispatch.setDispatchNo("DP-1");
when(orders.findGlobalByDispatchNo("DP-1")).thenReturn(dispatch);
var allocations=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class);ReflectionTestUtils.setField(service,"appointmentAllocations",allocations);
var references=List.of(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference("FBA_APPOINTMENT:1","AP-1","C",
List.of(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchSource("ORDER:10","FBA:ONT8",40))),
new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference("FBA_APPOINTMENT:2","AP-2","C",
List.of(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchSource("ORDER:11","FBA:ONT8",20))));
when(allocations.references(List.of(1L))).thenReturn(java.util.Map.of(1L,references));
var result=service.platformDetail("DP-1");assertNull(result.fbaAppointmentNo());assertEquals(references,result.appointmentBatches());
verify(allocations).references(List.of(1L));
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.NullAndEmptySource
@org.junit.jupiter.params.provider.ValueSource(strings = {"PICKUP", "EXPRESS", "AMAZON_PICKUP", "CUSTOMER_PICKUP", "UNKNOWN"})
@@ -17,6 +17,12 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetTransitEvidenceTest {
@Test void removingOriginalPhysicalItemDelegatesToDispatchAuthority() {
var service=mock(com.baiyue.lastleg.dispatch.service.DispatchService.class);
org.springframework.test.util.ReflectionTestUtils.setField(adapter,"dispatchService",service);
adapter.removeItem("PC-1",41L);
verify(service).removeItem("PC-1",41L);verifyNoMoreInteractions(service);
}
@BeforeAll static void initializeDeliveryMetadata() {
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
new org.apache.ibatis.builder.MapperBuilderAssistant(new com.baomidou.mybatisplus.core.MybatisConfiguration(), "fleet-transit-evidence"),
@@ -72,11 +78,20 @@ class FleetTransitEvidenceTest {
@Test void unsignedReturnRequiresImmutableReturnOperation() {
delivery.setStatus("RETURNED"); delivery.setUpdatedAt(LocalDateTime.now());
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false));
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.terminalEvidence("PC-1", false));
var returned = LocalDateTime.of(2026, 10, 3, 10, 0);
when(operations.returnedAt("CUSTOMER-A", 41L)).thenReturn(returned);
when(operations.returnReceipts("CUSTOMER-A", 41L)).thenReturn(List.of(new com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact(1L, 51L, 61L, 10, returned)));
assertEquals(returned.atZone(ZoneId.systemDefault()).toInstant(),
adapter.transitEvidence("PC-1", false).deliveries().get(0).cargo().terminalAt());
var terminal=adapter.terminalEvidence("PC-1",false);
assertEquals("CUSTOMER-A",terminal.customerId());assertEquals(10,terminal.shippedPackages());
var returnedDelivery=terminal.deliveries().get(0);
assertEquals("FBA:ONT8",returnedDelivery.destinationKey());assertEquals(10,returnedDelivery.packages());
assertEquals(returned.atZone(ZoneId.systemDefault()).toInstant(),returnedDelivery.terminalAt());
assertEquals("RETURNED",returnedDelivery.status());assertEquals(0L,returnedDelivery.recordVersion());
assertEquals(List.of(new com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.ReturnedReceipt("51","61",10,
returned.atZone(ZoneId.systemDefault()).toInstant())),returnedDelivery.receipts());
}
@Test void crossCustomerAndUnbalancedFactsFailClosed() {
@@ -54,6 +54,40 @@ import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class FleetTripPersistenceTest {
@Test void originalBatchReferencesSurviveRemovalReentryAndLaterDispatchReassignment() throws Exception {
String number=assignedTrip();dispatch(11,"CLIENT-A","92");dispatch(12,"CLIENT-B","92");
var allocations=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class);
ReflectionTestUtils.setField(service,"appointmentAllocations",allocations);
var a=List.of(batchReference("1","CLIENT-A",6),batchReference("2","CLIENT-A",4));
var originalB=List.of(batchReference("3","CLIENT-B",10));
var current=new java.util.HashMap<Long,List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference>>();current.put(11L,a);current.put(12L,originalB);
when(allocations.references(anyList())).thenAnswer(call->{List<Long> ids=call.getArgument(0);return ids.stream().collect(java.util.stream.Collectors.toMap(id->id,id->current.getOrDefault(id,List.of())));});
service.bindDispatch(number,"1",new FleetTripModels.BindingCommand(1L,"DP-11","加入甲"));
var both=service.bindDispatch(number,"1",new FleetTripModels.BindingCommand(2L,"DP-12","加入乙"));
assertThat(both.dispatches().get(0).appointmentBatches()).containsExactlyElementsOf(a);assertThat(both.previousDispatches()).isEmpty();
service.unbindDispatch(number,"1",new FleetTripModels.BindingCommand(3L,"DP-12","乙后送"));
current.put(12L,List.of(batchReference("4","CLIENT-B",4),batchReference("5","CLIENT-B",6)));
var reopened=service.detail(number);
assertThat(reopened.dispatches()).extracting(FleetTripModels.DispatchBinding::customerId).containsExactly("CLIENT-A");
assertThat(reopened.previousDispatches()).hasSize(1);
var previous=reopened.previousDispatches().get(0);assertThat(previous.dispatchNo()).isEqualTo("DP-12");assertThat(previous.customerId()).isEqualTo("CLIENT-B");
assertThat(previous.recordVersion()).isEqualTo(3);assertThat(previous.recordedAt()).isNotNull();assertThat(previous.appointmentBatches()).isEqualTo(originalB);
var rebound=service.bindDispatch(number,"1",new FleetTripModels.BindingCommand(4L,"DP-12","按当前后送批重入"));
assertThat(rebound.dispatches().get(1).appointmentBatches()).isEqualTo(current.get(12L));
assertThat(rebound.previousDispatches()).hasSize(1);assertThat(rebound.previousDispatches().get(0).appointmentBatches()).isEqualTo(originalB);
var audit=service.history(number,1,20).records().stream().filter(h->h.recordVersion()==3).findFirst().orElseThrow();
assertThat(audit.snapshot().dispatches().get(1).appointmentBatches()).isEqualTo(originalB);
assertThat(rebound.appointmentHistoryUnavailableReason()).isNull();
var json=new ObjectMapper().findAndRegisterModules();
var incomplete=json.valueToTree(audit.snapshot());((com.fasterxml.jackson.databind.node.ObjectNode)incomplete.path("dispatches").get(1)).remove("appointmentBatches");
jdbc.update("UPDATE ll_fleet_trip_history SET snapshot_json=? WHERE trip_id=(SELECT id FROM ll_fleet_trip WHERE trip_no=?) AND record_version=3",json.writeValueAsString(incomplete),number);
var readable=service.detail(number);assertThat(readable.dispatches()).hasSize(2);assertThat(readable.dispatches().get(1).appointmentBatches()).isEqualTo(current.get(12L));
assertThat(readable.previousDispatches()).isEmpty();assertThat(readable.appointmentHistoryUnavailableReason()).contains("追踪不完整");
}
private static com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference batchReference(String id,String customer,int packages) {
return new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference("FBA_APPOINTMENT:"+id,"AP-"+id,customer,
List.of(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchSource("ORDER:"+customer,"FBA:ONT8",packages)));
}
private JdbcTemplate jdbc;
private FleetTripService service;
private FleetTripPreparationService preparation;
@@ -11,6 +11,15 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class AppointmentWorkTransportProviderTest {
@Test void linkedVehicleCarriesIndependentCargoAndCommonPlanVersions() {
var mapper=mock(AppointmentWorkTripMapper.class);var commitments=mock(AppointmentWorkCommitmentService.class);
var trip=new com.baiyue.lastleg.fleet.trip.FleetTripEntity();trip.setId(10L);trip.setTripNo("TR-10");
when(mapper.trip(10)).thenReturn(trip);when(commitments.impactToken("TRIP:10")).thenReturn("TRIP:10:2");
when(commitments.impactToken("TRIP_PLAN:10")).thenReturn("TRIP_PLAN:10:0");
var request=new FactRequest("W",Type.TRANSPORT,Flow.TRANSFER,"C",null,null,"10",List.of(),List.of(),List.of());
var fact=new AppointmentWorkTransportProvider(mapper,mock(FleetWarehouseClient.class),commitments,"token").read(List.of(request)).get(0);
assertEquals("TRIP:10:2",fact.impactToken());assertEquals("TRIP_PLAN:10:0",fact.dependentImpactToken());
}
@Test void unassignedTransportWorkDoesNotQueryTripsOrWarehousesPerSource() {
var mapper=mock(AppointmentWorkTripMapper.class);var warehouses=mock(FleetWarehouseClient.class);var commitments=mock(AppointmentWorkCommitmentService.class);
var requests=IntStream.range(0,200).mapToObj(i->new FactRequest("W"+i,Type.TRANSPORT,Flow.TRANSFER,"C"+i,"INBOUND:"+i,null,null,List.of(),List.of(),List.of())).toList();
@@ -0,0 +1,44 @@
package com.baiyue.lastleg.fleet.trip.work;
import com.baiyue.lastleg.appointmentwork.*;
import com.baiyue.lastleg.fleet.trip.FleetTripEntity;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class AppointmentWorkTripChangesTest {
@Test void membershipAndRouteChangesReviewVehicleWhileOnlyCommonPlanChangesAffectOtherAppointments() {
var rows=new HashMap<String,AppointmentWorkMapper.CommitmentRow>();
var persistence=mock(AppointmentWorkMapper.class);
when(persistence.lockCommitment(anyString())).thenAnswer(c->rows.get(c.getArgument(0)));
when(persistence.commitment(anyString())).thenAnswer(c->rows.get(c.getArgument(0)));
when(persistence.insertCommitment(anyString(),anyString())).thenAnswer(c->{
var row=new AppointmentWorkMapper.CommitmentRow();row.setEntityKey(c.getArgument(0));row.setStateJson(c.getArgument(1));
rows.put(row.getEntityKey(),row);return 1;
});
when(persistence.updateCommitment(anyString(),anyString(),anyLong())).thenAnswer(c->{
var row=rows.get(c.getArgument(0));row.setStateJson(c.getArgument(1));row.setImpactVersion(c.getArgument(2));return 1;
});
var commitments=new AppointmentWorkCommitmentService(persistence,new ObjectMapper().findAndRegisterModules());
var mapper=mock(AppointmentWorkTripMapper.class);
var changes=new AppointmentWorkTripChanges(mock(AppointmentWorkChanges.class),commitments,mapper);
var trip=new FleetTripEntity();trip.setId(10L);trip.setVehicleId(20L);trip.setDriverId(30L);trip.setStatus("PLANNED");
trip.setStopsJson("[A,B,C]");trip.setPlannedDepartureAt(LocalDateTime.parse("2026-10-17T10:00:00"));
trip.setExpectedDriverEndAt(LocalDateTime.parse("2026-10-17T20:00:00"));
when(mapper.batches(10)).thenReturn(List.of("A","B","C"));changes.changed(trip);
assertEquals("TRIP:10:0",commitments.impactToken("TRIP:10"));assertEquals("TRIP_PLAN:10:0",commitments.impactToken("TRIP_PLAN:10"));
when(mapper.batches(10)).thenReturn(List.of("C","B","A"));changes.changed(trip);
assertEquals("TRIP:10:0",commitments.impactToken("TRIP:10"));
when(mapper.batches(10)).thenReturn(List.of("A","C"));trip.setStopsJson("[A,C]");changes.changed(trip);
assertEquals("TRIP:10:1",commitments.impactToken("TRIP:10"));assertEquals("TRIP_PLAN:10:0",commitments.impactToken("TRIP_PLAN:10"));
changes.changed(trip);assertEquals("TRIP:10:1",commitments.impactToken("TRIP:10"));
trip.setVehicleId(21L);changes.changed(trip);assertEquals("TRIP_PLAN:10:1",commitments.impactToken("TRIP_PLAN:10"));
trip.setDriverId(31L);changes.changed(trip);assertEquals("TRIP_PLAN:10:2",commitments.impactToken("TRIP_PLAN:10"));
trip.setPlannedDepartureAt(trip.getPlannedDepartureAt().plusHours(1));changes.changed(trip);
assertEquals("TRIP_PLAN:10:3",commitments.impactToken("TRIP_PLAN:10"));
trip.setStatus("CANCELLED");changes.changed(trip);assertEquals("TRIP_PLAN:10:4",commitments.impactToken("TRIP_PLAN:10"));
}
}
@@ -0,0 +1,69 @@
package com.baiyue.lastleg.inboundplan.receiving;
import com.baiyue.common.security.AuthenticatedUser;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.*;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*;
class FbaReceivingAuthorizationTest {
static final String EDIT="platform:overseas-warehouse:inbound-order:fba-appointment", DELETE="platform:appointment:delete", READ="platform:appointment:list";
@Configuration @EnableMethodSecurity static class Config {
@Bean FbaReceivingService service(){return mock(FbaReceivingService.class);}
@Bean FbaReceivingController controller(FbaReceivingService service){return new FbaReceivingController(service);}
}
@Test void listOnlyCanReadButCannotChangeOrCancel() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var user=login(0L,"PLATFORM",READ);var controller=context.getBean(FbaReceivingController.class);var service=context.getBean(FbaReceivingService.class);
controller.context(user,"FBA_APPOINTMENT:1");verify(service).context("FBA_APPOINTMENT:1");
var query=new ExecutionQuery(List.of(new FbaAppointmentExecutionPort.Member("FBA_APPOINTMENT:1","A")));
controller.executionScope(user,query);verify(service).executionScope(query);
assertThrows(AccessDeniedException.class,()->controller.book(user,null));
assertThrows(AccessDeniedException.class,()->controller.plan(user,null));
assertThrows(AccessDeniedException.class,()->controller.refreshCargo(user,null));
assertThrows(AccessDeniedException.class,()->controller.cancel(user,"RA-1",null));
assertThrows(AccessDeniedException.class,()->controller.propose(user,null));
} finally {SecurityContextHolder.clearContext();}
}
@Test void editingCannotCancelThroughRescheduleOrRemovalAndDeleteDoesNotGrantEditing() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var controller=context.getBean(FbaReceivingController.class);var service=context.getBean(FbaReceivingService.class);
var user=login(0L,"PLATFORM",EDIT);
assertThrows(AccessDeniedException.class,()->controller.cancel(user,"RA-1",null));
assertThrows(AccessDeniedException.class,()->controller.resolve(user,"CH-1",null));
assertEquals(400,assertThrows(ResponseStatusException.class,()->controller.reschedule(user,"RA-1",new GroupChange("key",0,"CANCEL",null,"仓库取消","t"))).getStatusCode().value());
var deleteUser=login(0L,"PLATFORM",DELETE);
assertThrows(AccessDeniedException.class,()->controller.plan(deleteUser,null));
assertThrows(AccessDeniedException.class,()->controller.refreshCargo(deleteUser,null));
assertThrows(AccessDeniedException.class,()->controller.reschedule(deleteUser,"RA-1",null));
assertThrows(AccessDeniedException.class,()->controller.propose(deleteUser,null));verifyNoInteractions(service);
} finally {SecurityContextHolder.clearContext();}
}
@Test void correctAuthoritiesStillRejectClientIdentityAndPermitExplicitScopeCommands() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var controller=context.getBean(FbaReceivingController.class);var service=context.getBean(FbaReceivingService.class);
var client=login(7L,"CLIENT",READ,EDIT,DELETE);
assertEquals(403,assertThrows(ResponseStatusException.class,()->controller.context(client,"FBA_APPOINTMENT:1")).getStatusCode().value());
assertEquals(403,assertThrows(ResponseStatusException.class,()->controller.plan(client,null)).getStatusCode().value());
assertEquals(403,assertThrows(ResponseStatusException.class,()->controller.refreshCargo(client,null)).getStatusCode().value());
assertEquals(403,assertThrows(ResponseStatusException.class,()->controller.executionScope(client,null)).getStatusCode().value());
var user=login(0L,"PLATFORM",EDIT,DELETE);var request=new Resolve("key",0,"CONFIRM","明确回复","t");
controller.resolve(user,"CH-1",request);verify(service).resolve("1","CH-1",request);
var editUser=login(0L,"PLATFORM",EDIT);
controller.plan(editUser,null);verify(service).plan("1",null);
controller.refreshCargo(editUser,null);verify(service).refreshCargo("1",null);
} finally {SecurityContextHolder.clearContext();}
}
static AuthenticatedUser login(long customer,String app,String...permissions) {
var user=new AuthenticatedUser(1L,customer,0L,1L,"operator",app,"WEB",Set.of(permissions));
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",Arrays.stream(permissions).map(SimpleGrantedAuthority::new).toList()));return user;
}
}
@@ -60,8 +60,8 @@ class AppointmentCargoVerificationPersistenceTest {
appointments.delete("operator",planned.appointmentNo(),new CancelFbaAppointmentRequest(1,"对方取消"));
var db=new JdbcTemplate(context.getBean(DataSource.class));
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_appointment_cargo_verification",Integer.class));
assertEquals(0,context.getBean(AppointmentCargoVerificationMapper.class).allocated("A","ORDER:1").packages());
cargo.save("ORDER:1","A",save(2,7,"after-cancel","FBA-RECHECKED"),"operator","客服");
assertEquals(8,context.getBean(AppointmentCargoVerificationMapper.class).allocated("A","ORDER:1").packages());
assertThrows(ResponseStatusException.class,()->cargo.save("ORDER:1","A",save(2,7,"after-cancel","FBA-RECHECKED"),"operator","客服"));
}
}
@Test void incompleteVerificationCanBeSavedButCannotConfirmAndChangedSourceMustBeReverified() {
@@ -0,0 +1,78 @@
package com.baiyue.lastleg.inboundplan.service;
import com.baiyue.lastleg.appointmentplanning.*;
import com.baiyue.lastleg.appointmentwork.*;
import com.baiyue.lastleg.inboundplan.dto.*;
import com.baiyue.lastleg.inboundplan.receiving.*;
import com.baiyue.lastleg.inboundplan.work.*;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.web.server.ResponseStatusException;
import javax.sql.DataSource;
import java.time.LocalDate;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*;
class AppointmentWorkPreArrangementPersistenceTest {
@Test void siblingPlanChangeCannotGrantOrRemovePhysicalPreArrangementAndNeverAuthorizesDeparture() {
try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) {
var sources=context.getBean(AppointmentSourceService.class);
var cargo=List.of(source("B",1,60),source("D",2,40));
when(sources.resolveBatch(anyList())).thenAnswer(call->{List<AppointmentCoverageService.SourceRef> refs=call.getArgument(0);return refs.stream().map(ref->cargo.stream().filter(s->s.customerId().equals(ref.customerId()) && s.sourceKey().equals(ref.sourceKey()) && s.destinationKey().equals(ref.destinationKey())).findFirst().orElseThrow()).toList();});
when(sources.canonicalOrders(anyList())).thenAnswer(call->{List<AppointmentExternalSourceGateway.OrderRef> refs=call.getArgument(0);return refs.stream().map(ref->new AppointmentCoverageService.SourceRef(ref.customerId(),"ORDER:"+ref.orderId(),null)).toList();});
var verification=context.getBean(AppointmentCargoVerificationService.class);
for(var source:cargo) verification.save(source.sourceKey(),source.customerId(),new AppointmentCargoVerificationService.Save("FBA:ONT8",source.sourceVersion(),0,source.packages(),List.of("SHIP-"+source.customerId()),null,"verify-"+source.customerId()),"operator","客服");
var batches=context.getBean(InboundFbaAppointmentService.class);var receiving=context.getBean(FbaReceivingService.class);var store=context.getBean(FbaReceivingStore.class);
var batch=batches.createFromSources("operator",new SourceFbaAppointmentRequest(List.of(new SourceFbaAppointmentRequest.SourceRef("B","ORDER:1","FBA:ONT8"),new SourceFbaAppointmentRequest.SourceRef("D","ORDER:2","FBA:ONT8")),"100","ONT8",plan("PENDING"),"create-bd"));
batch=receiving.plan("operator",new Plan("failed-b",new Selection(batch.batchKey(),batch.version(),"B"),plan("FAILED"),"transport-v1")).batches().get(0);
batch=receiving.plan("operator",new Plan("failed-d",new Selection(batch.batchKey(),batch.version(),"D"),plan("FAILED"),"transport-v1")).batches().get(0);
assertEquals("FAILED",batch.appointmentStatus());var originalB=store.plan(batch.batchKey(),"B");
var session=context.getBean(SqlSessionTemplate.class);session.getConfiguration().addMapper(AppointmentWorkDispatchMapper.class);
var mapper=spy(session.getMapper(AppointmentWorkDispatchMapper.class));
doReturn(List.of(new AppointmentWorkDispatchMapper.Candidate(101L,"P-B",11L,"IP-B","B","RECEIVED",false),new AppointmentWorkDispatchMapper.Candidate(102L,"P-D",12L,"IP-D","D","RECEIVED",false))).when(mapper).candidates(batch.id());
doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"B","FBA:ONT8",60,60))).when(mapper).physical(101L,"B");
doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(2L,"D","FBA:ONT8",40,40))).when(mapper).physical(102L,"D");
var jdbc=new JdbcTemplate(context.getBean(DataSource.class));jdbc.update("INSERT INTO ll_dispatch_order(id,customer_id,status) VALUES(20,'B','PENDING_DEPARTURE')");
var candidates=new AppointmentWorkDispatchCandidates(mapper,batches,sources);
var binding=new AppointmentWorkDispatchBinding(mapper,batches,sources,mock(AppointmentWorkChanges.class),store);
var tx=new TransactionTemplate(context.getBean(PlatformTransactionManager.class));
var before=candidates.read(batch.batchKey()).candidates().stream().filter(c->c.customerId().equals("B")).findFirst().orElseThrow();
assertTrue(before.blockReasons().isEmpty());assertEquals(60,before.packageCount());
assertBindable(tx,binding,mapper,before);
batch=receiving.plan("operator",new Plan("pending-d",new Selection(batch.batchKey(),batch.version(),"D"),plan("PENDING"),"transport-v1")).batches().get(0);
assertEquals("PENDING",batch.appointmentStatus());assertEquals(originalB,store.plan(batch.batchKey(),"B"));
var after=candidates.read(batch.batchKey()).candidates().stream().filter(c->c.customerId().equals("B")).findFirst().orElseThrow();
assertEquals(before,after);assertBindable(tx,binding,mapper,after);
var bound=new AppointmentWorkDispatchMapper.Bound(20L,201L,101L,batch.batchKey(),"B","ORDER:1","FBA:ONT8",60);
doReturn(List.of(bound)).when(mapper).bindings("DP-B");doReturn(List.of()).when(mapper).dispatchPhysical("DP-B");
assertTrue(assertThrows(ResponseStatusException.class,()->binding.requireDeparture("DP-B")).getReason().contains("尚无有效收货确认"));
// Unchanged real-source guards still reject an unverified pallet and an allocation owned by another customer.
doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"B","FBA:ONT8",60,null))).when(mapper).physical(101L,"B");
assertFalse(candidates.read(batch.batchKey()).candidates().get(0).blockReasons().isEmpty());
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->binding.bind("B",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,101L,"RECEIVED",60)),after.allocations())));
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->binding.bind("D",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,101L,"RECEIVED",60)),after.allocations())));
doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"B","FBA:ONT8",61,61))).when(mapper).physical(101L,"B");
var excessive=candidates.read(batch.batchKey()).candidates().get(0);assertFalse(excessive.blockReasons().isEmpty());
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->binding.bind("B",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,101L,"RECEIVED",61)),excessive.allocations())));
assertTrue(mapper.byDispatch(20).isEmpty());
}
}
private static void assertBindable(TransactionTemplate tx,AppointmentWorkDispatchBinding binding,AppointmentWorkDispatchMapper mapper,AppointmentWorkDispatchCandidates.Candidate candidate) {
tx.executeWithoutResult(status->{
binding.bind("B",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,101L,"RECEIVED",60)),candidate.allocations());
assertEquals(60,mapper.byDispatch(20).stream().mapToInt(AppointmentWorkDispatchMapper.Bound::packages).sum());
status.setRollbackOnly(); // Compare identical physical availability before and after the independent customer's plan command.
});
assertTrue(mapper.byDispatch(20).isEmpty());
}
private static FbaAppointmentDetails plan(String status) {return new FbaAppointmentDetails(LocalDate.of(2026,10,25),null,null,null,null,null,status,"仓库暂无确认",null,null);}
private static AppointmentSourceService.Source source(String customer,long id,int packages) {
return new AppointmentSourceService.Source("ORDER:"+id,customer,id,"IN-"+customer,null,null,"100","美国仓","FBA:ONT8","ONT8",packages,"ORDER:1",true,List.of());
}
}
@@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import com.baiyue.lastleg.appointmentplanning.*;
import com.baiyue.lastleg.inboundplan.dto.*;
import com.baiyue.lastleg.inboundplan.mapper.*;
import com.baiyue.lastleg.inboundplan.receiving.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.SqlSessionFactory;
@@ -101,17 +102,31 @@ class FbaAppointmentPersistenceTest {
} finally {executor.shutdownNow();}
}
}
@Test void cancellationReleasesGeneratedClaimsWhileReplayKeepsItsOriginalSnapshot() {
@Test void cancellationRetainsBatchQuantityWhileReplayKeepsItsOriginalSnapshot() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var service=context.getBean(InboundFbaAppointmentService.class);var first=service.createFromSources("operator",request("first"));
service.delete("operator",first.appointmentNo(),new CancelFbaAppointmentRequest(0,"收货方取消时段"));
var second=service.createFromSources("operator",request("second"));assertNotEquals(first.appointmentNo(),second.appointmentNo());
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("second")));
assertEquals("CANCELLED",service.detail(first.appointmentNo()).appointmentStatus());
assertNull(service.detail(first.appointmentNo()).plannedDate());
assertEquals(1,service.unscheduled(1,20).total());
assertEquals(first,service.createFromSources("operator",request("first")));
var db=new JdbcTemplate(context.getBean(DataSource.class));assertEquals(4,db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment_item",Integer.class));
var db=new JdbcTemplate(context.getBean(DataSource.class));assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment_item",Integer.class));
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment_item WHERE active=1",Integer.class));
}
}
@Test void unsuccessfulBookingRetainsTheSameAllocationAndCannotBeCreatedAgain() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var service=context.getBean(InboundFbaAppointmentService.class);
var original=service.createFromSources("operator",request("failed-original"));
var failed=service.update("operator",original.appointmentNo(),new UpdateFbaAppointmentRequest(
new FbaAppointmentDetails(null,null,null,null,null,null,"FAILED","仓库无可用时段",null,null),0,"failed-command"));
assertEquals(original.items(),failed.items());
var db=new JdbcTemplate(context.getBean(DataSource.class));
assertEquals(30,db.queryForObject("SELECT SUM(package_count) FROM ll_inbound_fba_appointment_item WHERE active=1",Integer.class));
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("failed-duplicate")));
}
}
@Test void restoringCancelledAppointmentRechecksClaimsAndPreservesCancellationHistory() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var service=context.getBean(InboundFbaAppointmentService.class);
@@ -144,8 +159,7 @@ class FbaAppointmentPersistenceTest {
var json=new ObjectMapper().findAndRegisterModules().writeValueAsString(page);
assertFalse(json.contains("internal-operator"));assertFalse(json.contains("responseJson"));assertFalse(json.contains("sourceKey"));
assertTrue(service.cancellationHistory(original.appointmentNo(),2,1).records().isEmpty());
var second=service.createFromSources("internal-operator",request("history-second"));
assertEquals(0,service.cancellationHistory(second.appointmentNo(),1,20).total());
assertThrows(ResponseStatusException.class,()->service.createFromSources("internal-operator",request("history-second")));
assertThrows(ResponseStatusException.class,()->service.cancellationHistory("AP-NOT-FOUND",1,20));
var db=new JdbcTemplate(context.getBean(DataSource.class));
var mapper=new ObjectMapper().findAndRegisterModules();
@@ -197,11 +211,13 @@ class FbaAppointmentPersistenceTest {
int start=baseline.indexOf("CREATE TABLE IF NOT EXISTS "+table+" (");assertTrue(start>=0);
String sql=baseline.substring(start,baseline.indexOf(';',start)+1).replace(" STORED","").replaceAll(" ENGINE=InnoDB[^;]*;",";");statement.execute(sql);
}
statement.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,version INT DEFAULT 0)");statement.execute("INSERT INTO ll_order(id) VALUES(1),(2)");
statement.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,version INT DEFAULT 0,customer_id VARCHAR(128),customer_name VARCHAR(128))");statement.execute("INSERT INTO ll_order(id) VALUES(1),(2)");
statement.execute("CREATE TABLE ll_dispatch_item(order_id BIGINT,customer_id VARCHAR(128),dispatch_order_id BIGINT)");
statement.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(128),status VARCHAR(32))");
String work;try(var input=FbaAppointmentPersistenceTest.class.getResourceAsStream("/db/appointment-work.sql")){work=new String(Objects.requireNonNull(input).readAllBytes(),StandardCharsets.UTF_8);}
for(var sql:work.replaceAll("(?m)^--.*$","").replaceAll(" ENGINE=InnoDB[^;]*;",";").split(";")) if(!sql.isBlank()) statement.execute(sql);
String receiving;try(var input=FbaAppointmentPersistenceTest.class.getResourceAsStream("/db/appointment-receiving.sql")){receiving=new String(Objects.requireNonNull(input).readAllBytes(),StandardCharsets.UTF_8);}
for(var sql:receiving.replaceAll("(?m)^--.*$","").replaceAll(" ENGINE=InnoDB[^;]*;",";").split(";")) if(!sql.isBlank()) statement.execute(sql);
}return ds;
}
@Bean DataSourceTransactionManager transactionManager(DataSource dataSource){return new DataSourceTransactionManager(dataSource);}
@@ -209,6 +225,7 @@ class FbaAppointmentPersistenceTest {
var config=new MybatisConfiguration();config.setEnvironment(new Environment("test",new SpringManagedTransactionFactory(),dataSource));config.setMapUnderscoreToCamelCase(true);
config.addMapper(InboundFbaAppointmentMapper.class);config.addMapper(InboundFbaAppointmentItemMapper.class);config.addMapper(InboundFbaAppointmentQueryMapper.class);config.addMapper(FbaAppointmentCommandMapper.class);
config.addMapper(OrderVersionProbe.class);
config.addMapper(FbaReceivingMapper.class);
config.addMapper(AppointmentCargoVerificationMapper.class);
config.addMapper(com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.class);
return new MybatisSqlSessionFactoryBuilder().build(config);
@@ -218,6 +235,24 @@ class FbaAppointmentPersistenceTest {
@Bean InboundFbaAppointmentItemMapper itemMapper(SqlSessionTemplate session){return session.getMapper(InboundFbaAppointmentItemMapper.class);}
@Bean InboundFbaAppointmentQueryMapper queryMapper(SqlSessionTemplate session){return session.getMapper(InboundFbaAppointmentQueryMapper.class);}
@Bean FbaAppointmentCommandMapper commands(SqlSessionTemplate session){return session.getMapper(FbaAppointmentCommandMapper.class);}
@Bean FbaReceivingMapper receivingMapper(SqlSessionTemplate session){return session.getMapper(FbaReceivingMapper.class);}
@Bean FbaReceivingStore receivingStore(FbaReceivingMapper mapper,ObjectMapper json){return new FbaReceivingStore(mapper,json);}
@Bean FbaAppointmentTransportPort receivingTransport(){
var port=mock(FbaAppointmentTransportPort.class);var scope=new FbaAppointmentTransportPort.Scope("transport-v1",List.of(),List.of());
when(port.inspect(anyList())).thenReturn(scope);when(port.lockAndInspect(anyList(),anyString())).thenReturn(scope);
when(port.apply(any(),anyList(),anyString(),anyString())).thenReturn(new FbaAppointmentTransportPort.Result(List.of(),List.of()));return port;
}
@Bean FbaAppointmentWorkPort receivingWork(){
var port=mock(FbaAppointmentWorkPort.class);
when(port.resolveFollowup(anyString(),nullable(java.time.LocalDate.class))).thenAnswer(c->new FbaAppointmentWorkPort.Followup(c.getArgument(0),"测试负责人",c.getArgument(1)));
return port;
}
@Bean FbaAppointmentExecutionPort receivingExecution(){
var port=mock(FbaAppointmentExecutionPort.class);
org.mockito.stubbing.Answer<FbaAppointmentExecutionPort.Scope> scope=c->{List<FbaAppointmentExecutionPort.Member> members=c.getArgument(0);return new FbaAppointmentExecutionPort.Scope("transport-v1",members.stream().map(m->new FbaAppointmentExecutionPort.Eligibility(m,false,true,null)).toList());};
when(port.inspect(anyList())).thenAnswer(scope);when(port.lockAndInspect(anyList(),anyString())).thenAnswer(scope);return port;
}
@Bean FbaReceivingService receivingService(FbaReceivingStore store,InboundFbaAppointmentService batches,FbaAppointmentTransportPort transport,FbaAppointmentCommandMapper commands,FbaAppointmentWorkPort work,FbaAppointmentExecutionPort execution){return new FbaReceivingService(store,batches,transport,commands,work,execution);}
@Bean AppointmentCargoVerificationMapper verifications(SqlSessionTemplate session){return session.getMapper(AppointmentCargoVerificationMapper.class);}
@Bean AppointmentCargoVerificationService verificationService(AppointmentSourceService sources,AppointmentCargoVerificationMapper mapper,ObjectMapper json){return new AppointmentCargoVerificationService(sources,mapper,json);}
@Bean AppointmentSourceService sources(){var mock=mock(AppointmentSourceService.class);when(mock.resolveBatch(anyList())).thenAnswer(call->{List<AppointmentCoverageService.SourceRef> refs=call.getArgument(0);return refs.stream().map(ref->cargo(20).stream().filter(s->s.sourceKey().equals(ref.sourceKey()) && s.customerId().equals(ref.customerId())).findFirst().orElseThrow()).toList();});when(mock.destinationTimezone("ONT8")).thenReturn("America/Los_Angeles");return mock;}
@@ -231,6 +266,6 @@ class FbaAppointmentPersistenceTest {
@Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper workMapper(SqlSessionTemplate session){return session.getMapper(com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.class);}
@Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges workInvalidation(com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper mapper){return new com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges(mapper);}
@Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService workCommitments(com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper mapper,ObjectMapper json){return new com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService(mapper,json);}
@Bean com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges appointmentWorkChanges(com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges changes,com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService commitments){return new com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges(commitments,changes);}
@Bean com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges appointmentWorkChanges(com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges changes,com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService commitments,FbaReceivingStore receiving){return new com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges(commitments,changes,receiving);}
}
}
@@ -15,6 +15,39 @@ import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class FbaAppointmentRepartitionPersistenceTest {
@Test void allCurrentDestinationsSurvivePartialRedistributionAndConvergingMergePaths() {
try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) {
var service=context.getBean(InboundFbaAppointmentService.class);
var original=service.createFromSources("operator",create("lineage-create"));
var first=service.repartition("operator",split(original,"lineage-first"));
var later=first.appointments().get(1);
var second=service.repartition("operator",new RepartitionFbaAppointmentsRequest(
List.of(new RepartitionFbaAppointmentsRequest.Original(later.appointmentNo(),later.version())),
List.of(batch(cargo("A","ORDER:1",4)),batch(cargo("A","ORDER:1",2))),"后送部分再排","lineage-second"));
var current=service.batchContext(original.batchKey()).currentBatches();
assertEquals(3,current.size());
assertEquals(List.of(2,4,4),current.stream().flatMap(a->a.items().stream()).filter(i->i.sourceKey().equals("ORDER:1")).map(FbaAppointmentResponse.Item::packageCount).sorted().toList());
assertEquals(2,service.batchContext(later.batchKey()).currentBatches().size());
var merged=service.repartition("operator",new RepartitionFbaAppointmentsRequest(
second.appointments().stream().map(a->new RepartitionFbaAppointmentsRequest.Original(a.appointmentNo(),a.version())).toList(),
List.of(batch(cargo("A","ORDER:1",6))),"后送重新合并","lineage-merge"));
var mergedCurrent=service.batchContext(original.batchKey()).currentBatches();
assertEquals(2,mergedCurrent.size());
assertEquals(1,mergedCurrent.stream().filter(a->a.batchKey().equals(merged.appointments().get(0).batchKey())).count());
assertEquals(List.of(merged.appointments().get(0)),service.batchContext(later.batchKey()).currentBatches());
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",create("lineage-duplicate")));
}
}
@Test void brokenOrCyclicReplacementRelationsAreNotReportedAsEmptyOrCompleted() {
try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) {
var service=context.getBean(InboundFbaAppointmentService.class);var db=new JdbcTemplate(context.getBean(DataSource.class));
var original=service.createFromSources("operator",create("broken-create"));
db.update("UPDATE ll_inbound_fba_appointment SET replacement_nos_json='[\"missing\"]',replacement_keys_json='[\"FBA_APPOINTMENT:999\"]' WHERE id=?",original.id());
assertEquals(404,assertThrows(ResponseStatusException.class,()->service.batchContext(original.batchKey())).getStatusCode().value());
db.update("UPDATE ll_inbound_fba_appointment SET replacement_keys_json=? WHERE id=?","[\""+original.batchKey()+"\"]",original.id());
assertEquals(409,assertThrows(ResponseStatusException.class,()->service.batchContext(original.batchKey())).getStatusCode().value());
}
}
private static SourceFbaAppointmentRequest create(String key) {
return new SourceFbaAppointmentRequest(List.of(new SourceFbaAppointmentRequest.SourceRef("A","ORDER:1","FBA:ONT8"),new SourceFbaAppointmentRequest.SourceRef("B","ORDER:2","FBA:ONT8")),
"100","ONT8",pending(),key);
@@ -0,0 +1,818 @@
package com.baiyue.lastleg.inboundplan.service;
import com.baiyue.lastleg.appointmentplanning.*;
import com.baiyue.lastleg.inboundplan.dto.*;
import com.baiyue.lastleg.inboundplan.receiving.*;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.web.server.ResponseStatusException;
import javax.sql.DataSource;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import static com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** Real H2 core transactions; transport, execution and work ports are mocked, not physical/owner-domain acceptance. */
class FbaReceivingPersistenceTest {
private static final String OPERATOR="operator", TRANSPORT="transport-v1", DESTINATION="FBA:ONT8";
@Test void pendingChangePreservesConfirmedCargoAndOnlyMarksTheAffectedCustomer() {
try(var f=new Fixture()) {
var original=f.create();var group=f.store.currentAgreement(original.batchKey(),"B");
var pending=f.propose(original,100,"pending");
assertEquals("WAITING",pending.status());
assertEquals(original,f.batches.detail(original.appointmentNo()));
assertEquals(group,f.store.currentAgreement(original.batchKey(),"B"));
assertTrue(f.store.hasPending(original.batchKey(),"B"));
assertFalse(f.store.hasPending(original.batchKey(),"A"));
assertEquals(1,f.receiving.context(original.batchKey()).pendingChanges().size());
f.assertAllocations(80,160,40);
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
var withdrawn=f.receiving.resolve(OPERATOR,pending.changeKey(),new Resolve("withdraw",0,"WITHDRAW",null,TRANSPORT));
assertEquals("WITHDRAWN",withdrawn.status());
assertFalse(f.store.hasPending(original.batchKey(),"B"));
assertEquals(original,f.batches.detail(original.appointmentNo()));
}
}
@Test void confirmedPartialMoveRetainsOtherCustomersAndLaterSixtyCanSplitIntoFortyAndTwenty() {
try(var f=new Fixture()) {
var original=f.create();var group=f.store.currentAgreement(original.batchKey(),"B");
var proposed=f.propose(original,100,"partial");
var result=f.confirm(proposed.changeKey(),"partial-confirm");
assertEquals("APPLIED",result.status());
var retained=result.batches().get(0);var later=result.batches().get(1);
assertEquals(original.batchKey(),retained.batchKey());
assertEquals(220,total(retained));assertEquals(60,total(later));
assertEquals(80,quantity(retained,"A"));assertEquals(100,quantity(retained,"B"));assertEquals(40,quantity(retained,"D"));
assertEquals(original.confirmationNo(),retained.confirmationNo());
assertNull(later.confirmationNo());assertEquals("PENDING",later.appointmentStatus());
var current=f.store.currentAgreement(original.batchKey(),"B");
assertEquals(group.agreementKey(),current.agreementKey());assertEquals(group.version()+1,current.version());
assertEquals(100,current.members().stream().filter(m->m.customerId().equals("B")).findFirst().orElseThrow().sources().get(0).packageCount());
assertFalse(f.store.hasPending(original.batchKey(),"B"));
var split=f.batches.repartition(OPERATOR,new RepartitionFbaAppointmentsRequest(
List.of(new RepartitionFbaAppointmentsRequest.Original(later.appointmentNo(),later.version())),
List.of(laterBatch(40),laterBatch(20)),"后送六十箱分两次送","split-later"));
assertEquals(List.of(20,40),split.appointments().stream().map(FbaReceivingPersistenceTest::total).sorted().toList());
var destinations=f.batches.batchContext(original.batchKey()).currentBatches();
assertEquals(List.of(20,40,100),destinations.stream().flatMap(b->b.items().stream()).filter(i->i.customerId().equals("B")).map(FbaAppointmentResponse.Item::packageCount).sorted().toList());
f.assertAllocations(80,160,40);
assertEquals(0,f.db.queryForObject("SELECT SUM(version) FROM ll_order",Integer.class));
verify(f.transport).apply(any(),eq(List.of(new FbaAppointmentTransportPort.Move(original.batchKey(),"B","ORDER:2",DESTINATION,160,100,later.batchKey()))),eq(OPERATOR),anyString());
}
}
@Test void allOfCustomerCanMoveOutWithoutLeavingZeroQuantityInCurrentBatch() {
try(var f=new Fixture()) {
var original=f.create();var group=f.store.currentAgreement(original.batchKey(),"B");
var result=f.confirm(f.propose(original,0,"all-out").changeKey(),"all-out-confirm");
assertEquals(120,total(result.batches().get(0)));assertEquals(160,total(result.batches().get(1)));
var reread=f.receiving.context(original.batchKey()).batch();
assertEquals(List.of("A","D"),reread.items().stream().map(FbaAppointmentResponse.Item::customerId).sorted().toList());
assertTrue(reread.items().stream().allMatch(i->i.packageCount()>0));
assertNull(f.store.currentAgreement(original.batchKey(),"B"));
var remaining=f.store.agreement(group.agreementKey(),false);
assertEquals(List.of("A","D"),remaining.members().stream().map(Member::customerId).sorted().toList());
assertEquals(original.confirmationNo(),reread.confirmationNo());
var destinations=f.batches.batchContext(original.batchKey());
assertTrue(destinations.originalSourceRefs().stream().anyMatch(s->s.customerId().equals("B")&&s.sourceKey().equals("ORDER:2")&&s.destinationKey().equals(DESTINATION)));
assertEquals(3,destinations.originalSourceRefs().size());
assertEquals(List.of(120,160),destinations.currentBatches().stream().map(FbaReceivingPersistenceTest::total).sorted().toList());
assertEquals(result.batches().get(1).batchKey(),destinations.currentBatches().stream().filter(b->quantity(b,"B")==160).findFirst().orElseThrow().batchKey());
f.assertAllocations(80,160,40);
}
}
@Test void identicalCommandsReplayAndDifferentContentCannotReuseTheirKeys() {
try(var f=new Fixture()) {
var original=f.create();var proposal=new Propose("same-proposal",intent(original,100));
var first=f.receiving.propose(OPERATOR,proposal);
assertEquals(first,f.receiving.propose(OPERATOR,proposal));
conflict(()->f.receiving.propose(OPERATOR,new Propose("same-proposal",intent(original,90))));
var resolve=new Resolve("same-resolve",0,"CONFIRM","仓库同意一百箱先送",TRANSPORT);
var applied=f.receiving.resolve(OPERATOR,first.changeKey(),resolve);
assertEquals(applied,f.receiving.resolve(OPERATOR,first.changeKey(),resolve));
conflict(()->f.receiving.resolve(OPERATOR,first.changeKey(),new Resolve("same-resolve",0,"WITHDRAW",null,TRANSPORT)));
assertEquals(2,f.db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment",Integer.class));
assertEquals(1,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_change",Integer.class));
verify(f.transport,times(1)).apply(any(),anyList(),anyString(),anyString());
f.assertAllocations(80,160,40);
}
}
@Test void transportFailureRollsBackAllocationAgreementAndCommandThenSameKeyCanRetry() {
try(var f=new Fixture()) {
var original=f.create();var group=f.store.currentAgreement(original.batchKey(),"B");
var pending=f.propose(original,100,"rollback-propose");
when(f.transport.apply(any(),anyList(),anyString(),anyString())).thenThrow(new IllegalStateException("physical adjustment rejected"));
assertThrows(IllegalStateException.class,()->f.confirm(pending.changeKey(),"retry-resolve"));
assertEquals(original,f.batches.detail(original.appointmentNo()));
assertEquals(group,f.store.currentAgreement(original.batchKey(),"B"));
assertEquals("WAITING",f.store.change(pending.changeKey(),false).status());
assertEquals(1,f.db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment",Integer.class));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key='retry-resolve'",Integer.class));
f.assertAllocations(80,160,40);
doReturn(new FbaAppointmentTransportPort.Result(List.of(),List.of())).when(f.transport).apply(any(),anyList(),anyString(),anyString());
assertEquals("APPLIED",f.confirm(pending.changeKey(),"retry-resolve").status());
f.assertAllocations(80,160,40);
}
}
@Test void groupCancellationKeepsAllCargoAndRebookingCreatesANewEffectiveIdentity() {
try(var f=new Fixture()) {
var original=f.create();var old=f.store.currentAgreement(original.batchKey(),"A");
var cancelled=f.cancel(old);var batch=cancelled.batches().get(0);var group=cancelled.agreements().get(0);
assertEquals(original.batchKey(),batch.batchKey());assertEquals(280,total(batch));
assertEquals("CANCELLED",batch.appointmentStatus());assertNull(batch.confirmationNo());assertNull(batch.plannedDate());
assertEquals(old.details(),group.details());
assertEquals(3,f.receiving.groupContext(old.agreementKey()).eligibleMembers().size());
var request=new Book("rebook-group",List.of(new Booking(selections(batch,"A","B","D"),confirmed("NEW-GROUP"),"仓库确认新约覆盖三客户")),old.agreementKey(),group.version(),TRANSPORT);
var rebooked=f.receiving.book(OPERATOR,request);
assertNotEquals(old.agreementKey(),rebooked.agreements().get(0).agreementKey());
assertEquals("SUCCESS",rebooked.batches().get(0).appointmentStatus());
assertEquals("NEW-GROUP",rebooked.batches().get(0).confirmationNo());
assertEquals(rebooked,f.receiving.book(OPERATOR,request));
assertEquals("CANCELLED",f.store.agreement(old.agreementKey(),false).status());
assertEquals(1,f.db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment",Integer.class));
f.assertAllocations(80,160,40);
}
}
@Test void independentlyRebookedCustomerIsExcludedFromOldGroupAndOthersCanRebookTogether() {
try(var f=new Fixture()) {
var original=f.create();var old=f.store.currentAgreement(original.batchKey(),"A");
var cancellation=f.cancel(old);var cancelled=cancellation.agreements().get(0);var batch=cancellation.batches().get(0);
var independent=f.receiving.book(OPERATOR,new Book("rebook-a",List.of(new Booking(selections(batch,"A"),confirmed("A-ONLY"),"A独立确认")),old.agreementKey(),cancelled.version(),TRANSPORT));
var group=f.receiving.groupContext(old.agreementKey());
assertEquals(List.of("B","D"),group.eligibleMembers().stream().map(Member::customerId).sorted().toList());
assertEquals(1,group.excludedMembers().size());assertEquals("A",group.excludedMembers().get(0).member().customerId());
assertEquals(independent.agreements().get(0).agreementKey(),group.excludedMembers().get(0).currentAgreementKey());
var current=independent.batches().get(0);
conflict(()->f.receiving.book(OPERATOR,new Book("try-include-a",List.of(new Booking(selections(current,"A","B","D"),confirmed("INVALID"),"旧范围")),old.agreementKey(),cancelled.version(),TRANSPORT)));
var others=f.receiving.book(OPERATOR,new Book("rebook-bd",List.of(new Booking(selections(current,"B","D"),confirmed("BD-ONLY"),"B及D共同确认")),old.agreementKey(),cancelled.version(),TRANSPORT));
assertNotEquals(independent.agreements().get(0).agreementKey(),others.agreements().get(0).agreementKey());
assertEquals(independent.agreements().get(0).agreementKey(),f.store.currentAgreement(original.batchKey(),"A").agreementKey());
f.assertAllocations(80,160,40);
}
}
@Test void staleBatchAndPendingVersionsNeverApplyQuantityChanges() {
try(var f=new Fixture()) {
var original=f.create();var pending=f.propose(original,100,"stale-propose");
conflict(()->f.receiving.resolve(OPERATOR,pending.changeKey(),new Resolve("wrong-pending",1,"CONFIRM","同意",TRANSPORT)));
f.db.update("UPDATE ll_inbound_fba_appointment SET version=version+1 WHERE id=?",original.id());
conflict(()->f.confirm(pending.changeKey(),"stale-batch"));
assertEquals("WAITING",f.store.change(pending.changeKey(),false).status());
assertEquals(280,total(f.batches.detail(original.appointmentNo())));f.assertAllocations(80,160,40);
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
}
}
@Test void changedAgreementScopeVersionInvalidatesEarlierWarehouseReply() {
try(var f=new Fixture()) {
var original=f.create();var group=f.store.currentAgreement(original.batchKey(),"B");
var pending=f.propose(original,100,"scope-propose");
new TransactionTemplate(f.context.getBean(DataSourceTransactionManager.class)).execute(status->{
f.store.update(group,group.members(),confirmed("UPDATED-GROUP"),"新一轮范围确认");return null;
});
conflict(()->f.confirm(pending.changeKey(),"stale-agreement"));
assertEquals("WAITING",f.store.change(pending.changeKey(),false).status());
assertEquals(original,f.batches.detail(original.appointmentNo()));f.assertAllocations(80,160,40);
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
}
}
@Test void changedOriginalSourceCannotBeAppliedUsingAnEarlierVerificationAndReply() {
try(var f=new Fixture()) {
var original=f.create();var pending=f.propose(original,100,"source-propose");
var changed=new AppointmentSourceService.Source("ORDER:2","B",2L,"IN-B",null,null,"100","美国仓",DESTINATION,"ONT8",160,"ORDER:2",true,List.of());
when(f.context.getBean(AppointmentSourceService.class).resolveBatch(anyList())).thenReturn(List.of(changed));
conflict(()->f.confirm(pending.changeKey(),"stale-source"));
assertEquals(original,f.batches.detail(original.appointmentNo()));
assertEquals("WAITING",f.store.change(pending.changeKey(),false).status());
f.assertAllocations(80,160,40);
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
}
}
@Test void transportScopeConflictCannotCreatePendingChangeOrConsumeCommandKey() {
try(var f=new Fixture()) {
var original=f.create();
when(f.transport.lockAndInspect(anyList(),eq(TRANSPORT))).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"原车次范围已更新"));
conflict(()->f.propose(original,100,"stale-transport"));
assertFalse(f.store.hasPending(original.batchKey(),"B"));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key='stale-transport'",Integer.class));
assertEquals(original,f.batches.detail(original.appointmentNo()));
f.assertAllocations(80,160,40);
}
}
@Test void legacyEditCannotCancelOrExpandAnIndependentCustomerAgreement() {
try(var f=new Fixture()) {
var original=f.create();var old=f.store.currentAgreement(original.batchKey(),"A");
var cancellation=f.cancel(old);var cancelled=cancellation.agreements().get(0);var batch=cancellation.batches().get(0);
var independent=f.receiving.book(OPERATOR,new Book("only-a",List.of(new Booking(selections(batch,"A"),confirmed("A-PRIVATE"),"仅A已获仓库确认")),old.agreementKey(),cancelled.version(),TRANSPORT));
var current=independent.batches().get(0);var agreement=independent.agreements().get(0);
var failed=new FbaAppointmentDetails(null,null,null,null,null,null,"FAILED","其他客户暂时没有约到",null,null);
conflict(()->f.batches.update(OPERATOR,current.appointmentNo(),new UpdateFbaAppointmentRequest(failed,current.version(),"legacy-failed")));
conflict(()->f.batches.update(OPERATOR,current.appointmentNo(),new UpdateFbaAppointmentRequest(confirmed("A-PRIVATE"),current.version(),"legacy-expand")));
assertEquals(agreement,f.store.currentAgreement(current.batchKey(),"A"));
assertEquals(List.of("A"),f.store.agreement(agreement.agreementKey(),false).members().stream().map(Member::customerId).toList());
assertNull(f.store.currentAgreement(current.batchKey(),"B"));assertNull(f.store.currentAgreement(current.batchKey(),"D"));
assertEquals(current,f.batches.detail(current.appointmentNo()));
var legacyContext=f.batches.batchContext(current.batchKey());
assertFalse(legacyContext.allowedActions().contains("SPLIT"));
assertFalse(legacyContext.allowedActions().contains("MERGE"));
assertTrue(legacyContext.blockReasons().stream().anyMatch(reason->reason.contains("部分客户已取得确认")));
f.assertAllocations(80,160,40);
}
}
@Test void fullyMovedSingleCustomerGroupExplainsExclusionWithoutReadingZeroQuantityAsCurrentCargo() {
try(var f=new Fixture()) {
var original=f.batches.createFromSources(OPERATOR,new SourceFbaAppointmentRequest(
List.of(new SourceFbaAppointmentRequest.SourceRef("B","ORDER:2",DESTINATION)),"100","ONT8",confirmed("B-ORIGINAL"),"create-b"));
var old=f.store.currentAgreement(original.batchKey(),"B");
var moved=f.confirm(f.propose(original,0,"move-all-b").changeKey(),"confirm-all-b");
assertEquals(160,total(moved.batches().get(1)));
var context=f.receiving.groupContext(old.agreementKey());
assertTrue(context.eligibleMembers().isEmpty());assertEquals(1,context.excludedMembers().size());
var excluded=context.excludedMembers().get(0);
assertEquals("B",excluded.member().customerId());
assertTrue(excluded.reason().contains("转入")||excluded.reason().contains("移出"));
assertEquals(160,excluded.member().sources().stream().mapToInt(Source::packageCount).sum());
assertTrue(excluded.member().sources().stream().allMatch(source->source.packageCount()>0));
assertNull(f.store.currentAgreement(original.batchKey(),"B"));
assertEquals(List.of(moved.batches().get(1).batchKey()),f.batches.batchContext(original.batchKey()).currentBatches().stream().map(FbaAppointmentResponse::batchKey).toList());
}
}
@Test void unchangedLegacyConfirmationWithNewCommandKeyDoesNotChangeBatchAgreementOrHistory() {
try(var f=new Fixture()) {
var original=f.create();var agreement=f.store.currentAgreement(original.batchKey(),"A");
var revisions=f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_revision WHERE agreement_key=?",Integer.class,agreement.agreementKey());
var request=new UpdateFbaAppointmentRequest(confirmed("ORIGINAL-GROUP"),original.version(),"unchanged-confirmation");
var saved=f.batches.update(OPERATOR,original.appointmentNo(),request);
assertEquals(original,saved);assertEquals(original,f.batches.detail(original.appointmentNo()));
assertEquals(agreement,f.store.currentAgreement(original.batchKey(),"A"));
assertEquals(revisions,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_revision WHERE agreement_key=?",Integer.class,agreement.agreementKey()));
assertEquals(saved,f.batches.update(OPERATOR,original.appointmentNo(),request));
f.assertAllocations(80,160,40);
}
}
@Test void pendingChangeCanBeWithdrawnAfterTransportBecomesImmutableButCannotBeConfirmed() {
try(var f=new Fixture()) {
var original=f.create();var group=f.store.currentAgreement(original.batchKey(),"B");
var pending=f.propose(original,100,"before-picking");
when(f.transport.lockAndInspect(anyList(),eq(TRANSPORT))).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"已开始拣货,禁止调整运输货量"));
conflict(()->f.confirm(pending.changeKey(),"confirm-after-picking"));
assertEquals("WAITING",f.store.change(pending.changeKey(),false).status());
conflict(()->f.receiving.resolve(OPERATOR,pending.changeKey(),new Resolve("withdraw-wrong-version",1,"WITHDRAW",null,TRANSPORT)));
var request=new Resolve("withdraw-after-picking",0,"WITHDRAW",null,TRANSPORT);
var result=f.receiving.resolve(OPERATOR,pending.changeKey(),request);
assertEquals("WITHDRAWN",result.status());
assertEquals(1,f.store.change(pending.changeKey(),false).version());
assertFalse(f.store.hasPending(original.batchKey(),"B"));
assertEquals(original,f.batches.detail(original.appointmentNo()));
assertEquals(group,f.store.currentAgreement(original.batchKey(),"B"));
assertEquals(result,f.receiving.resolve(OPERATOR,pending.changeKey(),request));
conflict(()->f.receiving.resolve(OPERATOR,pending.changeKey(),new Resolve("withdraw-after-picking",0,"CONFIRM","修改请求内容",TRANSPORT)));
verify(f.transport,times(2)).lockAndInspect(anyList(),eq(TRANSPORT));
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
f.assertAllocations(80,160,40);
}
}
@Test void completedCustomerIsExcludedAndRemainingCustomersRebookWithoutTransportMutationLocks() {
try(var f=new Fixture()) {
var original=f.create();var group=f.store.currentAgreement(original.batchKey(),"A");
var cancelled=f.cancel(group);var batch=cancelled.batches().get(0);var cancelledGroup=cancelled.agreements().get(0);
org.mockito.stubbing.Answer<FbaAppointmentExecutionPort.Scope> scope=call->{
List<FbaAppointmentExecutionPort.Member> members=call.getArgument(0);
return new FbaAppointmentExecutionPort.Scope("execution-v1",members.stream().map(m->new FbaAppointmentExecutionPort.Eligibility(m,m.customerId().equals("A"),!m.customerId().equals("A"),m.customerId().equals("A")?"本客户货物已经全部签收":null)).toList());
};
when(f.execution.inspect(anyList())).thenAnswer(scope);
when(f.execution.lockAndInspect(anyList(),anyString())).thenAnswer(scope);
clearInvocations(f.transport);
var candidates=f.receiving.groupContext(group.agreementKey());
assertEquals(List.of("B","D"),candidates.eligibleMembers().stream().map(Member::customerId).sorted().toList());
assertEquals(1,candidates.excludedMembers().size());
assertEquals("A",candidates.excludedMembers().get(0).member().customerId());
assertFalse(candidates.excludedMembers().get(0).reason().isBlank());
var before=f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_agreement",Integer.class);
conflict(()->f.receiving.book(OPERATOR,new Book("completed-a-rejected",List.of(new Booking(selections(batch,"A"),confirmed("ILLEGAL-A"),"误选已签收客户")),group.agreementKey(),cancelledGroup.version(),"execution-v1")));
assertEquals(before,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_agreement",Integer.class));
assertNull(f.store.currentAgreement(batch.batchKey(),"A"));
var rebooked=f.receiving.book(OPERATOR,new Book("remaining-bd",List.of(new Booking(selections(batch,"B","D"),confirmed("REMAINING-BD"),"仓库仅确认未完成客户")),group.agreementKey(),cancelledGroup.version(),candidates.executionScope().version()));
assertEquals(List.of("B","D"),rebooked.agreements().get(0).members().stream().map(Member::customerId).sorted().toList());
verify(f.execution).lockAndInspect(eq(List.of(new FbaAppointmentExecutionPort.Member(batch.batchKey(),"B"),new FbaAppointmentExecutionPort.Member(batch.batchKey(),"D"))),eq("execution-v1"));
verify(f.transport,never()).lockAndInspect(anyList(),anyString());
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
f.assertAllocations(80,160,40);
}
}
@Test void undatedLaterBatchReceivesVerifiedFollowupAndFurtherSplitInheritsExactCustomerMembers() {
try(var f=new Fixture()) {
var original=f.create();var base=intent(original,100);var followup=new FbaAppointmentWorkPort.Followup("42","王客服",LocalDate.of(2026,10,19));
when(f.work.resolveFollowup("42",followup.nextFollowupDate())).thenReturn(followup);
var input=new ChangeIntent(base.batchKey(),base.batchVersion(),base.customerId(),base.quantities(),null,"42",followup.nextFollowupDate(),base.reason(),base.transportVersion());
var pending=f.receiving.propose(OPERATOR,new Propose("undated-propose",input));
assertEquals(followup,f.store.change(pending.changeKey(),false).followup());
var applied=f.confirm(pending.changeKey(),"undated-confirm");var later=applied.batches().get(1);
assertNull(later.plannedDate());assertNull(later.plannedAt());assertNull(later.confirmationNo());
verify(f.work,times(2)).resolveFollowup("42",followup.nextFollowupDate());
verify(f.work).seedFollowup(new FbaAppointmentWorkPort.Member(original.batchKey(),"B"),new FbaAppointmentWorkPort.Member(later.batchKey(),"B"),followup,OPERATOR);
var split=f.batches.repartition(OPERATOR,new RepartitionFbaAppointmentsRequest(
List.of(new RepartitionFbaAppointmentsRequest.Original(later.appointmentNo(),later.version())),List.of(laterBatch(40),laterBatch(20)),"后送再拆分并继承跟进","inherit-split"));
verify(f.work).inheritFollowup(eq(List.of(new FbaAppointmentWorkPort.Member(later.batchKey(),"B"))),eq(split.appointments().stream().map(b->new FbaAppointmentWorkPort.Member(b.batchKey(),"B")).toList()),eq(OPERATOR));
assertEquals(List.of(20,40),split.appointments().stream().map(FbaReceivingPersistenceTest::total).sorted().toList());
f.assertAllocations(80,160,40);
}
}
@Test void inactiveResponsiblePersonPreventsProposalWithoutCreatingPendingOrQuantityChanges() {
try(var f=new Fixture()) {
var original=f.create();
when(f.work.resolveFollowup(anyString(),nullable(LocalDate.class))).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"负责人已停用"));
conflict(()->f.propose(original,100,"inactive-propose"));
assertFalse(f.store.hasPending(original.batchKey(),"B"));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key='inactive-propose'",Integer.class));
assertEquals(original,f.batches.detail(original.appointmentNo()));
verify(f.work,never()).seedFollowup(any(),any(),any(),anyString());
f.assertAllocations(80,160,40);
}
}
@Test void ownerBecomingInactiveBeforeConfirmationPreservesPendingAndOriginalCargo() {
try(var f=new Fixture()) {
var original=f.create();var agreement=f.store.currentAgreement(original.batchKey(),"B");
var pending=f.propose(original,100,"owner-active-propose");
when(f.work.resolveFollowup(anyString(),nullable(LocalDate.class))).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"负责人已停用"));
conflict(()->f.confirm(pending.changeKey(),"inactive-confirm"));
assertEquals("WAITING",f.store.change(pending.changeKey(),false).status());
assertEquals(original,f.batches.detail(original.appointmentNo()));
assertEquals(agreement,f.store.currentAgreement(original.batchKey(),"B"));
assertEquals(1,f.db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment",Integer.class));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key='inactive-confirm'",Integer.class));
verify(f.work,never()).seedFollowup(any(),any(),any(),anyString());
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
f.assertAllocations(80,160,40);
}
}
@Test void followupSeedFailureRollsBackCreatedLaterBatchAndKeepsOriginalPendingRetryable() {
try(var f=new Fixture()) {
var original=f.create();var agreement=f.store.currentAgreement(original.batchKey(),"B");
var pending=f.propose(original,100,"seed-propose");
doThrow(new IllegalStateException("work followup persistence unavailable")).when(f.work).seedFollowup(any(),any(),any(),anyString());
assertThrows(IllegalStateException.class,()->f.confirm(pending.changeKey(),"seed-retry"));
assertEquals(original,f.batches.detail(original.appointmentNo()));
assertEquals(agreement,f.store.currentAgreement(original.batchKey(),"B"));
assertEquals("WAITING",f.store.change(pending.changeKey(),false).status());
assertEquals(1,f.db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment",Integer.class));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key='seed-retry'",Integer.class));
doNothing().when(f.work).seedFollowup(any(),any(),any(),anyString());
assertEquals("APPLIED",f.confirm(pending.changeKey(),"seed-retry").status());
f.assertAllocations(80,160,40);
}
}
@Test void movedCustomerCannotReuseConfirmationStillCoveringOtherCustomersButCanBookIndependently() {
try(var f=new Fixture()) {
var original=f.create();var moved=f.confirm(f.propose(original,0,"move-b-for-rebook").changeKey(),"move-b-for-rebook-confirm");
var retained=moved.batches().get(0);var later=moved.batches().get(1);
var remainingAgreement=f.store.currentAgreement(original.batchKey(),"A");
var before=f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_agreement",Integer.class);
conflict(()->f.receiving.book(OPERATOR,new Book("reuse-live-number",List.of(new Booking(selections(later,"B"),confirmed(original.confirmationNo()),"B错误沿用原约号")),null,null,TRANSPORT)));
assertEquals(before,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_agreement",Integer.class));
assertEquals(remainingAgreement,f.store.currentAgreement(original.batchKey(),"A"));
assertEquals(remainingAgreement,f.store.currentAgreement(original.batchKey(),"D"));
assertEquals(retained,f.batches.detail(original.appointmentNo()));
assertEquals(later,f.batches.detail(later.appointmentNo()));assertEquals(160,total(later));
assertNull(f.store.currentAgreement(later.batchKey(),"B"));
var separate=f.receiving.book(OPERATOR,new Book("new-b-number",List.of(new Booking(selections(later,"B"),confirmed("B-NEW-CONFIRMATION"),"B已取得独立约号")),null,null,TRANSPORT));
assertEquals("B-NEW-CONFIRMATION",separate.agreements().get(0).details().confirmationNo());
assertNotEquals(remainingAgreement.agreementKey(),separate.agreements().get(0).agreementKey());
assertEquals(remainingAgreement,f.store.currentAgreement(original.batchKey(),"A"));
f.assertAllocations(80,160,40);
}
}
@Test void executionAssociationChangingBetweenLockAndBatchLockRejectsBookingAtomically() {
try(var f=new Fixture()) {
var original=f.create();var old=f.store.currentAgreement(original.batchKey(),"A");
var cancelled=f.cancel(old);var batch=cancelled.batches().get(0);var cancelledGroup=cancelled.agreements().get(0);
var member=new FbaAppointmentExecutionPort.Member(batch.batchKey(),"B");
var eligibility=new FbaAppointmentExecutionPort.Eligibility(member,false,true,null);
when(f.execution.lockAndInspect(eq(List.of(member)),eq("execution-v1"))).thenReturn(new FbaAppointmentExecutionPort.Scope("execution-v1",List.of(eligibility)));
when(f.execution.inspect(eq(List.of(member)))).thenReturn(new FbaAppointmentExecutionPort.Scope("execution-v2",List.of(eligibility)));
var before=f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_agreement",Integer.class);
conflict(()->f.receiving.book(OPERATOR,new Book("raced-execution",List.of(new Booking(selections(batch,"B"),confirmed("RACED-B"),"并发新关联")),old.agreementKey(),cancelledGroup.version(),"execution-v1")));
assertEquals(before,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_agreement",Integer.class));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key='raced-execution'",Integer.class));
assertNull(f.store.currentAgreement(batch.batchKey(),"B"));
assertEquals(batch,f.batches.detail(batch.appointmentNo()));
f.assertAllocations(80,160,40);
}
}
@Test void transportAssociationChangingBeforeBatchLockRejectsProposalWithoutPendingOrCommand() {
try(var f=new Fixture()) {
var original=f.create();var agreement=f.store.currentAgreement(original.batchKey(),"B");
when(f.transport.lockAndInspect(anyList(),eq(TRANSPORT))).thenReturn(new FbaAppointmentTransportPort.Scope(TRANSPORT,List.of(),List.of()));
when(f.transport.inspect(anyList())).thenReturn(new FbaAppointmentTransportPort.Scope("transport-v2",List.of(),List.of()));
conflict(()->f.propose(original,100,"raced-proposal"));
assertFalse(f.store.hasPending(original.batchKey(),"B"));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_change",Integer.class));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key='raced-proposal'",Integer.class));
assertEquals(original,f.batches.detail(original.appointmentNo()));
assertEquals(agreement,f.store.currentAgreement(original.batchKey(),"B"));
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
f.assertAllocations(80,160,40);
}
}
@Test void transportAssociationChangingBeforeBatchLockRejectsBothGroupCancellationAndRescheduling() {
try(var f=new Fixture()) {
var original=f.create();var agreement=f.store.currentAgreement(original.batchKey(),"A");
var revisionCount=f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_revision",Integer.class);
when(f.transport.lockAndInspect(anyList(),eq(TRANSPORT))).thenReturn(new FbaAppointmentTransportPort.Scope(TRANSPORT,List.of(),List.of()));
when(f.transport.inspect(anyList())).thenReturn(new FbaAppointmentTransportPort.Scope("transport-v2",List.of(),List.of()));
for(var action:List.of("CANCEL","RESCHEDULE")) {
var key="raced-group-"+action;
var request=new GroupChange(key,agreement.version(),action,action.equals("RESCHEDULE")?confirmed("CHANGED-CONFIRMATION"):null,"仓库回复期间新关联排车",TRANSPORT);
conflict(()->f.receiving.changeGroup(OPERATOR,agreement.agreementKey(),request));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key=?",Integer.class,key));
assertEquals(original,f.batches.detail(original.appointmentNo()));
assertEquals(agreement,f.store.agreement(agreement.agreementKey(),false));
for(var customer:List.of("A","B","D")) assertEquals(agreement,f.store.currentAgreement(original.batchKey(),customer));
assertEquals(revisionCount,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_revision",Integer.class));
f.assertAllocations(80,160,40);
}
verify(f.transport,times(2)).lockAndInspect(eq(List.of(original.batchKey())),eq(TRANSPORT));
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
}
}
@Test void customerPlanChangesOnlyUnconfirmedCustomerWhileAnotherConfirmationAndUnknownDateRemain() {
try(var f=new Fixture()) {
var original=f.createPending(null);
var booked=f.receiving.book(OPERATOR,new Book("confirm-a-before-plan",List.of(new Booking(selections(original,"A"),confirmed("A-ONLY-PLAN"),"A取得确认")),null,null,TRANSPORT));
var batch=booked.batches().get(0);var agreement=booked.agreements().get(0);
var target=LocalDate.of(2026,11,3);
var result=f.plan(batch,"B",planned(target,"PENDING",null),"plan-b");
assertEquals("APPLIED",result.status());
assertEquals(agreement,f.store.currentAgreement(batch.batchKey(),"A"));
assertEquals(target,f.store.plan(batch.batchKey(),"B").details().plannedDate());
assertNull(f.store.plan(batch.batchKey(),"D").details().plannedDate());
assertNull(f.store.plan(batch.batchKey(),"A"));
assertNull(result.batches().get(0).plannedDate());assertNull(result.batches().get(0).confirmationNo());
assertEquals(1,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_agreement",Integer.class));
assertEquals(2,f.receiving.context(batch.batchKey()).plans().size());
assertTrue(f.receiving.context(batch.batchKey()).allowedActions().contains("PLAN_RECEIVING"));
var current=result.batches().get(0);
conflict(()->f.batches.update(OPERATOR,current.appointmentNo(),new UpdateFbaAppointmentRequest(planned(null,"PENDING",null),current.version(),"legacy-plan-overwrite")));
conflict(()->f.batches.delete(OPERATOR,current.appointmentNo(),new CancelFbaAppointmentRequest(current.version(),"不能整批取消客户计划")));
f.assertAllocations(80,160,40);
}
}
@Test void failedCustomerPlanMayHaveNoDateButCannotSmuggleFormalConfirmationFields() {
try(var f=new Fixture()) {
var original=f.createPending(null);
var saved=f.plan(original,"B",planned(null,"FAILED","仓库暂无时段"),"failed-plan");
var current=saved.batches().get(0);var plan=f.store.plan(original.batchKey(),"B");
assertEquals("FAILED",plan.details().appointmentStatus());assertNull(plan.details().plannedDate());
assertTrue(saved.agreements().isEmpty());
var invalid=new java.util.ArrayList<FbaAppointmentDetails>();
invalid.add(confirmed("NOT-A-PLAN"));invalid.add(planned(null,"FAILED"," "));
for(var number:List.of(""," ","EXTERNAL")) invalid.add(new FbaAppointmentDetails(null,null,null,number,null,null,"PENDING",null,null,null));
invalid.add(new FbaAppointmentDetails(null,null,null,null,LocalDateTime.of(2026,10,20,10,0),null,"PENDING",null,null,null));
int index=0;
for(var details:invalid) {
var key="bad-plan-"+(index++);
assertEquals(400,assertThrows(ResponseStatusException.class,()->f.plan(current,"B",details,key)).getStatusCode().value());
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key=?",Integer.class,key));
}
assertEquals(plan,f.store.plan(original.batchKey(),"B"));assertEquals(current,f.batches.detail(original.appointmentNo()));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_agreement",Integer.class));
f.assertAllocations(80,160,40);
}
}
@Test void customerPlanReplayAndSemanticNoopKeepVersionAndStaleWritesAreRejected() {
try(var f=new Fixture()) {
var original=f.createPending(null);var details=planned(LocalDate.of(2026,11,4),"PENDING","等待回复");
var request=new Plan("plan-idempotent",new Selection(original.batchKey(),original.version(),"B"),details,TRANSPORT);
var saved=f.receiving.plan(OPERATOR,request);var current=saved.batches().get(0);
assertEquals(saved,f.receiving.plan(OPERATOR,request));
var noop=f.plan(current,"B",details,"plan-noop");
assertEquals("UNCHANGED",noop.status());assertEquals(current,noop.batches().get(0));
assertEquals(current,f.batches.detail(original.appointmentNo()));
assertEquals(3,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_plan",Integer.class));
conflict(()->f.plan(original,"B",planned(null,"PENDING",null),"stale-plan"));
conflict(()->f.plan(original,"B",planned(null,"PENDING",null),"plan-idempotent"));
assertEquals(details,f.store.plan(original.batchKey(),"B").details());
}
}
@Test void customerPlanRechecksExecutionScopeAndRejectsCurrentConfirmationOrPendingChange() {
try(var f=new Fixture()) {
var original=f.createPending(null);var member=new FbaAppointmentExecutionPort.Member(original.batchKey(),"B");
var eligibility=new FbaAppointmentExecutionPort.Eligibility(member,false,true,null);
when(f.execution.inspect(eq(List.of(member)))).thenReturn(new FbaAppointmentExecutionPort.Scope("execution-v2",List.of(eligibility)));
conflict(()->f.plan(original,"B",planned(null,"PENDING",null),"raced-plan"));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_plan",Integer.class));
when(f.execution.inspect(eq(List.of(member)))).thenReturn(new FbaAppointmentExecutionPort.Scope(TRANSPORT,List.of(eligibility)));
f.propose(original,100,"pending-before-plan");
conflict(()->f.plan(original,"B",planned(null,"PENDING",null),"pending-plan"));
assertEquals(original,f.batches.detail(original.appointmentNo()));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_plan",Integer.class));
}
try(var f=new Fixture()) {
var original=f.create();var agreement=f.store.currentAgreement(original.batchKey(),"B");
conflict(()->f.plan(original,"B",planned(null,"PENDING",null),"confirmed-plan"));
assertEquals(agreement,f.store.currentAgreement(original.batchKey(),"B"));
assertEquals(original,f.batches.detail(original.appointmentNo()));
}
}
@Test void bookingFreezesOtherCustomersOriginalTargetAndDeletesOnlyBookedPlan() {
try(var f=new Fixture()) {
var target=LocalDate.of(2026,10,25);var original=f.createPending(target);
var a=f.receiving.book(OPERATOR,new Book("freeze-a",List.of(new Booking(selections(original,"A"),confirmed("FROZEN-A"),"A确认")),null,null,TRANSPORT));
assertNull(f.store.plan(original.batchKey(),"A"));
assertEquals(target,f.store.plan(original.batchKey(),"B").details().plannedDate());
assertEquals(target,f.store.plan(original.batchKey(),"D").details().plannedDate());
var updated=f.plan(a.batches().get(0),"B",planned(target.plusDays(1),"PENDING",null),"adjust-b-plan").batches().get(0);
var b=f.receiving.book(OPERATOR,new Book("freeze-b",List.of(new Booking(selections(updated,"B"),confirmed("FROZEN-B"),"B确认")),null,null,TRANSPORT));
assertNull(f.store.plan(original.batchKey(),"B"));
var d=f.store.plan(original.batchKey(),"D");assertEquals(target,d.details().plannedDate());
f.receiving.changeGroup(OPERATOR,a.agreements().get(0).agreementKey(),new GroupChange("cancel-a-only",a.agreements().get(0).version(),"CANCEL",null,"仅A取消",TRANSPORT));
assertEquals(d,f.store.plan(original.batchKey(),"D"));
assertEquals(b.agreements().get(0),f.store.currentAgreement(original.batchKey(),"B"));
f.assertAllocations(80,160,40);
}
}
@Test void repartitionUsesExplicitNewDatesAndDoesNotTreatHistoricalCustomerPlansAsCurrent() {
try(var f=new Fixture()) {
var original=f.createPending(LocalDate.of(2026,10,25));
var current=f.plan(original,"B",planned(LocalDate.of(2026,10,26),"PENDING",null),"before-split-plan").batches().get(0);
assertTrue(f.batches.batchContext(current.batchKey()).allowedActions().contains("SPLIT"));
var nextDate=LocalDate.of(2026,11,2);
var split=f.batches.repartition(OPERATOR,new RepartitionFbaAppointmentsRequest(List.of(new RepartitionFbaAppointmentsRequest.Original(current.appointmentNo(),current.version())),List.of(
new RepartitionFbaAppointmentsRequest.Batch(nextDate,null,List.of(new RepartitionFbaAppointmentsRequest.Allocation("A","ORDER:1",80,"ORDER:1",1),new RepartitionFbaAppointmentsRequest.Allocation("B","ORDER:2",100,"ORDER:1",1),new RepartitionFbaAppointmentsRequest.Allocation("D","ORDER:3",40,"ORDER:1",1))),laterBatch(60)),"拆分使用本次新目标","plan-repartition"));
assertEquals(nextDate,split.appointments().get(0).plannedDate());assertNull(split.appointments().get(1).plannedDate());
assertEquals(3,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_plan WHERE batch_key=?",Integer.class,current.batchKey()));
assertTrue(f.store.snapshots(List.of(current.batchKey())).get(0).plans().isEmpty());
for(var batch:split.appointments()) assertTrue(f.store.plans(batch.batchKey()).isEmpty());
f.assertAllocations(80,160,40);
}
}
@Test void partialMoveKeepsRemainingCustomerPlanAndFullMoveClearsOnlyItsOldPlan() {
try(var f=new Fixture()) {
var original=f.createPending(LocalDate.of(2026,10,25));
var current=f.plan(original,"B",planned(LocalDate.of(2026,10,27),"PENDING",null),"move-plan-b").batches().get(0);
var b=f.store.plan(current.batchKey(),"B");var a=f.store.plan(current.batchKey(),"A");var d=f.store.plan(current.batchKey(),"D");
var partial=f.confirm(f.propose(current,100,"plan-partial").changeKey(),"plan-partial-confirm");
assertEquals(b,f.store.plan(current.batchKey(),"B"));
assertEquals(LocalDate.of(2026,10,23),partial.batches().get(1).plannedDate());
var all=f.confirm(f.propose(partial.batches().get(0),0,"plan-full").changeKey(),"plan-full-confirm");
assertNull(f.store.plan(current.batchKey(),"B"));
assertEquals(a,f.store.plan(current.batchKey(),"A"));assertEquals(d,f.store.plan(current.batchKey(),"D"));
assertEquals(100,total(all.batches().get(1)));f.assertAllocations(80,160,40);
}
}
@Test void customerPlanRejectsStaleSourceWithoutChangingFrozenPlansOrQuantity() {
try(var f=new Fixture()) {
var original=f.createPending(null);
var changed=new AppointmentSourceService.Source("ORDER:2","B",2L,"IN-B",null,null,"100","美国仓",DESTINATION,"ONT8",160,"ORDER:2",true,List.of());
when(f.context.getBean(AppointmentSourceService.class).resolveBatch(anyList())).thenReturn(List.of(changed));
conflict(()->f.plan(original,"B",planned(LocalDate.of(2026,10,25),"PENDING",null),"stale-plan-source"));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_plan",Integer.class));
assertEquals(original,f.batches.detail(original.appointmentNo()));f.assertAllocations(80,160,40);
}
}
@Test void emptyDatePlanAllowsMissingTimezoneButNewDateRequiresAuthoritativeIanaZone() {
try(var f=new Fixture()) {
var original=f.createPending(null);when(f.context.getBean(AppointmentSourceService.class).destinationTimezone("ONT8")).thenReturn(null);
assertEquals(400,assertThrows(ResponseStatusException.class,()->f.plan(original,"B",planned(LocalDate.of(2026,10,25),"PENDING",null),"missing-zone-date")).getStatusCode().value());
var saved=f.plan(original,"B",planned(null,"FAILED","待补仓库时区及回复"),"missing-zone-undated");
assertNull(f.store.plan(original.batchKey(),"B").timezone());
assertNull(saved.batches().get(0).plannedAt());
when(f.context.getBean(AppointmentSourceService.class).destinationTimezone("ONT8")).thenReturn("America/Los_Angeles");
var dated=f.plan(saved.batches().get(0),"B",planned(LocalDate.of(2026,10,25),"PENDING",null),"zone-restored-date");
assertNull(f.store.plan(original.batchKey(),"B").details().plannedAt());
assertEquals(LocalDate.of(2026,10,25),f.store.plan(original.batchKey(),"B").details().plannedDate());
assertEquals("APPLIED",dated.status());
}
}
@Test void customerPlanValidatesDstGapAmbiguityOffsetsAndCrossMidnightWithoutInventingTimes() {
try(var f=new Fixture()) {
var original=f.createPending(null);
var gap=LocalDateTime.of(2026,3,8,2,30);var overlap=LocalDateTime.of(2026,11,1,1,30);
var invalid=List.of(new FbaAppointmentDetails(gap.toLocalDate(),gap,null,null,null,null,"PENDING",null,null,null),
new FbaAppointmentDetails(overlap.toLocalDate(),overlap,null,null,null,null,"PENDING",null,null,null),
new FbaAppointmentDetails(overlap.toLocalDate(),null,null,null,null,null,"PENDING",null,"-07:00",null));
int index=0;for(var details:invalid) {var key="invalid-plan-time-"+(index++);assertEquals(400,assertThrows(ResponseStatusException.class,()->f.plan(original,"B",details,key)).getStatusCode().value());}
var explicit=new FbaAppointmentDetails(overlap.toLocalDate(),overlap,overlap.plusMinutes(15),null,null,null,"PENDING",null,"-07:00","-07:00");
var saved=f.plan(original,"B",explicit,"explicit-plan-offset").batches().get(0);
assertEquals(explicit,f.store.plan(original.batchKey(),"B").details());
var start=LocalDateTime.of(2026,11,3,23,30);var across=new FbaAppointmentDetails(start.toLocalDate(),start,start.plusHours(2),null,null,null,"PENDING",null,null,null);
f.plan(saved,"B",across,"cross-midnight-plan");
assertEquals(start.plusHours(2),f.store.plan(original.batchKey(),"B").details().plannedEndAt());
}
}
@Test void adoptingNewVerificationRestoresOnlyUnconfirmedCustomerWithoutChangingOtherAgreementPlanOrQuantity() {
try(var f=new Fixture()) {
var original=f.createPending(LocalDate.of(2026,10,25));
var a=f.receiving.book(OPERATOR,new Book("a-before-refresh",List.of(new Booking(selections(original,"A"),confirmed("A-BEFORE-REFRESH"),"A确认")),null,null,TRANSPORT));
var batch=a.batches().get(0);var plans=f.store.plans(batch.batchKey());
var otherItems=batch.items().stream().filter(i->!i.customerId().equals("B")).toList();
f.verifyB(1,"verify-b-v2","B-NEW-SHIPMENT");
conflict(()->f.plan(batch,"B",planned(LocalDate.of(2026,11,5),"PENDING",null),"before-refresh-plan"));
conflict(()->f.receiving.book(OPERATOR,new Book("before-refresh-book",List.of(new Booking(selections(batch,"B"),confirmed("B-BEFORE-REFRESH"),"B确认")),null,null,TRANSPORT)));
var result=f.refresh(batch,"B",List.of(new CargoVersion("ORDER:2",DESTINATION,"ORDER:1",2)),"adopt-b-v2");var refreshed=result.batches().get(0);
assertEquals("APPLIED",result.status());assertEquals(batch.batchKey(),refreshed.batchKey());
assertEquals(batch.version()+1,refreshed.version());
assertEquals(otherItems,refreshed.items().stream().filter(i->!i.customerId().equals("B")).toList());
var b=refreshed.items().stream().filter(i->i.customerId().equals("B")).findFirst().orElseThrow();
assertEquals(2,b.cargoVerificationVersion());assertEquals(List.of("B-NEW-SHIPMENT"),b.shipmentReferences());assertEquals(160,b.packageCount());
assertEquals(plans,f.store.plans(batch.batchKey()));assertEquals(a.agreements().get(0),f.store.currentAgreement(batch.batchKey(),"A"));
var plannedBatch=f.plan(refreshed,"B",planned(LocalDate.of(2026,11,5),"PENDING",null),"after-refresh-plan").batches().get(0);
var booked=f.receiving.book(OPERATOR,new Book("after-refresh-book",List.of(new Booking(selections(plannedBatch,"B"),confirmed("B-AFTER-REFRESH"),"B确认")),null,null,TRANSPORT));
assertEquals("APPLIED",booked.status());assertEquals(a.agreements().get(0),f.store.currentAgreement(batch.batchKey(),"A"));
assertEquals(plans.stream().filter(p->p.customerId().equals("D")).findFirst().orElseThrow(),f.store.plan(batch.batchKey(),"D"));
f.assertAllocations(80,160,40);
}
}
@Test void cargoAdoptionUsesExactExpectedVersionsAndPersistentIdempotencyWithoutRepeatedChanges() {
try(var f=new Fixture()) {
var original=f.createPending(null);f.verifyB(1,"v2-for-replay","B-V2");
var versions=List.of(new CargoVersion("ORDER:2",DESTINATION,"ORDER:1",2));
var request=new RefreshCargo("adopt-replay",new Selection(original.batchKey(),original.version(),"B"),versions,TRANSPORT);
var saved=f.receiving.refreshCargo(OPERATOR,request);var current=saved.batches().get(0);
assertEquals(saved,f.receiving.refreshCargo(OPERATOR,request));
var noop=f.refresh(current,"B",versions,"adopt-noop");assertEquals("UNCHANGED",noop.status());assertEquals(current,noop.batches().get(0));
conflict(()->f.refresh(current,"B",List.of(new CargoVersion("ORDER:2",DESTINATION,"ORDER:1",1)),"stale-adopt-version"));
conflict(()->f.refresh(original,"B",versions,"stale-adopt-batch"));
conflict(()->f.refresh(original,"B",List.of(new CargoVersion("ORDER:2",DESTINATION,"ORDER:1",1)),"adopt-replay"));
for(var key:List.of("stale-adopt-version","stale-adopt-batch")) assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key=?",Integer.class,key));
assertEquals(current,f.batches.detail(original.appointmentNo()));f.assertAllocations(80,160,40);
}
}
@Test void cargoAdoptionRejectsMissingDuplicateForeignSourcesAndDestinationSubstitution() {
try(var f=new Fixture()) {
var original=f.createPending(null);f.verifyB(1,"v2-for-scope","B-V2");
List<List<CargoVersion>> invalid=List.of(List.of(),List.of(new CargoVersion("ORDER:1",DESTINATION,"ORDER:1",2)),
List.of(new CargoVersion("ORDER:2","FBA:LAX9","ORDER:1",2)),List.of(new CargoVersion("ORDER:2",DESTINATION,"ORDER:1",2),new CargoVersion("ORDER:2",DESTINATION,"ORDER:1",2)));
int index=0;for(var versions:invalid) {
var key="invalid-adopt-"+(index++);assertThrows(ResponseStatusException.class,()->f.refresh(original,"B",versions,key));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key=?",Integer.class,key));
}
assertEquals(original,f.batches.detail(original.appointmentNo()));f.assertAllocations(80,160,40);
}
}
@Test void cargoAdoptionCannotReduceAllocationEvenWhenLatestEvidenceClaimsLessQuantity() {
try(var f=new Fixture()) {
var original=f.createPending(null);var verification=f.context.getBean(AppointmentCargoVerificationService.class);
conflict(()->verification.save("ORDER:2","B",new AppointmentCargoVerificationService.Save(DESTINATION,"ORDER:1",1,159,List.of("B-LOW"),null,"bad-lower-total"),OPERATOR,"客服"));
f.verifyB(1,"v2-for-count-guard","B-V2");
// Corrupt the fixture's latest authoritative count to prove the core independently guards allocation totals.
f.db.update("UPDATE ll_appointment_cargo_verification SET package_count=159 WHERE customer_id='B' AND version=2");
conflict(()->f.refresh(original,"B",List.of(new CargoVersion("ORDER:2",DESTINATION,"ORDER:1",2)),"adopt-too-small"));
assertEquals(original,f.batches.detail(original.appointmentNo()));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key='adopt-too-small'",Integer.class));
f.assertAllocations(80,160,40);
}
}
@Test void cargoAdoptionAfterSourceChangeRequiresFreshVerificationAndRejectsChangedIdentity() {
try(var f=new Fixture()) {
var original=f.createPending(null);
var changed=new AppointmentSourceService.Source("ORDER:2","B",2L,"IN-B",null,null,"100","美国仓",DESTINATION,"ONT8",160,"ORDER:2",true,List.of());
when(f.context.getBean(AppointmentSourceService.class).resolveBatch(anyList())).thenReturn(List.of(changed));
conflict(()->f.refresh(original,"B",List.of(new CargoVersion("ORDER:2",DESTINATION,"ORDER:2",1)),"adopt-before-reverify"));
f.context.getBean(AppointmentCargoVerificationService.class).save("ORDER:2","B",new AppointmentCargoVerificationService.Save(DESTINATION,"ORDER:2",1,160,List.of("B-SOURCE-V2"),null,"source-v2-verification"),OPERATOR,"客服");
var refreshed=f.refresh(original,"B",List.of(new CargoVersion("ORDER:2",DESTINATION,"ORDER:2",2)),"adopt-source-v2").batches().get(0);
assertEquals("ORDER:2",refreshed.items().stream().filter(i->i.customerId().equals("B")).findFirst().orElseThrow().sourceVersion());
var wrongCustomer=new AppointmentSourceService.Source("ORDER:2","X",2L,"IN-B",null,null,"100","美国仓",DESTINATION,"ONT8",160,"ORDER:2",true,List.of());
when(f.context.getBean(AppointmentSourceService.class).resolveBatch(anyList())).thenReturn(List.of(wrongCustomer));
conflict(()->f.refresh(refreshed,"B",List.of(new CargoVersion("ORDER:2",DESTINATION,"ORDER:2",null)),"adopt-wrong-customer"));
assertEquals(refreshed,f.batches.detail(original.appointmentNo()));f.assertAllocations(80,160,40);
}
}
@Test void cargoAdoptionRejectsExecutionRaceCompletedConfirmedAndPendingCustomer() {
try(var f=new Fixture()) {
var original=f.createPending(null);f.verifyB(1,"v2-execution-adopt","B-V2");
var versions=List.of(new CargoVersion("ORDER:2",DESTINATION,"ORDER:1",2));
var member=new FbaAppointmentExecutionPort.Member(original.batchKey(),"B");
when(f.execution.inspect(eq(List.of(member)))).thenReturn(new FbaAppointmentExecutionPort.Scope("execution-raced",List.of(new FbaAppointmentExecutionPort.Eligibility(member,false,true,null))));
conflict(()->f.refresh(original,"B",versions,"raced-cargo-adopt"));
when(f.execution.inspect(eq(List.of(member)))).thenReturn(new FbaAppointmentExecutionPort.Scope(TRANSPORT,List.of(new FbaAppointmentExecutionPort.Eligibility(member,true,false,"已完成"))));
conflict(()->f.refresh(original,"B",versions,"completed-cargo-adopt"));
when(f.execution.inspect(eq(List.of(member)))).thenReturn(new FbaAppointmentExecutionPort.Scope(TRANSPORT,List.of(new FbaAppointmentExecutionPort.Eligibility(member,false,true,null))));
f.propose(original,100,"pending-cargo-adopt");
conflict(()->f.refresh(original,"B",versions,"pending-cargo-refresh"));
assertEquals(original,f.batches.detail(original.appointmentNo()));f.assertAllocations(80,160,40);
}
try(var f=new Fixture()) {
var original=f.create();var group=f.store.currentAgreement(original.batchKey(),"B");f.verifyB(1,"v2-confirmed-adopt","B-V2");
conflict(()->f.refresh(original,"B",List.of(new CargoVersion("ORDER:2",DESTINATION,"ORDER:1",2)),"confirmed-cargo-adopt"));
assertEquals(group,f.store.currentAgreement(original.batchKey(),"B"));assertEquals(original,f.batches.detail(original.appointmentNo()));
}
}
@Test void cargoAdoptionPersistenceFailureRollsBackItemAndCommandAndSameRequestCanRetry() {
try(var f=new Fixture()) {
var original=f.createPending(null);f.verifyB(1,"v2-adopt-failure","B-V2");
var versions=List.of(new CargoVersion("ORDER:2",DESTINATION,"ORDER:1",2));
f.db.execute("ALTER TABLE ll_inbound_fba_appointment_item ADD CONSTRAINT reject_adopt_v2 CHECK(cargo_verification_version<>2)");
assertThrows(RuntimeException.class,()->f.refresh(original,"B",versions,"adopt-db-retry"));
assertEquals(original,f.batches.detail(original.appointmentNo()));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key='adopt-db-retry'",Integer.class));
f.db.execute("ALTER TABLE ll_inbound_fba_appointment_item DROP CONSTRAINT reject_adopt_v2");
assertEquals("APPLIED",f.refresh(original,"B",versions,"adopt-db-retry").status());f.assertAllocations(80,160,40);
}
}
private static void conflict(org.junit.jupiter.api.function.Executable action) {
assertEquals(409,assertThrows(ResponseStatusException.class,action).getStatusCode().value());
}
private static int total(FbaAppointmentResponse batch) {return batch.items().stream().mapToInt(FbaAppointmentResponse.Item::packageCount).sum();}
private static int quantity(FbaAppointmentResponse batch,String customer) {return batch.items().stream().filter(i->i.customerId().equals(customer)).mapToInt(FbaAppointmentResponse.Item::packageCount).sum();}
private static FbaAppointmentDetails confirmed(String number) {
var start=LocalDateTime.of(2026,10,20,10,0);
return new FbaAppointmentDetails(start.toLocalDate(),null,null,number,start,start.plusHours(1),"SUCCESS","仓库已确认",null,null);
}
private static FbaAppointmentDetails planned(LocalDate date,String status,String remark) {return new FbaAppointmentDetails(date,null,null,null,null,null,status,remark,null,null);}
private static ChangeIntent intent(FbaAppointmentResponse batch,int retained) {
var item=batch.items().stream().filter(i->i.customerId().equals("B")).findFirst().orElseThrow();
return new ChangeIntent(batch.batchKey(),batch.version(),"B",List.of(new Quantity(item.sourceKey(),item.destinationKey(),retained,item.sourceVersion(),item.cargoVerificationVersion())),LocalDate.of(2026,10,23),"42",LocalDate.of(2026,10,19),"B客户部分后送",TRANSPORT);
}
private static List<Selection> selections(FbaAppointmentResponse batch,String... customers) {return java.util.Arrays.stream(customers).map(c->new Selection(batch.batchKey(),batch.version(),c)).toList();}
private static RepartitionFbaAppointmentsRequest.Batch laterBatch(int amount) {
return new RepartitionFbaAppointmentsRequest.Batch(null,null,List.of(new RepartitionFbaAppointmentsRequest.Allocation("B","ORDER:2",amount,"ORDER:1",1)));
}
private static final class Fixture implements AutoCloseable {
final AnnotationConfigApplicationContext context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class);
final InboundFbaAppointmentService batches=context.getBean(InboundFbaAppointmentService.class);
final FbaReceivingService receiving=context.getBean(FbaReceivingService.class);
final FbaReceivingStore store=context.getBean(FbaReceivingStore.class);
final FbaAppointmentTransportPort transport=context.getBean(FbaAppointmentTransportPort.class);
final FbaAppointmentWorkPort work=context.getBean(FbaAppointmentWorkPort.class);
final FbaAppointmentExecutionPort execution=context.getBean(FbaAppointmentExecutionPort.class);
final JdbcTemplate db=new JdbcTemplate(context.getBean(DataSource.class));
Fixture() {
db.update("INSERT INTO ll_order(id) VALUES(3)");
var sources=List.of(source("A",1,80),source("B",2,160),source("D",3,40));
when(context.getBean(AppointmentSourceService.class).resolveBatch(anyList())).thenAnswer(call->{
List<AppointmentCoverageService.SourceRef> refs=call.getArgument(0);
return refs.stream().map(ref->sources.stream().filter(s->s.customerId().equals(ref.customerId())&&s.sourceKey().equals(ref.sourceKey())&&s.destinationKey().equals(ref.destinationKey())).findFirst().orElseThrow()).toList();
});
var verification=context.getBean(AppointmentCargoVerificationService.class);
for(var source:sources) verification.save(source.sourceKey(),source.customerId(),new AppointmentCargoVerificationService.Save(DESTINATION,source.sourceVersion(),0,source.packages(),List.of("SHIP-"+source.customerId()),null,"verify-"+source.customerId()),OPERATOR,"客服");
}
FbaAppointmentResponse create() {
return batches.createFromSources(OPERATOR,new SourceFbaAppointmentRequest(List.of(
new SourceFbaAppointmentRequest.SourceRef("A","ORDER:1",DESTINATION),new SourceFbaAppointmentRequest.SourceRef("B","ORDER:2",DESTINATION),new SourceFbaAppointmentRequest.SourceRef("D","ORDER:3",DESTINATION)),"100","ONT8",confirmed("ORIGINAL-GROUP"),"create"));
}
FbaAppointmentResponse createPending(LocalDate date) {
return batches.createFromSources(OPERATOR,new SourceFbaAppointmentRequest(List.of(
new SourceFbaAppointmentRequest.SourceRef("A","ORDER:1",DESTINATION),new SourceFbaAppointmentRequest.SourceRef("B","ORDER:2",DESTINATION),new SourceFbaAppointmentRequest.SourceRef("D","ORDER:3",DESTINATION)),"100","ONT8",planned(date,"PENDING",null),"create-pending"));
}
Result plan(FbaAppointmentResponse batch,String customer,FbaAppointmentDetails details,String key) {return receiving.plan(OPERATOR,new Plan(key,new Selection(batch.batchKey(),batch.version(),customer),details,TRANSPORT));}
Result refresh(FbaAppointmentResponse batch,String customer,List<CargoVersion> versions,String key) {return receiving.refreshCargo(OPERATOR,new RefreshCargo(key,new Selection(batch.batchKey(),batch.version(),customer),versions,TRANSPORT));}
void verifyB(int version,String key,String shipment) {context.getBean(AppointmentCargoVerificationService.class).save("ORDER:2","B",new AppointmentCargoVerificationService.Save(DESTINATION,"ORDER:1",version,160,List.of(shipment),null,key),OPERATOR,"客服");}
Result propose(FbaAppointmentResponse batch,int retained,String key) {return receiving.propose(OPERATOR,new Propose(key,intent(batch,retained)));}
Result confirm(String changeKey,String key) {return receiving.resolve(OPERATOR,changeKey,new Resolve(key,0,"CONFIRM","仓库明确同意本次保留量和移出量",TRANSPORT));}
Result cancel(Agreement group) {return receiving.changeGroup(OPERATOR,group.agreementKey(),new GroupChange("cancel-group",group.version(),"CANCEL",null,"仓库取消本份预约",TRANSPORT));}
void assertAllocations(int a,int b,int d) {
for(var entry:java.util.Map.of("A",a,"B",b,"D",d).entrySet()) assertEquals(entry.getValue(),db.queryForObject("SELECT SUM(package_count) FROM ll_inbound_fba_appointment_item WHERE customer_id=? AND active=1",Integer.class,entry.getKey()));
}
@Override public void close() {context.close();}
private static AppointmentSourceService.Source source(String customer,long id,int packages) {
return new AppointmentSourceService.Source("ORDER:"+id,customer,id,"IN-"+customer,null,null,"100","美国仓",DESTINATION,"ONT8",packages,"ORDER:1",true,List.of());
}
}
}
@@ -33,6 +33,8 @@ class InboundFbaAppointmentServiceTest {
headers=mock(InboundFbaAppointmentMapper.class);items=mock(InboundFbaAppointmentItemMapper.class);query=mock(InboundFbaAppointmentQueryMapper.class);
commands=mock(FbaAppointmentCommandMapper.class);sources=mock(AppointmentSourceService.class);flags=mock(AppointmentBatchPreviewMapper.class);
service=new InboundFbaAppointmentService();
ReflectionTestUtils.setField(service,"receiving",mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class));
ReflectionTestUtils.setField(service,"receivingWork",mock(com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort.class));
ReflectionTestUtils.setField(service,"workChanges",mock(com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges.class));
ReflectionTestUtils.setField(service,"verification",mock(AppointmentCargoVerificationService.class));
ReflectionTestUtils.setField(service,"appointmentMapper",headers);ReflectionTestUtils.setField(service,"itemMapper",items);ReflectionTestUtils.setField(service,"queryMapper",query);
@@ -141,6 +143,13 @@ class InboundFbaAppointmentServiceTest {
when(sources.destinationTimezone("ONT8")).thenReturn(null);
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("no-zone",confirmed(),"ORDER:1")));assertTrue(storedHeaders.isEmpty());
}
@Test void confirmedAppointmentCannotLoseItsConfirmationThroughOrdinaryFailedEdit() {
var original=service.createFromSources("operator",request("confirmed-before-failure",confirmed(),"ORDER:1"));
var attempt=new UpdateFbaAppointmentRequest(new FbaAppointmentDetails(null,null,null,null,null,null,"FAILED","仓库取消",null,null),original.version(),"bypass-cancel");
assertEquals(400,assertThrows(ResponseStatusException.class,()->service.update("operator",original.appointmentNo(),attempt)).getStatusCode().value());
assertEquals("SUCCESS",storedHeaders.get(0).getAppointmentStatus());assertEquals(original.confirmationNo(),storedHeaders.get(0).getConfirmationNo());
verify(headers,never()).update(any(InboundFbaAppointment.class),any());
}
@Test void confirmationRequiresActualShipmentReferencesAndKnownCargoWithoutFakingMaterialization() {
when(query.countMissingShipmentReferences(List.of(1L))).thenReturn(1L);
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("missing-materials",confirmed(),"ORDER:1")));
@@ -191,10 +200,14 @@ class InboundFbaAppointmentServiceTest {
verify(commands,never()).begin(anyString(),anyString(),anyString(),anyString(),any());
verify(headers,never()).update(any(InboundFbaAppointment.class),any());
}
@Test void cancellationPreservesConfirmedFactsAndHistoryButCannotRestoreOverAnotherActiveClaim() {
@Test void cancellationClearsEffectiveDatesButPreservesAllocationAndImmutableConfirmedHistory() throws Exception {
var result=service.createFromSources("operator",request("confirmed",confirmed(),"ORDER:1"));
service.delete("operator",result.appointmentNo(),new CancelFbaAppointmentRequest(0,"收货方取消时段"));
assertEquals("CANCELLED",storedHeaders.get(0).getAppointmentStatus());assertEquals(at,storedHeaders.get(0).getAppointmentAt());assertFalse(storedItems.get(0).getActive());
assertEquals("CANCELLED",storedHeaders.get(0).getAppointmentStatus());assertNull(storedHeaders.get(0).getAppointmentAt());assertNull(storedHeaders.get(0).getPlannedDate());assertTrue(storedItems.get(0).getActive());
var snapshot=journal.get("CANCEL:"+result.appointmentNo()+":0").responseJson();
var json=new ObjectMapper().findAndRegisterModules();
assertEquals("EXT-ONT8-1",json.readTree(snapshot).path("confirmationNo").asText());
assertEquals(json.valueToTree(at),json.readTree(snapshot).get("appointmentAt"));
assertEquals(result,service.createFromSources("operator",request("confirmed",confirmed(),"ORDER:1")));
when(query.allocation(anyString(),anyString(),nullable(Long.class),eq(result.id()))).thenReturn(new InboundFbaAppointmentQueryMapper.Allocation(10,1,0));
assertThrows(ResponseStatusException.class,()->service.update("operator",result.appointmentNo(),new UpdateFbaAppointmentRequest(confirmed(),1,"restore")));
@@ -6,6 +6,7 @@ import com.baiyue.lastleg.appointmentplanning.AppointmentSourceService.Resolutio
import com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import org.junit.jupiter.api.Test;
import static com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*;
import java.time.LocalDateTime;
import java.util.List;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
@@ -14,24 +15,169 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class AppointmentWorkDeliveryProviderTest {
@Test void confirmedCustomerIndependentFailedPlanAndUnplannedSiblingRemainSeparate() {
String key="FBA_APPOINTMENT:1",zone="America/Los_Angeles";
var appointments=mock(InboundFbaAppointmentService.class);var commitments=mock(AppointmentWorkCommitmentService.class);var receiving=receiving();
var batch=mock(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.class);
when(batch.id()).thenReturn(1L);when(batch.batchKey()).thenReturn(key);when(batch.appointmentStatus()).thenReturn("SUCCESS");
when(batch.plannedDate()).thenReturn(java.time.LocalDate.of(2026,10,19));when(batch.timezone()).thenReturn(zone);
var a=item();when(a.customerId()).thenReturn("A");var b=item();when(b.customerId()).thenReturn("B");var d=item();when(d.customerId()).thenReturn("D");
when(batch.items()).thenReturn(List.of(a,b,d));when(appointments.batchSnapshots(List.of(key))).thenReturn(List.of(batch));
var confirmedAt=LocalDateTime.of(2026,10,20,10,0);
var agreement=new Agreement("RA-A",1,"ACTIVE","FBA:ONT8",new com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails(null,null,null,"CONF-A",confirmedAt,null,"SUCCESS",null,null,null),zone,
List.of(new Member(key,"AP-1",1,"A",List.of(),null)),"正式确认");
var failedDate=java.time.LocalDate.of(2026,10,22);
var failed=new CustomerPlan(key,"B",planDetails(failedDate,"FAILED"),zone);
var staleA=new CustomerPlan(key,"A",planDetails(failedDate,"FAILED"),zone);
when(receiving.snapshots(List.of(key))).thenReturn(List.of(new BatchReceiving(key,List.of(agreement),List.of(),List.of(staleA,failed))));
when(commitments.impactTokens(anyList())).thenAnswer(call->{List<String> keys=call.getArgument(0);return keys.stream().collect(java.util.stream.Collectors.toMap(k->k,k->k+":0"));});
var provider=new AppointmentWorkDeliveryProvider(appointments,mock(AppointmentCargoVerificationService.class),commitments,mock(AppointmentWorkDeliveryMapper.class),mock(AppointmentWorkSourceMapper.class),receiving);
var requests=List.of("A","B","D").stream().map(c->new FactRequest("W"+c,Type.DELIVERY,Flow.TRANSFER,"1",key+"|CUSTOMER:"+c,key,null,List.of(key),List.of(),List.of(new SourceReference(c,"ORDER:1","FBA:ONT8")))).toList();
var facts=provider.read(requests);var aFact=facts.get(0);var bFact=facts.get(1);var dFact=facts.get(2);
assertEquals(confirmedAt.toLocalDate(),aFact.confirmed().date());assertNull(aFact.target());assertEquals(PlanStatus.CONFIRMED,aFact.planStatus());assertTrue(aFact.blockReasons().isEmpty());
assertEquals(failedDate,bFact.target().date());assertNull(bFact.confirmed());assertEquals(PlanStatus.TARGET_SET,bFact.planStatus());assertEquals("沿原批次重新预约",bFact.action().label());
assertEquals(PlanStatus.UNPLANNED,dFact.planStatus());assertNull(dFact.target());assertNull(dFact.confirmed());assertTrue(dFact.blockReasons().isEmpty());
var state=new com.baiyue.lastleg.appointmentwork.AppointmentWorkState();state.setWorkItemKey("WB");state.setType(Type.DELIVERY);state.setFlow(Flow.TRANSFER);
var projected=com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.project(state,bFact,new Actor("reader","reader",java.util.Set.of()),java.time.Clock.systemUTC());
assertEquals(failedDate,projected.calendarDate());assertEquals(DateBasis.TARGET,projected.dateBasis());
var cleared=new CustomerPlan(key,"B",planDetails(null,"PENDING"),zone);
when(receiving.snapshots(List.of(key))).thenReturn(List.of(new BatchReceiving(key,List.of(agreement),List.of(),List.of(cleared))));
var updated=provider.read(requests);
assertEquals(aFact.fingerprint(),updated.get(0).fingerprint());assertEquals(dFact.fingerprint(),updated.get(2).fingerprint());
assertEquals(PlanStatus.UNPLANNED,updated.get(1).planStatus());assertNull(updated.get(1).target());assertTrue(updated.get(1).blockReasons().isEmpty());
// An explicitly cleared customer plan wins even when the original shared header has a date.
when(receiving.snapshots(List.of(key))).thenReturn(List.of(new BatchReceiving(key,List.of(),List.of(),List.of(cleared))));
assertNull(provider.read(List.of(requests.get(1))).get(0).target());
when(receiving.snapshots(List.of(key))).thenReturn(List.of(new BatchReceiving(key,List.of(),List.of(),List.of())));
assertEquals(batch.plannedDate(),provider.read(List.of(requests.get(1))).get(0).target().date());
}
private static com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails planDetails(java.time.LocalDate date,String status) {
return new com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails(date,null,null,null,null,null,status,null,null,null);
}
@Test void customerExecutionExcludesRebookingAndFullBatchReadLimitAppliesBeforeCustomerFiltering() {
String key="FBA_APPOINTMENT:1";var at=LocalDateTime.parse("2026-10-17T12:00:00");
var appointments=mock(InboundFbaAppointmentService.class);var commitments=mock(AppointmentWorkCommitmentService.class);var deliveries=mock(AppointmentWorkDeliveryMapper.class);
var batch=mock(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.class);
var own=item();var other=item();when(other.customerId()).thenReturn("D");when(other.sourceKey()).thenReturn("ORDER:2");
when(batch.id()).thenReturn(1L);when(batch.batchKey()).thenReturn(key);when(batch.items()).thenReturn(List.of(own,other));when(batch.appointmentStatus()).thenReturn("PENDING");
when(appointments.batchSnapshots(List.of(key))).thenReturn(List.of(batch));
when(commitments.impactTokens(List.of(key+"|CUSTOMER:C"))).thenReturn(java.util.Map.of(key+"|CUSTOMER:C","C:0"));
var provider=new AppointmentWorkDeliveryProvider(appointments,mock(AppointmentCargoVerificationService.class),commitments,deliveries,mock(AppointmentWorkSourceMapper.class),receiving());
var request=new FactRequest("W",Type.DELIVERY,Flow.TRANSFER,"1",key+"|CUSTOMER:C",key,null,List.of(key),List.of(),refs());
var signed=new AppointmentWorkDeliveryMapper.BatchDelivery(key,"C","ORDER:1","FBA:ONT8",100,"DEPARTED","DL-C","DELIVERED",100,100,at);
var waiting=new AppointmentWorkDeliveryMapper.BatchDelivery(key,"D","ORDER:2","FBA:ONT8",100,"PENDING_DEPARTURE","DL-D","WAIT_DELIVERY",100,null,null);
when(deliveries.batches(List.of(key))).thenReturn(List.of(signed,waiting));
var complete=provider.read(List.of(request)).get(0);
assertEquals(ExecutionStatus.COMPLETED,complete.executionStatus());assertEquals(at,complete.actual().localOccurredAt());assertNull(complete.action());
var inTransit=new AppointmentWorkDeliveryMapper.BatchDelivery(key,"C","ORDER:1","FBA:ONT8",100,"DEPARTED","DL-C","WAIT_DELIVERY",100,null,null);
when(deliveries.batches(List.of(key))).thenReturn(List.of(inTransit,waiting));
var executing=provider.read(List.of(request)).get(0);
assertEquals(ExecutionStatus.IN_PROGRESS,executing.executionStatus());assertNull(executing.action());assertNull(executing.actual());assertFalse(executing.blockReasons().isEmpty());
var unexpected=new AppointmentWorkDeliveryMapper.BatchDelivery(key,"C","ORDER:999","FBA:ONT8",5,"DEPARTED","DL-X","DELIVERED",5,5,at);
when(deliveries.batches(List.of(key))).thenReturn(List.of(signed,unexpected));
assertNotEquals(ExecutionStatus.COMPLETED,provider.read(List.of(request)).get(0).executionStatus());
var truncated=new java.util.ArrayList<>(java.util.Collections.nCopies(1000,waiting));truncated.add(signed);
when(deliveries.batches(List.of(key))).thenReturn(truncated);
assertTrue(assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.read(List.of(request))).getReason().contains("完整读取上限"));
}
@Test void independentDatesAndPendingScopeUseOneBulkReadWithoutHeaderConfirmationOrSiblingVersionImpact() {
var appointments=mock(InboundFbaAppointmentService.class);var commitments=mock(AppointmentWorkCommitmentService.class);
var deliveries=mock(AppointmentWorkDeliveryMapper.class);var receiving=receiving();
var batch=mock(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.class);
when(batch.id()).thenReturn(1L);when(batch.batchKey()).thenReturn("FBA_APPOINTMENT:1");when(batch.appointmentStatus()).thenReturn("PENDING");
var a=item();when(a.customerId()).thenReturn("A");var b=item();when(b.customerId()).thenReturn("B");when(b.sourceKey()).thenReturn("ORDER:2");
when(batch.items()).thenReturn(List.of(a,b));when(appointments.batchSnapshots(List.of(batch.batchKey()))).thenReturn(List.of(batch));
var memberA=new Member(batch.batchKey(),"AP-1",1,"A",List.of(new Source("ORDER:1","FBA:ONT8",100,"v1",1,null)),null);
var memberB=new Member(batch.batchKey(),"AP-1",1,"B",List.of(new Source("ORDER:2","FBA:ONT8",100,"v1",1,null)),null);
var timeA=LocalDateTime.parse("2026-10-17T10:00:00");var timeB=timeA.plusDays(1);
var detailsA=new com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails(null,null,null,"CONF-A",timeA,null,"SUCCESS",null,null,null);
var detailsB=new com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails(null,null,null,"CONF-B",timeB,null,"SUCCESS",null,null,null);
var groupA=new Agreement("RA-A",1,"ACTIVE","FBA:ONT8",detailsA,"America/Los_Angeles",List.of(memberA),"确认A");
var groupB=new Agreement("RA-B",1,"ACTIVE","FBA:ONT8",detailsB,"America/Los_Angeles",List.of(memberB),"确认B");
var pending=new Pending("RC-B",0,"WAITING",new ChangeIntent(batch.batchKey(),1,"B",List.of(),timeB.toLocalDate().plusDays(1),"1",timeA.toLocalDate(),"后送", "scope"),List.of(memberB),List.of(groupB),new com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort.Followup("1","负责人",timeA.toLocalDate()));
var batchKey=batch.batchKey();
when(receiving.snapshots(List.of(batchKey))).thenReturn(List.of(new BatchReceiving(batchKey,List.of(groupA,groupB),List.of(pending),List.of())));
var requestA=new FactRequest("WA",Type.DELIVERY,Flow.TRANSFER,"1","A",batch.batchKey(),null,List.of(batch.batchKey()),List.of(),List.of(new SourceReference("A","ORDER:1","FBA:ONT8")));
var requestB=new FactRequest("WB",Type.DELIVERY,Flow.TRANSFER,"1","B",batch.batchKey(),null,List.of(batch.batchKey()),List.of(),List.of(new SourceReference("B","ORDER:2","FBA:ONT8")));
when(commitments.impactTokens(anyList())).thenAnswer(call->{List<String> keys=call.getArgument(0);return keys.stream().collect(java.util.stream.Collectors.toMap(k->k,k->k+":0"));});
var provider=new AppointmentWorkDeliveryProvider(appointments,mock(AppointmentCargoVerificationService.class),commitments,deliveries,mock(AppointmentWorkSourceMapper.class),receiving);
var facts=provider.read(List.of(requestA,requestB));
assertEquals(timeA.toLocalDate(),facts.get(0).confirmed().date());assertEquals(PlanStatus.CONFIRMED,facts.get(0).planStatus());assertTrue(facts.get(0).blockReasons().isEmpty());
assertEquals(timeB.toLocalDate(),facts.get(1).confirmed().date());assertEquals(PlanStatus.AWAITING_REPLY,facts.get(1).planStatus());assertTrue(facts.get(1).arranged());
assertEquals(List.of(new SourceReference("B","ORDER:2","FBA:ONT8")),((AppointmentTarget)facts.get(1).action().target()).sources());
assertFalse(facts.get(1).blockReasons().isEmpty());verify(receiving).snapshots(List.of(batch.batchKey()));verifyNoMoreInteractions(receiving);
// A shared confirmation's revision changes when another member changes; A's fact stays semantic.
var shared=new Agreement("RA-SHARED",9,"ACTIVE","FBA:ONT8",detailsA,"America/Los_Angeles",List.of(memberA,memberB),"共同确认");
when(receiving.snapshots(List.of(batchKey))).thenReturn(List.of(new BatchReceiving(batchKey,List.of(shared),List.of(),List.of())));
var unchanged=provider.read(List.of(requestA)).get(0);
assertEquals(facts.get(0).fingerprint(),unchanged.fingerprint());assertEquals(facts.get(0).impactToken(),unchanged.impactToken());
when(receiving.snapshots(List.of(batchKey))).thenReturn(List.of(new BatchReceiving(batchKey,List.of(),List.of(),List.of())));
when(batch.appointmentStatus()).thenReturn("SUCCESS");when(batch.appointmentAt()).thenReturn(timeA);
assertNull(provider.read(List.of(requestA)).get(0).confirmed());
}
private static List<SourceReference> refs() { return List.of(new SourceReference("C","ORDER:1","FBA:ONT8")); }
private static com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.Item item() {
var item=mock(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.Item.class);
when(item.customerId()).thenReturn("C");when(item.sourceKey()).thenReturn("ORDER:1");when(item.destinationKey()).thenReturn("FBA:ONT8");when(item.packageCount()).thenReturn(100);
return item;
}
private static com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving() {
var store=mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class);
when(store.snapshots(anyList())).thenAnswer(call->{List<String> keys=call.getArgument(0);return keys.stream().map(k->new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.BatchReceiving(k,List.of(),List.of(),List.of())).toList();});
return store;
}
@Test void cancelledBookingClearsDatesAndGuidesRebookingTheSameAllocatedBatch() {
var appointments=mock(InboundFbaAppointmentService.class);var commitments=mock(AppointmentWorkCommitmentService.class);
var provider=new AppointmentWorkDeliveryProvider(appointments,mock(AppointmentCargoVerificationService.class),commitments,mock(AppointmentWorkDeliveryMapper.class),mock(AppointmentWorkSourceMapper.class),receiving());
var batch=mock(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.class);
var ownItem=item();when(batch.items()).thenReturn(List.of(ownItem));when(batch.id()).thenReturn(1L);when(batch.batchKey()).thenReturn("FBA_APPOINTMENT:1");
when(batch.appointmentStatus()).thenReturn("CANCELLED");when(batch.appointmentAt()).thenReturn(LocalDateTime.parse("2026-10-15T11:00:00"));
when(appointments.batchSnapshots(List.of("FBA_APPOINTMENT:1"))).thenReturn(List.of(batch));
when(commitments.impactTokens(List.of("FBA_APPOINTMENT:1|CUSTOMER:C"))).thenReturn(java.util.Map.of("FBA_APPOINTMENT:1|CUSTOMER:C","cancelled:1"));
var request=new FactRequest("W",Type.DELIVERY,Flow.TRANSFER,"1",null,"FBA_APPOINTMENT:1",null,List.of(),List.of(),refs());
var fact=provider.read(List.of(request)).get(0);
assertTrue(fact.planExists());assertEquals(PlanStatus.UNPLANNED,fact.planStatus());
assertNull(fact.target());assertNull(fact.confirmed());assertNull(fact.actual());
assertEquals("沿原批次重新预约",fact.action().label());
assertEquals(new AppointmentTarget("1","FBA_APPOINTMENT:1",refs()),fact.action().target());
assertFalse(fact.blockReasons().isEmpty());
}
@Test void failedBookingKeepsItsTargetCalendarDateWithoutInventingConfirmation() {
var appointments=mock(InboundFbaAppointmentService.class);var commitments=mock(AppointmentWorkCommitmentService.class);
var provider=new AppointmentWorkDeliveryProvider(appointments,mock(AppointmentCargoVerificationService.class),commitments,mock(AppointmentWorkDeliveryMapper.class),mock(AppointmentWorkSourceMapper.class),receiving());
var batch=mock(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.class);
var ownItem=item();when(batch.items()).thenReturn(List.of(ownItem));when(batch.id()).thenReturn(1L);when(batch.batchKey()).thenReturn("FBA_APPOINTMENT:1");when(batch.appointmentStatus()).thenReturn("FAILED");
when(batch.plannedDate()).thenReturn(java.time.LocalDate.of(2026,10,18));when(batch.timezone()).thenReturn("America/Los_Angeles");
when(appointments.batchSnapshots(List.of("FBA_APPOINTMENT:1"))).thenReturn(List.of(batch));
when(commitments.impactTokens(List.of("FBA_APPOINTMENT:1|CUSTOMER:C"))).thenReturn(java.util.Map.of("FBA_APPOINTMENT:1|CUSTOMER:C","failed:1"));
var request=new FactRequest("W",Type.DELIVERY,Flow.TRANSFER,"1",null,"FBA_APPOINTMENT:1",null,List.of(),List.of(),refs());
var fact=provider.read(List.of(request)).get(0);
assertEquals(PlanStatus.TARGET_SET,fact.planStatus());assertNull(fact.confirmed());assertNull(fact.actual());
assertEquals("沿原批次重新预约",fact.action().label());assertFalse(fact.blockReasons().isEmpty());
var state=new com.baiyue.lastleg.appointmentwork.AppointmentWorkState();state.setWorkItemKey("W");state.setType(Type.DELIVERY);state.setFlow(Flow.TRANSFER);
var projected=com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.project(state,fact,new Actor("reader","reader",java.util.Set.of()),java.time.Clock.systemUTC());
assertEquals(java.time.LocalDate.of(2026,10,18),projected.calendarDate());assertEquals(DateBasis.TARGET,projected.dateBasis());
when(batch.plannedDate()).thenReturn(null);
assertEquals(PlanStatus.UNPLANNED,provider.read(List.of(request)).get(0).planStatus());
}
@Test void establishedFbaBatchesShareSnapshotsDeliveriesAndImpactReads() {
var appointments=mock(InboundFbaAppointmentService.class);var cargo=mock(AppointmentCargoVerificationService.class);
var commitments=mock(AppointmentWorkCommitmentService.class);var deliveries=mock(AppointmentWorkDeliveryMapper.class);var sources=mock(AppointmentWorkSourceMapper.class);
var requests=java.util.stream.IntStream.rangeClosed(1,200).mapToObj(i->new FactRequest("W"+i,Type.DELIVERY,Flow.TRANSFER,"1",null,"FBA_APPOINTMENT:"+i,null,List.of(),List.of(),List.of())).toList();
var requests=java.util.stream.IntStream.rangeClosed(1,200).mapToObj(i->new FactRequest("W"+i,Type.DELIVERY,Flow.TRANSFER,"1",null,"FBA_APPOINTMENT:"+i,null,List.of(),List.of(),refs())).toList();
var keys=requests.stream().map(FactRequest::batchKey).toList();
var snapshots=keys.stream().map(key->{
var snapshot=mock(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.class);
when(snapshot.batchKey()).thenReturn(key);when(snapshot.id()).thenReturn(Long.valueOf(key.substring(16)));when(snapshot.appointmentStatus()).thenReturn("PENDING");
var ownItem=item();when(snapshot.items()).thenReturn(List.of(ownItem));when(snapshot.batchKey()).thenReturn(key);when(snapshot.id()).thenReturn(Long.valueOf(key.substring(16)));when(snapshot.appointmentStatus()).thenReturn("PENDING");
return snapshot;
}).toList();
when(appointments.batchSnapshots(keys)).thenReturn(snapshots);
when(commitments.impactTokens(keys)).thenReturn(keys.stream().collect(java.util.stream.Collectors.toMap(k->k,k->k+":0")));
var result=new AppointmentWorkDeliveryProvider(appointments,cargo,commitments,deliveries,sources).read(requests);
var impactKeys=keys.stream().map(k->k+"|CUSTOMER:C").toList();when(commitments.impactTokens(impactKeys)).thenReturn(impactKeys.stream().collect(java.util.stream.Collectors.toMap(k->k,k->k+":0")));
var result=new AppointmentWorkDeliveryProvider(appointments,cargo,commitments,deliveries,sources,receiving()).read(requests);
assertEquals(200,result.size());assertTrue(result.stream().allMatch(f->f.planExists() && f.executionStatus()==ExecutionStatus.PENDING));
verify(appointments).batchSnapshots(keys);verify(deliveries).batches(keys);verify(commitments).impactTokens(keys);
verify(appointments).batchSnapshots(keys);verify(deliveries).batches(keys);verify(commitments).impactTokens(impactKeys);
verifyNoMoreInteractions(appointments,deliveries,commitments);verifyNoInteractions(cargo,sources);
when(appointments.batchSnapshots(keys)).thenReturn(List.of());
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->new AppointmentWorkDeliveryProvider(appointments,cargo,commitments,deliveries,sources).read(requests));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->new AppointmentWorkDeliveryProvider(appointments,cargo,commitments,deliveries,sources,receiving()).read(requests));
}
@Test void handledPrerequisiteCommitsTheCallerTransactionThroughTheRealCargoProxy() {
var sourceService=mock(com.baiyue.lastleg.appointmentplanning.AppointmentSourceService.class);
@@ -41,7 +187,7 @@ class AppointmentWorkDeliveryProviderTest {
var manager=new org.springframework.jdbc.datasource.DataSourceTransactionManager(ds);
var proxy=new org.springframework.aop.framework.ProxyFactory(cargo);proxy.setProxyTargetClass(true);
proxy.addAdvice(new org.springframework.transaction.interceptor.TransactionInterceptor(manager,new org.springframework.transaction.annotation.AnnotationTransactionAttributeSource()));
var provider=new AppointmentWorkDeliveryProvider(mock(InboundFbaAppointmentService.class),(AppointmentCargoVerificationService)proxy.getProxy(),mock(AppointmentWorkCommitmentService.class),mock(AppointmentWorkDeliveryMapper.class),mock(AppointmentWorkSourceMapper.class));
var provider=new AppointmentWorkDeliveryProvider(mock(InboundFbaAppointmentService.class),(AppointmentCargoVerificationService)proxy.getProxy(),mock(AppointmentWorkCommitmentService.class),mock(AppointmentWorkDeliveryMapper.class),mock(AppointmentWorkSourceMapper.class),receiving());
var jdbc=new org.springframework.jdbc.core.JdbcTemplate(ds);jdbc.execute("CREATE TABLE work_probe(id INT PRIMARY KEY)");
var request=new FactRequest("W",Type.DELIVERY,Flow.TRANSFER,"1","INBOUND:1",null,null,List.of(),List.of(),List.of(new SourceReference("C","ORDER:11",null)));
new org.springframework.transaction.support.TransactionTemplate(manager).executeWithoutResult(tx->{
@@ -52,7 +198,7 @@ class AppointmentWorkDeliveryProviderTest {
}
@Test void knownSourcePrerequisiteIsVisibleAndCannotOfferBookingButOutagesStillFail() {
var cargo=mock(AppointmentCargoVerificationService.class);var sources=mock(AppointmentWorkSourceMapper.class);
var provider=new AppointmentWorkDeliveryProvider(mock(InboundFbaAppointmentService.class),cargo,mock(AppointmentWorkCommitmentService.class),mock(AppointmentWorkDeliveryMapper.class),sources);
var provider=new AppointmentWorkDeliveryProvider(mock(InboundFbaAppointmentService.class),cargo,mock(AppointmentWorkCommitmentService.class),mock(AppointmentWorkDeliveryMapper.class),sources,receiving());
var request=new FactRequest("W",Type.DELIVERY,Flow.TRANSFER,"1","INBOUND:1",null,null,List.of(),List.of(),List.of(new SourceReference("C","ORDER:11",null)));
when(cargo.inspectBatch(List.of(new SourceRef("C","ORDER:11",null)))).thenReturn(List.of(new AppointmentCargoVerificationService.Inspection(new SourceRef("C","ORDER:11",null),null,new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST,"接仓点不在启用的美国仓目录中"))));
var fact=provider.read(List.of(request)).get(0);
@@ -63,7 +209,7 @@ class AppointmentWorkDeliveryProviderTest {
}
@Test void privateCompletionCoversThisDispatchPerSourceWithoutCompletingTheWholeOrder() {
var appointments=mock(InboundFbaAppointmentService.class);var deliveries=mock(AppointmentWorkDeliveryMapper.class);var sources=mock(AppointmentWorkSourceMapper.class);
var provider=new AppointmentWorkDeliveryProvider(appointments,mock(AppointmentCargoVerificationService.class),mock(AppointmentWorkCommitmentService.class),deliveries,sources);
var provider=new AppointmentWorkDeliveryProvider(appointments,mock(AppointmentCargoVerificationService.class),mock(AppointmentWorkCommitmentService.class),deliveries,sources,receiving());
when(sources.privateBatch("DISPATCH:20")).thenReturn(List.of(new AppointmentWorkSourceMapper.DispatchSource(20,"D-20","DEPARTED",11,"C","ORDER:11","CUSTOMER:11",4),new AppointmentWorkSourceMapper.DispatchSource(20,"D-20","DEPARTED",12,"C","ORDER:12","CUSTOMER:12",6)));
var time=LocalDateTime.parse("2026-10-15T11:00:00");
var one=new AppointmentWorkDeliveryMapper.CustomerDelivery(11,"C",4,"L-1","DELIVERED",10,10,time,time.minusDays(1));
@@ -11,9 +11,80 @@ import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class AppointmentWorkDispatchBindingTest {
@Test void referencesCombinePhysicalRowsWithoutLosingBatchCustomerOrSourceIdentity() throws Exception {
var mapper=mock(AppointmentWorkDispatchMapper.class);
var service=new AppointmentWorkDispatchBinding(mapper,mock(InboundFbaAppointmentService.class),mock(AppointmentSourceService.class),mock(AppointmentWorkChanges.class),mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class));
when(mapper.references(List.of(11L,12L))).thenReturn(List.of(
new AppointmentWorkDispatchMapper.ReferenceRow(11L,"FBA_APPOINTMENT:1","AP-1","A","ORDER:1","FBA:ONT8",40),
new AppointmentWorkDispatchMapper.ReferenceRow(11L,"FBA_APPOINTMENT:1","AP-1","A","ORDER:1","FBA:ONT8",20),
new AppointmentWorkDispatchMapper.ReferenceRow(11L,"FBA_APPOINTMENT:2",null,"A","ORDER:2","FBA:ONT8",10),
new AppointmentWorkDispatchMapper.ReferenceRow(12L,"DISPATCH:12","DP-12","B","ORDER:3","CUSTOMER:3",30)));
var result=service.references(List.of(12L,11L));assertEquals(2,result.get(11L).size());
assertEquals(60,result.get(11L).get(0).sources().get(0).packageCount());assertNull(result.get(11L).get(1).batchNo());
assertEquals("DP-12",result.get(12L).get(0).batchNo());assertEquals("B",result.get(12L).get(0).customerId());
verify(mapper).references(List.of(11L,12L));
var transaction=AppointmentWorkDispatchBinding.class.getMethod("references",List.class).getAnnotation(org.springframework.transaction.annotation.Transactional.class);
assertEquals(org.springframework.transaction.annotation.Propagation.SUPPORTS,transaction.propagation());assertTrue(transaction.readOnly());
when(mapper.references(List.of(11L))).thenReturn(java.util.Collections.nCopies(20001,new AppointmentWorkDispatchMapper.ReferenceRow(11L,"FBA_APPOINTMENT:1","AP-1","A","ORDER:1","FBA:ONT8",1)));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.references(List.of(11L)));
}
@Test void departureRechecksCurrentSourceAllocationAfterBatchQuantityOrMembershipChanges() {
var mapper=mock(AppointmentWorkDispatchMapper.class);var appointments=mock(InboundFbaAppointmentService.class);
var sources=mock(AppointmentSourceService.class);var changes=mock(AppointmentWorkChanges.class);
var receiving=mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class);
var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,receiving);
when(receiving.currentAgreement("FBA_APPOINTMENT:1","C")).thenReturn(agreement("C",4));
var row=new AppointmentWorkDispatchMapper.Bound(20,30,10,"FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",4);
when(mapper.bindings("D-20")).thenReturn(List.of(row));
when(mapper.lockAppointment(1)).thenReturn(new com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment());
var batch=mock(FbaAppointmentResponse.class);when(batch.batchKey()).thenReturn("FBA_APPOINTMENT:1");
when(batch.appointmentStatus()).thenReturn("SUCCESS");when(batch.appointmentAt()).thenReturn(java.time.LocalDateTime.of(2026,10,18,10,0));
when(batch.replacementBatchKeys()).thenReturn(List.of());
when(appointments.batchContext("FBA_APPOINTMENT:1")).thenReturn(new com.baiyue.lastleg.inboundplan.dto.FbaAppointmentBatchContext(batch,List.of(),List.of(),List.of(),List.of(batch),List.of()));
var item=mock(FbaAppointmentResponse.Item.class);when(item.customerId()).thenReturn("C");when(item.sourceKey()).thenReturn("ORDER:1");when(item.destinationKey()).thenReturn("FBA:ONT8");
when(item.packageCount()).thenReturn(3);when(batch.items()).thenReturn(List.of(item));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
when(item.packageCount()).thenReturn(4);when(mapper.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",20)).thenReturn(1L);
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
when(mapper.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",20)).thenReturn(0L);
when(item.customerId()).thenReturn("OTHER");
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
when(item.customerId()).thenReturn("C");when(item.destinationKey()).thenReturn("FBA:OTHER");
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
verify(mapper,never()).pickedPhysical(anyLong(),anyString());
when(item.destinationKey()).thenReturn("FBA:ONT8");
when(mapper.pickedPhysical(30,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:ONT8",null,4)));
when(sources.canonicalOrders(anyList())).thenReturn(List.of(new AppointmentCoverageService.SourceRef("C","ORDER:1",null)));
assertDoesNotThrow(()->service.requireDeparture("D-20"));
when(item.packageCount()).thenReturn(5);when(mapper.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",20)).thenReturn(1L);
assertTrue(service.departureBlockReason("D-20").contains("收货确认未完整覆盖")); // Batch has 5, but the current agreement covers only 4 across every dispatch.
when(item.packageCount()).thenReturn(4);when(mapper.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",20)).thenReturn(0L);
when(batch.appointmentStatus()).thenReturn("PENDING");
assertDoesNotThrow(()->service.requireDeparture("D-20")); // The current receiving agreement, not the old header, authorizes receipt.
when(receiving.hasPending("FBA_APPOINTMENT:1","OTHER")).thenReturn(true);
assertDoesNotThrow(()->service.requireDeparture("D-20"));
when(receiving.hasPending("FBA_APPOINTMENT:1","C")).thenReturn(true);
clearInvocations(mapper);
assertTrue(service.departureBlockReason("D-20").contains("待仓库确认"));verify(mapper,never()).lockAppointment(anyLong());
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
when(receiving.hasPending("FBA_APPOINTMENT:1","C")).thenReturn(false);
when(receiving.currentAgreement("FBA_APPOINTMENT:1","C")).thenReturn(agreement("OTHER",4));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
when(receiving.currentAgreement("FBA_APPOINTMENT:1","C")).thenReturn(agreement("C",3));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
when(receiving.currentAgreement("FBA_APPOINTMENT:1","C")).thenReturn(null);
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
verifyNoInteractions(changes);
}
private com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Agreement agreement(String customer,int packages) {
var details=new com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails(null,null,null,"CONF-1",java.time.LocalDateTime.of(2026,10,18,10,0),null,"SUCCESS",null,null,null);
var source=new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Source("ORDER:1","FBA:ONT8",packages,"v1",1,null);
var member=new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Member("FBA_APPOINTMENT:1","AP-1",1,customer,List.of(source),null);
return new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Agreement("RA-1",1,"ACTIVE","FBA:ONT8",details,"America/Los_Angeles",List.of(member),"仓库已确认");
}
@Test void groupedReplayCannotRewriteBatchBindingsAfterTheOriginalDispatchHasMovedOn() {
var mapper=mock(AppointmentWorkDispatchMapper.class);var changes=mock(AppointmentWorkChanges.class);
var service=new AppointmentWorkDispatchBinding(mapper,mock(InboundFbaAppointmentService.class),mock(AppointmentSourceService.class),changes);
var service=new AppointmentWorkDispatchBinding(mapper,mock(InboundFbaAppointmentService.class),mock(AppointmentSourceService.class),changes,mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class));
var row=new AppointmentWorkDispatchMapper.Bound(20,30,10,"FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",4);
when(mapper.byDispatch(20)).thenReturn(List.of(row));
var allocation=new AppointmentWorkDispatchPort.Allocation(10L,"FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",4);
@@ -23,7 +94,7 @@ class AppointmentWorkDispatchBindingTest {
}
@Test void privateDispatchBindsOriginalPhysicalSourceAndDepartureRequiresActualPickedQuantity() {
var mapper=mock(AppointmentWorkDispatchMapper.class);var appointments=mock(InboundFbaAppointmentService.class);var sources=mock(AppointmentSourceService.class);var changes=mock(AppointmentWorkChanges.class);
var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes);
var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class));
when(mapper.physical(10,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","CUSTOMER:1",4,4)));
when(sources.canonicalOrders(anyList())).thenReturn(List.of(new AppointmentCoverageService.SourceRef("C","ORDER:1",null)));
service.bind("C",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(30L,10L,"RECEIVED",4)),List.of());
@@ -48,16 +119,19 @@ class AppointmentWorkDispatchBindingTest {
@Test void emptyAllocationCannotBypassAnExistingBatchAtArrangeOrDeparture() {
var mapper=mock(AppointmentWorkDispatchMapper.class); var appointments=mock(InboundFbaAppointmentService.class);
var sources=mock(AppointmentSourceService.class); var changes=mock(AppointmentWorkChanges.class);
var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes);
var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class));
var part=new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:ONT8",4,4);
when(mapper.physical(10,"C")).thenReturn(List.of(part));
when(sources.canonicalOrders(anyList())).thenReturn(List.of(new AppointmentCoverageService.SourceRef("C","ORDER:1",null)));
var batch=mock(FbaAppointmentResponse.class);when(batch.appointmentStatus()).thenReturn("SUCCESS");when(batch.replacementBatchKeys()).thenReturn(List.of());
when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(batch));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.bind("C",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(30L,10L,"RECEIVED",4)),List.of()));
when(mapper.bindings("D-1")).thenReturn(List.of());
when(mapper.dispatchPhysical("D-1")).thenReturn(List.of(new AppointmentWorkDispatchMapper.DispatchPhysical(30,10L,"C","RECEIVED",4)));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-1"));
for(var status:List.of("SUCCESS","CANCELLED","FAILED")) {
when(batch.appointmentStatus()).thenReturn(status);
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.bind("C",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(30L,10L,"RECEIVED",4)),List.of()));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-1"));
}
verify(mapper,never()).insert(any()); verifyNoInteractions(changes);
}
}
@@ -39,6 +39,7 @@ class AppointmentWorkDomainMapperTest {
var dispatch=session.getMapper(AppointmentWorkDispatchMapper.class);
assertTrue(dispatch.candidates(1).isEmpty());assertTrue(dispatch.physical(1,"C").isEmpty());assertTrue(dispatch.pickedPhysical(1,"C").isEmpty());
assertTrue(dispatch.dispatchPhysical("D").isEmpty());assertTrue(dispatch.bindings("D").isEmpty());assertTrue(dispatch.byDispatch(1).isEmpty());assertTrue(dispatch.trips("FBA_APPOINTMENT:1").isEmpty());
assertTrue(dispatch.references(List.of(1L)).isEmpty());
assertEquals(0,dispatch.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",1));
var deliveries=session.getMapper(AppointmentWorkDeliveryMapper.class);assertTrue(deliveries.batch("FBA_APPOINTMENT:1").isEmpty());assertTrue(deliveries.customer("1","1").isEmpty());
assertTrue(deliveries.privateBatch("DISPATCH:1").isEmpty());
@@ -61,9 +62,13 @@ class AppointmentWorkDomainMapperTest {
assertEquals(403L,sessions.stream().filter(s->"1".equals(s.getSourceContainerBookingId())).findFirst().orElseThrow().getId());
try(var sql=session.getConnection().createStatement()) {
sql.execute("INSERT INTO ll_dispatch_order(id,customer_id,dispatch_no,status) VALUES(501,'A','D-1','PENDING_DEPARTURE'),(502,'B','D-2','CANCELLED'),(503,'B','D-3','PENDING_DEPARTURE')");
sql.execute("INSERT INTO ll_inbound_fba_appointment(id,appointment_no,origin_warehouse_id,fba_warehouse_code,created_by) VALUES(1,'AP-1','W','ONT8','operator'),(2,'AP-2','W','ONT8','operator')");
sql.execute("INSERT INTO ll_dispatch_appointment_allocation(dispatch_id,dispatch_item_id,plan_pallet_id,batch_key,customer_id,source_key,destination_key,package_count) VALUES(501,601,701,'FBA_APPOINTMENT:1','A','ORDER:101','FBA:ONT8',4),(501,602,702,'FBA_APPOINTMENT:1','B','ORDER:102','FBA:ONT8',6),(502,603,703,'FBA_APPOINTMENT:1','B','ORDER:102','FBA:ONT8',2),(503,604,704,'FBA_APPOINTMENT:2','B','ORDER:102','FBA:ONT8',3)");
}
session.clearCache();
var references=dispatch.references(List.of(501L,503L));
assertEquals(2,references.size());assertEquals("A",references.get(0).customerId());assertEquals("AP-1",references.get(0).batchNo());assertEquals(4,references.get(0).packages());
assertEquals("FBA_APPOINTMENT:2",references.get(1).batchKey());assertEquals("AP-2",references.get(1).batchNo()); // Wrong-customer rows on dispatch 501 must not leak into current references.
var batchDeliveries=deliveries.batches(List.of("FBA_APPOINTMENT:1","FBA_APPOINTMENT:2"));assertEquals(3,batchDeliveries.size());
assertEquals(deliveries.batch("FBA_APPOINTMENT:1"),batchDeliveries.stream().filter(d->d.batchKey().equals("FBA_APPOINTMENT:1")).map(AppointmentWorkDeliveryMapper.BatchDelivery::delivery).toList());
assertEquals(deliveries.batch("FBA_APPOINTMENT:2"),batchDeliveries.stream().filter(d->d.batchKey().equals("FBA_APPOINTMENT:2")).map(AppointmentWorkDeliveryMapper.BatchDelivery::delivery).toList());
@@ -11,6 +11,35 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
class AppointmentWorkRequirementProviderTest {
@Test void cancelledAndFailedBookingsRetainTheirAllocatedBatchAndOnlyAuthorityDefinesRemainder() {
var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class);
var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class);
var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping);
var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of());
var container=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement));
when(mapping.containerSources("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.Source(11L,"C","10","1","FBA","ONT8",60)));
when(sources.canonicalOrders(anyList())).thenReturn(List.of(new AppointmentCoverageService.SourceRef("C","ORDER:11",null)));
var source=new AppointmentSourceService.Source("ORDER:11","C",11L,"IN-11",null,"CNT",null,null,"FBA:ONT8","ONT8",60,"v1",true,List.of());
when(sources.resolveBatch(anyList())).thenReturn(List.of(source));
var allocation=mock(AppointmentCargoVerificationService.Context.class);
when(cargo.inspect(source)).thenReturn(allocation);when(allocation.availablePackages()).thenReturn(0L);
var batch=mock(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.class);
var item=mock(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.Item.class);
when(item.customerId()).thenReturn("C");when(item.sourceKey()).thenReturn("ORDER:11");when(item.destinationKey()).thenReturn("FBA:ONT8");
when(batch.batchKey()).thenReturn("FBA_APPOINTMENT:1");when(batch.appointmentNo()).thenReturn("AP-1");
when(batch.fbaWarehouseCode()).thenReturn("ONT8");when(batch.items()).thenReturn(List.of(item));when(batch.replacementBatchKeys()).thenReturn(List.of());
when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(batch));
for(var status:List.of("CANCELLED","FAILED")) {
when(batch.appointmentStatus()).thenReturn(status);
var result=provider.requirements(container,Flow.TRANSFER);
assertEquals(List.of("FBA_APPOINTMENT:1|CUSTOMER:C"),result.stream().map(Requirement::requirementKey).toList());
assertEquals(List.of(new SourceReference("C","ORDER:11","FBA:ONT8")),result.get(0).sources());
}
when(allocation.availablePackages()).thenReturn(20L);
assertEquals(List.of("INBOUND:1","FBA_APPOINTMENT:1|CUSTOMER:C"),provider.requirements(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList());
when(batch.replacementBatchKeys()).thenReturn(List.of("FBA_APPOINTMENT:2"));
assertEquals(List.of("INBOUND:1"),provider.requirements(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList());
}
@Test void unbookableSourceKeepsItsPhysicalRequirementWhileAuthorityFailuresStillAbort() {
var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class);
var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class);
@@ -0,0 +1,62 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.fleet.trip.*;
import com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentExecutionPort.*;
import org.junit.jupiter.api.Test;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class FbaAppointmentExecutionAdapterTest {
final String batch="FBA_APPOINTMENT:1";
final Member a=new Member(batch,"A"),b=new Member(batch,"B");
final AppointmentWorkDispatchMapper mapper=mock(AppointmentWorkDispatchMapper.class);
final FleetTripMapper trips=mock(FleetTripMapper.class);
final FleetDispatchGateway dispatches=mock(FleetDispatchGateway.class);
final FbaAppointmentExecutionAdapter adapter=new FbaAppointmentExecutionAdapter(mapper,trips,dispatches);
final Instant at=Instant.parse("2026-10-01T12:00:00Z");
FbaAppointmentExecutionAdapterTest() {
when(mapper.receivingSources(List.of(batch))).thenReturn(List.of(new AppointmentWorkDispatchMapper.ReceivingSource(batch,"A","ORDER:1","FBA:ONT8",100),new AppointmentWorkDispatchMapper.ReceivingSource(batch,"B","ORDER:2","FBA:ONT8",100)));
when(mapper.transportRows(List.of(batch),List.of())).thenReturn(List.of(row("A","DEPARTED","COMPLETED"),row("B","PENDING_DEPARTURE","PLANNED")));
when(dispatches.completion("D-A",false)).thenReturn(new FleetCompletionModels.DispatchCompletion("D-A","100",List.of(new FleetCompletionModels.Outcome("D-A","DL-A","A","ONT8","DELIVERED",100,at,100,0,0)),null));
when(dispatches.completion("D-B",false)).thenReturn(new FleetCompletionModels.DispatchCompletion("D-B","100",List.of(),"排车出库尚未完成"));
when(dispatches.terminalEvidence("D-A",false)).thenReturn(proof(100,at,1,List.of()));
when(trips.lockByNo("T-B")).thenReturn(new FleetTripEntity());
}
@Test void sameBatchSubsetKeepsTokenAndOnlySelectedCustomerExecutionRowsAreLocked() {
var all=adapter.inspect(List.of(a,b));var selected=adapter.inspect(List.of(b));
assertEquals(all.version(),selected.version());assertTrue(all.members().get(0).completed());assertFalse(all.members().get(0).canRebook());assertTrue(selected.members().get(0).canRebook());
var locked=adapter.lockAndInspect(List.of(b),all.version());assertTrue(locked.members().get(0).canRebook());
var order=inOrder(trips,dispatches);order.verify(trips).lockByNo("T-B");order.verify(dispatches).completion("D-B",true);order.verify(dispatches).lockForPlanning("D-B");
verify(trips,never()).lockByNo("T-A");verify(dispatches,never()).completion("D-A",true);verify(dispatches,never()).lockForPlanning("D-A");
}
@Test void statusCannotReplaceRealProofAndPartialActualCoverageCannotCompleteWholeAllocation() {
when(dispatches.terminalEvidence("D-A",false)).thenReturn(proof(50,at,1,List.of()));
var partial=adapter.inspect(List.of(a)).members().get(0);assertFalse(partial.completed());assertFalse(partial.canRebook());
when(dispatches.terminalEvidence("D-A",false)).thenThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"缺少真实出库依据"));
var absent=adapter.inspect(List.of(a)).members().get(0);assertFalse(absent.completed());assertFalse(absent.canRebook());
when(mapper.transportRows(List.of(batch),List.of())).thenReturn(List.of(row("B","PENDING_DEPARTURE","COMPLETED")));
var tripOnly=adapter.inspect(List.of(b)).members().get(0);assertFalse(tripOnly.completed());assertFalse(tripOnly.canRebook());
}
@Test void actualReturnCompletionExcludesMemberAndSiblingExecutionChangeInvalidatesToken() {
var prior=adapter.inspect(List.of(b));
when(dispatches.terminalEvidence("D-A",false)).thenReturn(proof(100,at.plusSeconds(60),2,List.of(new FleetDispatchGateway.ReturnedReceipt("11","101",100,at.plusSeconds(60)))));
var returned=adapter.inspect(List.of(a)).members().get(0);assertTrue(returned.completed());assertFalse(returned.canRebook());
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->adapter.lockAndInspect(List.of(b),prior.version()));
verify(dispatches,never()).completion("D-A",true);
}
@Test void noCandidatesDoesNotGrantAnOperationOrReadOtherBusinessData() {
clearInvocations(mapper,trips,dispatches);assertTrue(adapter.inspect(List.of()).members().isEmpty());
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->adapter.lockAndInspect(List.of(),"empty"));
verifyNoInteractions(mapper,trips,dispatches);
}
private AppointmentWorkDispatchMapper.TransportRow row(String customer,String status,String tripStatus) {
long id="A".equals(customer)?1:2;boolean executed="DEPARTED".equals(status);
return new AppointmentWorkDispatchMapper.TransportRow(id,"D-"+customer,customer,status,executed?100:0,executed?100:0,LocalDateTime.of(2026,10,1,12,0),id+10,id+100,100,"RECEIVED",batch,"ORDER:"+id,"FBA:ONT8",100,"T-"+customer,1L,tripStatus,id+200,executed?"OUTBOUNDED":"WAIT_OUTBOUND",executed?"COMPLETED":"PDA_PENDING");
}
private FleetDispatchGateway.TerminalEvidence proof(int packages,Instant terminal,long version,List<FleetDispatchGateway.ReturnedReceipt> returns) {
return new FleetDispatchGateway.TerminalEvidence("D-A","A","100",packages,List.of(new FleetDispatchGateway.TerminalDelivery("A","DL-A","FBA:ONT8",packages,terminal,returns.isEmpty()?"DELIVERED":"RETURNED",version,returns)));
}
}
@@ -0,0 +1,103 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges;
import com.baiyue.lastleg.fleet.trip.*;
import com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentTransportPort;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.junit.jupiter.api.*;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** Real mapper/transaction checks; original Fleet/Dispatch commands are represented by transactional SQL test doubles. */
class FbaAppointmentTransportAdapterTest {
private JdbcTemplate jdbc;
private TransactionTemplate tx;
private FbaAppointmentTransportPort adapter;
private FleetTripService fleet;
private FleetDispatchGateway dispatch;
@BeforeEach void setup() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:transport-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
jdbc=new JdbcTemplate(ds);var manager=new DataSourceTransactionManager(ds);tx=new TransactionTemplate(manager);
jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,trip_no VARCHAR(40),record_version BIGINT,status VARCHAR(30))");
jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(40),customer_id VARCHAR(40),status VARCHAR(30),package_count INT,picked_package_count INT,shipped_package_count INT,updated_at TIMESTAMP)");
jdbc.execute("CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(40),plan_pallet_id BIGINT,source_stage VARCHAR(30),package_count INT)");
jdbc.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT,dispatch_item_id BIGINT,plan_pallet_id BIGINT,batch_key VARCHAR(60),customer_id VARCHAR(40),source_key VARCHAR(40),destination_key VARCHAR(40),package_count INT)");
jdbc.execute("CREATE TABLE ll_fleet_trip_dispatch(dispatch_id BIGINT PRIMARY KEY,trip_id BIGINT)");
jdbc.execute("CREATE TABLE ll_outbound_order(id BIGINT,dispatch_order_id BIGINT,customer_id VARCHAR(40),status VARCHAR(30))");
jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,dispatch_order_id BIGINT,customer_id VARCHAR(40),status VARCHAR(30))");
jdbc.update("INSERT INTO ll_fleet_trip VALUES(1,'T-1',1,'PLANNED')");
seed(1,"A",80,List.of(80));seed(2,"B",160,List.of(100,60));seed(3,"C",40,List.of(40));
var configuration=new MybatisConfiguration();configuration.setMapUnderscoreToCamelCase(true);configuration.addMapper(AppointmentWorkDispatchMapper.class);configuration.addMapper(FleetTripMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(configuration);
var session=new SqlSessionTemplate(factory.getObject());
fleet=mock(FleetTripService.class);dispatch=mock(FleetDispatchGateway.class);var gateway=dispatch;
when(gateway.lockForPlanning(anyString())).thenAnswer(c->{String number=c.getArgument(0);jdbc.queryForList("SELECT id FROM ll_dispatch_order WHERE dispatch_no=? FOR UPDATE",number);return null;});
when(fleet.unbindDispatch(anyString(),anyString(),any())).thenAnswer(c->{var command=c.getArgument(2,FleetTripModels.BindingCommand.class);jdbc.update("DELETE FROM ll_fleet_trip_dispatch WHERE dispatch_id=(SELECT id FROM ll_dispatch_order WHERE dispatch_no=?)",command.dispatchNo());bump();return null;});
when(fleet.bindDispatch(anyString(),anyString(),any())).thenAnswer(c->{var command=c.getArgument(2,FleetTripModels.BindingCommand.class);jdbc.update("INSERT INTO ll_fleet_trip_dispatch SELECT id,1 FROM ll_dispatch_order WHERE dispatch_no=?",command.dispatchNo());bump();return null;});
when(fleet.detail("T-1")).thenAnswer(c->{
var response=mock(FleetTripModels.Response.class);when(response.recordVersion()).thenReturn(jdbc.queryForObject("SELECT record_version FROM ll_fleet_trip WHERE id=1",Long.class));
var bindings=jdbc.query("SELECT d.* FROM ll_dispatch_order d JOIN ll_fleet_trip_dispatch b ON b.dispatch_id=d.id ORDER BY d.id",(rs,n)->new FleetTripModels.DispatchBinding(rs.getString("dispatch_no"),rs.getString("customer_id"),rs.getInt("package_count"),"operator",null,null,List.of()));
return new FleetTripModels.Detail(response,null,bindings,null,List.of(),null);
});
when(fleet.cancel(anyString(),anyString(),any())).thenAnswer(c->{jdbc.update("UPDATE ll_fleet_trip SET status='CANCELLED',record_version=record_version+1 WHERE id=1");return null;});
doAnswer(c->{
long item=c.getArgument(1);String number=c.getArgument(0);
jdbc.update("DELETE FROM ll_dispatch_appointment_allocation WHERE dispatch_item_id=?",item);jdbc.update("DELETE FROM ll_dispatch_item WHERE id=?",item);
jdbc.update("UPDATE ll_dispatch_order SET package_count=(SELECT SUM(package_count) FROM ll_dispatch_item WHERE dispatch_order_id=ll_dispatch_order.id) WHERE dispatch_no=?",number);return null;
}).when(dispatch).removeItem(anyString(),anyLong());
var target=new FbaAppointmentTransportAdapter(session.getMapper(AppointmentWorkDispatchMapper.class),session.getMapper(FleetTripMapper.class),gateway,fleet,mock(FleetBookingReservations.class),mock(AppointmentWorkChanges.class));
var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));adapter=(FbaAppointmentTransportPort)proxy.getProxy();
}
private void seed(long id,String customer,int total,List<Integer> amounts) {
jdbc.update("INSERT INTO ll_dispatch_order VALUES(?,?,?,'PENDING_DEPARTURE',?,0,0,CURRENT_TIMESTAMP)",id,"D-"+id,customer,total);
jdbc.update("INSERT INTO ll_fleet_trip_dispatch VALUES(?,1)",id);
for(int i=0;i<amounts.size();i++) {
long item=id*10+i;
jdbc.update("INSERT INTO ll_dispatch_item VALUES(?,?,?,?, 'RECEIVED',?)",item,id,customer,item+100,amounts.get(i));
jdbc.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(?,?,?,?,?,?,?,?)",id,item,item+100,"FBA_APPOINTMENT:"+id,customer,"ORDER:"+id,"FBA:ONT8",amounts.get(i));
}
}
private void bump() { jdbc.update("UPDATE ll_fleet_trip SET record_version=record_version+1 WHERE id=1"); }
private FbaAppointmentTransportPort.Result move(int retained) {
var scope=adapter.inspect(List.of("FBA_APPOINTMENT:2"));
return tx.execute(status->{var locked=adapter.lockAndInspect(List.of("FBA_APPOINTMENT:2"),scope.version());return adapter.apply(locked,List.of(new FbaAppointmentTransportPort.Move("FBA_APPOINTMENT:2","B","ORDER:2","FBA:ONT8",160,retained,"FBA_APPOINTMENT:4")),"operator","客户明确调整");});
}
private int remaining(FbaAppointmentTransportPort.Result result) { return result.vehicles().stream().filter(v->"T-1".equals(v.tripNo())).mapToInt(FbaAppointmentTransportPort.Vehicle::packageCount).sum(); }
@Test void wholeCustomerExitLeavesOtherCustomers120AndReusesOriginalPhysicalDispatch() {
var other=jdbc.queryForList("SELECT * FROM ll_dispatch_appointment_allocation WHERE customer_id<>'B'");
var result=move(0);assertEquals(120,remaining(result));
assertEquals(other,jdbc.queryForList("SELECT * FROM ll_dispatch_appointment_allocation WHERE customer_id<>'B'"));
assertEquals(160,jdbc.queryForObject("SELECT SUM(package_count) FROM ll_dispatch_appointment_allocation WHERE batch_key='FBA_APPOINTMENT:4'",Integer.class));
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item WHERE dispatch_order_id=2",Integer.class));verify(dispatch,never()).removeItem(anyString(),anyLong());
}
@Test void wholePhysical60MovesOutLeavingOriginalVehicle220AndLaterScopeHasNoSyntheticVehicle() {
var result=move(100);assertEquals(220,remaining(result));verify(dispatch).removeItem("D-2",21L);
assertEquals(100,jdbc.queryForObject("SELECT package_count FROM ll_dispatch_order WHERE id=2",Integer.class));
var later=adapter.inspect(List.of("FBA_APPOINTMENT:4"));assertTrue(later.vehicles().isEmpty());
var next=tx.execute(status->{var locked=adapter.lockAndInspect(List.of("FBA_APPOINTMENT:4"),later.version());return adapter.apply(locked,List.of(new FbaAppointmentTransportPort.Move("FBA_APPOINTMENT:4","B","ORDER:2","FBA:ONT8",60,40,"FBA_APPOINTMENT:5")),"operator","后送60分40和20");});
assertTrue(next.vehicles().isEmpty());assertEquals(3,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Integer.class));
}
@Test void unmatchedPartialQuantityAndStaleScopeNeverMutateOriginalPhysicalRows() {
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->move(110));
assertEquals(4,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Integer.class));verify(dispatch,never()).removeItem(anyString(),anyLong());
var old=adapter.inspect(List.of("FBA_APPOINTMENT:2"));bump();
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->tx.execute(status->adapter.lockAndInspect(List.of("FBA_APPOINTMENT:2"),old.version())));
}
@Test void downstreamFailureRollsBackUnbindingPhysicalRemovalAndQuantityTogether() {
doThrow(new IllegalStateException("原车绑定失败")).when(fleet).bindDispatch(anyString(),anyString(),any());
assertThrows(IllegalStateException.class,()->move(100));
assertEquals(4,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Integer.class));
assertEquals(160,jdbc.queryForObject("SELECT package_count FROM ll_dispatch_order WHERE id=2",Integer.class));
assertEquals(3,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch",Integer.class));
assertEquals(1L,jdbc.queryForObject("SELECT record_version FROM ll_fleet_trip WHERE id=1",Long.class));
}
}