feat(appointments): support scoped receiving and stable trip continuation
This commit is contained in:
@@ -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 不算成功。
|
||||
|
||||
## 平台管理员密码
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
+17
-1
@@ -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>();
|
||||
|
||||
+4
@@ -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)));
|
||||
|
||||
+1
-1
@@ -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()); }
|
||||
|
||||
+6
@@ -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);
|
||||
}
|
||||
|
||||
+5
-1
@@ -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());
|
||||
|
||||
+4
-2
@@ -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);
|
||||
|
||||
+72
@@ -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);
|
||||
}
|
||||
}
|
||||
+2
@@ -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);
|
||||
}
|
||||
|
||||
+2
@@ -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 }
|
||||
|
||||
+10
@@ -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("跟进日期已过");
|
||||
|
||||
+31
-1
@@ -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);
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
+5
@@ -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);
|
||||
|
||||
+54
-8
@@ -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,
|
||||
|
||||
+5
@@ -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);
|
||||
|
||||
+8
-2
@@ -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. */
|
||||
|
||||
+4
@@ -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 = "司机资格不满足本次任务,请在平台核对";
|
||||
|
||||
+6
-1
@@ -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());
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+4
-1
@@ -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
-1
@@ -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) { }
|
||||
|
||||
+13
@@ -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);
|
||||
}
|
||||
+18
@@ -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);
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
+64
@@ -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) { }
|
||||
}
|
||||
+52
@@ -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());
|
||||
}
|
||||
}
|
||||
+66
@@ -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);
|
||||
}
|
||||
+284
@@ -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);}
|
||||
}
|
||||
+147
@@ -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); }
|
||||
}
|
||||
+238
-14
@@ -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()));
|
||||
|
||||
+41
-4
@@ -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());
|
||||
|
||||
+36
-22
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+75
-14
@@ -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(); }
|
||||
|
||||
+2
-1
@@ -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>();
|
||||
|
||||
+48
@@ -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<>'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);
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+17
-2
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+116
@@ -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();
|
||||
}
|
||||
}
|
||||
+159
@@ -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();
|
||||
}
|
||||
}
|
||||
+25
@@ -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]
|
||||
|
||||
+1
-1
@@ -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"));
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+71
@@ -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);
|
||||
}
|
||||
}
|
||||
+109
@@ -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);
|
||||
|
||||
+25
@@ -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));
|
||||
|
||||
+19
-4
@@ -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;
|
||||
|
||||
+175
@@ -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());
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -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")) {
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
+12
@@ -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"})
|
||||
|
||||
+15
@@ -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() {
|
||||
|
||||
+34
@@ -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;
|
||||
|
||||
+9
@@ -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();
|
||||
|
||||
+44
@@ -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"));
|
||||
}
|
||||
}
|
||||
+69
@@ -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;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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() {
|
||||
|
||||
+78
@@ -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());
|
||||
}
|
||||
}
|
||||
+42
-7
@@ -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);}
|
||||
}
|
||||
}
|
||||
|
||||
+33
@@ -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);
|
||||
|
||||
+818
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-2
@@ -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")));
|
||||
|
||||
+155
-9
@@ -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));
|
||||
|
||||
+79
-5
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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());
|
||||
|
||||
+29
@@ -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);
|
||||
|
||||
+62
@@ -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)));
|
||||
}
|
||||
}
|
||||
+103
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user