Merge remote-tracking branch 'origin/test' into test

This commit is contained in:
2026-10-28 13:42:18 +08:00
23 changed files with 233 additions and 56 deletions
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.appointmentplanning;
import org.springframework.stereotype.Repository;
import java.util.*;
import java.util.stream.Collectors;
/** 读取原货物的货件资料,始终按客户及订单身份隔离。 */
@Repository
public class AppointmentCargoReferenceRepository {
private final AppointmentCargoVerificationMapper mapper;
/** 复用预约货物 Mapper,不跨服务或跨库补读申报。 */
public AppointmentCargoReferenceRepository(AppointmentCargoVerificationMapper mapper) { this.mapper = mapper; }
/** 一次批读本次明确货源的原货件号,未实收建单的外接货保持空列表。 */
public Map<List<String>, List<String>> originalReferences(List<AppointmentSourceService.Source> sources) {
var orders = sources.stream().filter(source -> source.orderId() != null)
.map(source -> new AppointmentCoverageMapper.OrderRef(source.customerId(), source.orderId())).distinct().toList();
var byOrder = orders.isEmpty() ? Map.<AppointmentCoverageMapper.OrderRef, List<AppointmentCargoVerificationMapper.ShipmentReference>>of()
: mapper.originalShipmentReferences(orders).stream().collect(Collectors.groupingBy(
row -> new AppointmentCoverageMapper.OrderRef(row.customerId(), row.orderId())));
var result = new HashMap<List<String>, List<String>>();
for (var source : sources) {
var references = byOrder.getOrDefault(new AppointmentCoverageMapper.OrderRef(source.customerId(), source.orderId()), List.of())
.stream().map(AppointmentCargoVerificationMapper.ShipmentReference::shipmentReference).filter(Objects::nonNull)
.map(String::trim).filter(value -> !value.isEmpty()).distinct().sorted().toList();
result.put(List.of(source.customerId(), source.sourceKey()), references);
}
return Map.copyOf(result);
}
}
@@ -8,6 +8,23 @@ import org.apache.ibatis.annotations.Select;
@Mapper @Mapper
public interface AppointmentCargoVerificationMapper extends BaseMapper<AppointmentCargoVerification> { public interface AppointmentCargoVerificationMapper extends BaseMapper<AppointmentCargoVerification> {
record ShipmentReference(String customerId, Long orderId, String shipmentReference) { }
/** 按已授权的客户与原订单读取原有货件号和追踪号,仓储转运读取其正式来源。 */
@Select({"<script>",
"SELECT o.customer_id AS customerId,o.id AS orderId,",
"CASE WHEN o.order_type='WAREHOUSE_TRANSFER' THEN wt.fba_shipment_no ELSE d.shipment_no END AS shipmentReference ",
"FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id AND d.customer_id=o.customer_id AND o.order_type&lt;&gt;'WAREHOUSE_TRANSFER' ",
AppointmentCoverageMapper.TRANSFER_JOIN,
"WHERE <foreach collection='orders' item='o' open='(' separator=' OR ' close=')'>(o.id=#{o.orderId} AND o.customer_id=#{o.customerId})</foreach>",
"UNION ALL SELECT o.customer_id AS customerId,o.id AS orderId,",
"CASE WHEN o.order_type='WAREHOUSE_TRANSFER' THEN wt.tracking_no ELSE d.fba_tracking_no END AS shipmentReference ",
"FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id AND d.customer_id=o.customer_id AND o.order_type&lt;&gt;'WAREHOUSE_TRANSFER' ",
AppointmentCoverageMapper.TRANSFER_JOIN,
"WHERE <foreach collection='orders' item='o' open='(' separator=' OR ' close=')'>(o.id=#{o.orderId} AND o.customer_id=#{o.customerId})</foreach>",
"</script>"})
java.util.List<ShipmentReference> originalShipmentReferences(@Param("orders") java.util.List<AppointmentCoverageMapper.OrderRef> orders);
/** Same canonical source row used by allocation; flush pre-lock observations before re-reading. */ /** Same canonical source row used by allocation; flush pre-lock observations before re-reading. */
@Select("SELECT id FROM ll_order WHERE id=#{orderId} FOR UPDATE") @Select("SELECT id FROM ll_order WHERE id=#{orderId} FOR UPDATE")
@Options(flushCache = Options.FlushCachePolicy.TRUE, useCache = false) @Options(flushCache = Options.FlushCachePolicy.TRUE, useCache = false)
@@ -21,10 +21,12 @@ public class AppointmentCargoVerificationService {
private final AppointmentSourceService sources; private final AppointmentSourceService sources;
private final AppointmentCargoVerificationMapper mapper; private final AppointmentCargoVerificationMapper mapper;
private final ObjectMapper json; private final ObjectMapper json;
private final AppointmentCargoReferenceRepository references;
/** 核实记录与原货物资料分别读取,已有货件号无需重复录入。 */
public AppointmentCargoVerificationService(AppointmentSourceService sources, AppointmentCargoVerificationMapper mapper, public AppointmentCargoVerificationService(AppointmentSourceService sources, AppointmentCargoVerificationMapper mapper,
ObjectMapper json) { ObjectMapper json, AppointmentCargoReferenceRepository references) {
this.sources=sources; this.mapper=mapper; this.json=json; this.sources=sources; this.mapper=mapper; this.json=json; this.references=references;
} }
public record Save(@NotBlank @Size(max=160) String destinationKey, @Size(max=160) String expectedSourceVersion, public record Save(@NotBlank @Size(max=160) String destinationKey, @Size(max=160) String expectedSourceVersion,
@Min(0) int expectedVersion, @Positive Integer packageCount, @Min(0) int expectedVersion, @Positive Integer packageCount,
@@ -34,7 +36,7 @@ public class AppointmentCargoVerificationService {
Integer packageCount, List<String> shipmentReferences, String remark, Integer packageCount, List<String> shipmentReferences, String remark,
String operatorName, Instant verifiedAt) { } String operatorName, Instant verifiedAt) { }
public record Context(AppointmentSourceService.Source source, Verification verification, public record Context(AppointmentSourceService.Source source, Verification verification,
long allocatedPackages, long unknownAllocationCount, Integer effectivePackages, long allocatedPackages, long unknownAllocationCount, Integer effectivePackages, List<String> effectiveShipmentReferences,
Long availablePackages, boolean sourceChanged, List<String> allowedActions, Long availablePackages, boolean sourceChanged, List<String> allowedActions,
List<String> blockReasons) { } List<String> blockReasons) { }
@@ -50,12 +52,13 @@ public class AppointmentCargoVerificationService {
public record Inspection(AppointmentCoverageService.SourceRef reference,Context context,ResponseStatusException error) { public record Inspection(AppointmentCoverageService.SourceRef reference,Context context,ResponseStatusException error) {
public Context requireContext() { if(error!=null) throw error; return context; } public Context requireContext() { if(error!=null) throw error; return context; }
} }
/** Shares source and warehouse reads within this call only; never caches mutable business facts across requests. */ /** 在本次批读内共用来源、仓库及原货件资料,不缓存可变业务事实。 */
public List<Inspection> inspectBatch(List<AppointmentCoverageService.SourceRef> refs) { public List<Inspection> inspectBatch(List<AppointmentCoverageService.SourceRef> refs) {
var result=new ArrayList<Inspection>(); var result=new ArrayList<Inspection>();
for(int start=0;start<refs.size();start+=100) { for(int start=0;start<refs.size();start+=100) {
var batch=refs.subList(start,Math.min(start+100,refs.size())); var batch=refs.subList(start,Math.min(start+100,refs.size()));
var resolved=sources.inspectBatch(batch); var resolved=sources.inspectBatch(batch);
var originals=originalShipmentReferences(resolved.stream().filter(r->r.error()==null).map(AppointmentSourceService.Resolution::source).toList());
var valid=resolved.stream().filter(r->r.error()==null).map(AppointmentSourceService.Resolution::reference).toList(); var valid=resolved.stream().filter(r->r.error()==null).map(AppointmentSourceService.Resolution::reference).toList();
var latest=new HashMap<List<String>,AppointmentCargoVerification>(); var latest=new HashMap<List<String>,AppointmentCargoVerification>();
var allocations=new HashMap<List<String>,AppointmentCargoVerificationMapper.Allocation>(); var allocations=new HashMap<List<String>,AppointmentCargoVerificationMapper.Allocation>();
@@ -65,18 +68,19 @@ public class AppointmentCargoVerificationService {
} }
for(var resolution:resolved) { for(var resolution:resolved) {
var key=List.of(resolution.reference().customerId(),resolution.reference().sourceKey()); var key=List.of(resolution.reference().customerId(),resolution.reference().sourceKey());
result.add(new Inspection(resolution.reference(),resolution.error()==null?inspect(resolution.source(),latest.get(key),allocations.getOrDefault(key,new AppointmentCargoVerificationMapper.Allocation(0,0))):null,resolution.error())); result.add(new Inspection(resolution.reference(),resolution.error()==null?inspect(resolution.source(),latest.get(key),allocations.getOrDefault(key,new AppointmentCargoVerificationMapper.Allocation(0,0)),originals.getOrDefault(key,List.of())):null,resolution.error()));
} }
} }
return List.copyOf(result); return List.copyOf(result);
} }
/** Uses an already-authorized batched source resolution; never repeats warehouse HTTP lookups per row. */ /** 使用已授权来源读取本票核实记录及原有货件资料。 */
public Context inspect(AppointmentSourceService.Source source) { public Context inspect(AppointmentSourceService.Source source) {
var row=mapper.latest(source.customerId(),source.sourceKey()); var row=mapper.latest(source.customerId(),source.sourceKey());
var allocation=mapper.allocated(source.customerId(),source.sourceKey()); var allocation=mapper.allocated(source.customerId(),source.sourceKey());
return inspect(source,row,allocation); return inspect(source,row,allocation,originalShipmentReferences(List.of(source)).getOrDefault(List.of(source.customerId(),source.sourceKey()),List.of()));
} }
private Context inspect(AppointmentSourceService.Source source,AppointmentCargoVerification row,AppointmentCargoVerificationMapper.Allocation allocation) { /** 已保存的货件核实值优先;尚未补录货件时带入原货物的已有编号。 */
private Context inspect(AppointmentSourceService.Source source,AppointmentCargoVerification row,AppointmentCargoVerificationMapper.Allocation allocation,List<String> originals) {
boolean changed=row!=null && changed(row,source); boolean changed=row!=null && changed(row,source);
Integer total=row==null?source.packages():row.getPackageCount(); Integer total=row==null?source.packages():row.getPackageCount();
var reasons=new ArrayList<>(source.blockReasons()); var reasons=new ArrayList<>(source.blockReasons());
@@ -88,10 +92,17 @@ public class AppointmentCargoVerificationService {
?null:total.longValue()-allocation.packages(); ?null:total.longValue()-allocation.packages();
// Actions describe this endpoint's business capability; controller permissions remain mandatory. // Actions describe this endpoint's business capability; controller permissions remain mandatory.
var actions=source.canFollowup()?List.of("VERIFY_CARGO"):List.<String>of(); var actions=source.canFollowup()?List.of("VERIFY_CARGO"):List.<String>of();
return new Context(source,row==null?null:view(row),allocation.packages(),allocation.unknownItems(),total, var verified=row==null?null:view(row);
var effectiveReferences=verified==null || verified.shipmentReferences().isEmpty()?originals:verified.shipmentReferences();
return new Context(source,verified,allocation.packages(),allocation.unknownItems(),total,effectiveReferences,
available,changed,actions,List.copyOf(reasons)); available,changed,actions,List.copyOf(reasons));
} }
/** 对预约明细和核实表单提供同一批原始货件资料,不改写原订单。 */
public Map<List<String>,List<String>> originalShipmentReferences(List<AppointmentSourceService.Source> sources) {
return references.originalReferences(sources);
}
@Transactional(isolation=Isolation.READ_COMMITTED) @Transactional(isolation=Isolation.READ_COMMITTED)
public Verification save(String sourceKey,String customerId,Save request,String operatorId,String operatorName) { public Verification save(String sourceKey,String customerId,Save request,String operatorId,String operatorName) {
validate(request,operatorId); validate(request,operatorId);
@@ -20,6 +20,7 @@ public class AppointmentCoverageService {
this.mapper=mapper; this.external=external; this.mapper=mapper; this.external=external;
} }
/** 按真实货物与外部约号核对覆盖,确认时段缺失不伪造时间也不撤销已确认事实。 */
public List<Coverage> query(List<SourceRef> input) { public List<Coverage> query(List<SourceRef> input) {
if (input==null || input.isEmpty()) return List.of(); if (input==null || input.isEmpty()) return List.of();
if (input.size()>100 || input.stream().anyMatch(s -> s==null || blank(s.customerId()) || blank(s.sourceKey()) || blank(s.destinationKey()))) { if (input.size()>100 || input.stream().anyMatch(s -> s==null || blank(s.customerId()) || blank(s.sourceKey()) || blank(s.destinationKey()))) {
@@ -85,7 +86,7 @@ public class AppointmentCoverageService {
for (var fact:relevant) { for (var fact:relevant) {
if (fact.orderId()!=null && !Objects.equals(order.orderId(),fact.orderId()) || !Objects.equals(order.fbaWarehouseCode(),fact.warehouseCode()) || fact.packages()==null || fact.packages()<=0 || !seen.add(fact.itemId())) { invalid=true; continue; } if (fact.orderId()!=null && !Objects.equals(order.orderId(),fact.orderId()) || !Objects.equals(order.fbaWarehouseCode(),fact.warehouseCode()) || fact.packages()==null || fact.packages()<=0 || !seen.add(fact.itemId())) { invalid=true; continue; }
covered+=fact.packages(); covered+=fact.packages();
boolean success="SUCCESS".equals(fact.status()) && fact.appointmentAt()!=null && !blank(fact.confirmationNo()) && !blank(fact.timezone()); boolean success="SUCCESS".equals(fact.status()) && !blank(fact.confirmationNo()) && !blank(fact.timezone());
if (success) confirmed+=fact.packages(); if (success) confirmed+=fact.packages();
distinct.putIfAbsent(fact.appointmentNo(),new Appointment(fact.appointmentNo(),success ? fact.confirmationNo() : null,fact.status(),success ? fact.appointmentAt() : null,success ? fact.appointmentEndAt() : null,fact.timezone())); distinct.putIfAbsent(fact.appointmentNo(),new Appointment(fact.appointmentNo(),success ? fact.confirmationNo() : null,fact.status(),success ? fact.appointmentAt() : null,success ? fact.appointmentEndAt() : null,fact.timezone()));
changed|=!Objects.equals(currentVersion,fact.sourceVersion()); changed|=!Objects.equals(currentVersion,fact.sourceVersion());
@@ -99,7 +100,8 @@ public class AppointmentCoverageService {
else if (confirmed<packages) { status="PENDING"; missing.add("RECEIVING_CONFIRMATION"); } else if (confirmed<packages) { status="PENDING"; missing.add("RECEIVING_CONFIRMATION"); }
else { status="CONFIRMED"; } else { status="CONFIRMED"; }
if (relevant.stream().anyMatch(f->"SUCCESS".equals(f.status()) && blank(f.timezone()))) missing.add("APPOINTMENT_TIMEZONE_UNVERIFIED"); if (relevant.stream().anyMatch(f->"SUCCESS".equals(f.status()) && blank(f.timezone()))) missing.add("APPOINTMENT_TIMEZONE_UNVERIFIED");
if (evidence==null ? order.shipmentReferencesMissing() : !hasReferences(evidence.getShipmentReferencesJson())) missing.add("FBA_SHIPMENT_REFERENCES"); // 只补录过箱数的核实记录不能遮蔽原订单已经完整提供的货件号。
if (order.shipmentReferencesMissing() && (evidence==null || !hasReferences(evidence.getShipmentReferencesJson()))) missing.add("FBA_SHIPMENT_REFERENCES");
if (changed) missing.add("SOURCE_CHANGED_REVIEW"); if (changed) missing.add("SOURCE_CHANGED_REVIEW");
output.add(new Coverage(source.customerId(),source.sourceKey(),source.destinationKey(),status,List.copyOf(distinct.values()),List.copyOf(missing), output.add(new Coverage(source.customerId(),source.sourceKey(),source.destinationKey(),status,List.copyOf(distinct.values()),List.copyOf(missing),
(booking==null ? "" : "BOOKING:"+version(booking.recordVersion())+"/")+"ORDER:"+version(order.version()),changed || invalid)); (booking==null ? "" : "BOOKING:"+version(booking.recordVersion())+"/")+"ORDER:"+version(order.version()),changed || invalid));
@@ -7,15 +7,19 @@ import java.time.ZoneId;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.List; import java.util.List;
/** Resolves a location's appointment window without using the operator's system time zone. */ /** 解析预约地点的真实确认时段,不使用操作人的系统时区或虚构确认时间。 */
public final class AppointmentTimePolicy { public final class AppointmentTimePolicy {
private AppointmentTimePolicy() { } private AppointmentTimePolicy() { }
public record Window(Instant start, Instant end, String timezone) { } public record Window(Instant start, Instant end, String timezone) { }
/** 未提供确认时段时保持为空;提供时仍校验地区时区、夏令时及先后顺序。 */
public static Window resolve(LocalDateTime start, LocalDateTime end, String timezone, public static Window resolve(LocalDateTime start, LocalDateTime end, String timezone,
ZoneOffset startOffset, ZoneOffset endOffset) { ZoneOffset startOffset, ZoneOffset endOffset) {
if (start == null) throw new IllegalArgumentException("请填写预约开始时间"); if (start == null) {
if (end != null || startOffset != null || endOffset != null) throw new IllegalArgumentException("未填写确认开始时间,不能填写确认结束时间或时差");
return new Window(null, null, timezone);
}
if (timezone == null || timezone.isBlank()) throw new IllegalArgumentException("请先补齐预约地点时区"); if (timezone == null || timezone.isBlank()) throw new IllegalArgumentException("请先补齐预约地点时区");
final ZoneId zone; final ZoneId zone;
try { zone = ZoneId.of(timezone); } try { zone = ZoneId.of(timezone); }
@@ -18,6 +18,7 @@ public interface AppointmentWorkDispatchPort {
Map<CandidatePhysical,CandidateBatches> candidateBatches(List<CandidatePhysical> candidates); Map<CandidatePhysical,CandidateBatches> candidateBatches(List<CandidatePhysical> candidates);
/** 只读取本车已选实物的逐票预约选项;本车原占用不扣减本车可重新分配量。 */ /** 只读取本车已选实物的逐票预约选项;本车原占用不扣减本车可重新分配量。 */
Map<CandidatePhysical,List<AllocationSource>> allocationOptions(long dispatchId,List<CandidatePhysical> items); Map<CandidatePhysical,List<AllocationSource>> allocationOptions(long dispatchId,List<CandidatePhysical> items);
/** 正式确认的已知时段原样返回;未提供确认时间时保持为空。 */
record AllocationOption(String batchKey,String batchNo,Long recordVersion,int availablePackages, record AllocationOption(String batchKey,String batchNo,Long recordVersion,int availablePackages,
String confirmationNo,String appointmentAt,String appointmentEndAt,String timezone) { } String confirmationNo,String appointmentAt,String appointmentEndAt,String timezone) { }
record AllocationSource(String sourceKey,String orderNo,String inboundCode,String destinationKey,Integer packageCount, record AllocationSource(String sourceKey,String orderNo,String inboundCode,String destinationKey,Integer packageCount,
@@ -33,7 +34,7 @@ public interface AppointmentWorkDispatchPort {
String departureBlockReason(String dispatchNo); String departureBlockReason(String dispatchNo);
record ReceivingWindow(String batchKey,String batchNo,String customerId,String destinationKey,String confirmationNo, record ReceivingWindow(String batchKey,String batchNo,String customerId,String destinationKey,String confirmationNo,
java.time.Instant startAt,java.time.Instant endAt,String agreementKey,int version) { } java.time.Instant startAt,java.time.Instant endAt,String agreementKey,int version) { }
/** FBA选入前须已有正式时间确认;提交审核、审核通过和开始拣货继续复核运输安排。 */ /** FBA选入前须已有正式外部确认;提交审核、审核通过和开始拣货继续复核运输安排及已知时段。 */
String reviewBlockReason(String dispatchNo,java.time.Instant plannedDepartureAt,java.time.Instant estimatedArrivalAt); String reviewBlockReason(String dispatchNo,java.time.Instant plannedDepartureAt,java.time.Instant estimatedArrivalAt);
List<ReceivingWindow> reviewEvidence(String dispatchNo); List<ReceivingWindow> reviewEvidence(String dispatchNo);
void removeItem(long dispatchId,long itemId); void removeItem(long dispatchId,long itemId);
@@ -381,7 +381,7 @@ public class InboundFbaAppointmentService {
return response(row,items(id)); 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) @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public FbaAppointmentResponse refreshReceivingCargo(String key,String customerId,List<com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.CargoVersion> expected) { 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 snapshot=lockReceivingBatch(key);var row=appointmentMapper.selectById(snapshot.id());
@@ -395,6 +395,7 @@ public class InboundFbaAppointmentService {
} }
var refs=selected.stream().map(i->new AppointmentCoverageService.SourceRef(i.getCustomerId(),i.getSourceKey(),"FBA:"+i.getFbaWarehouseCode())).toList(); 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 resolved=resolveForWrite(refs);var evidence=verification(resolved);
var originals=verification.originalShipmentReferences(resolved);
var bySource=resolved.stream().collect(Collectors.toMap(AppointmentSourceService.Source::sourceKey,s->s)); var bySource=resolved.stream().collect(Collectors.toMap(AppointmentSourceService.Source::sourceKey,s->s));
for(var item:selected) { for(var item:selected) {
var requested=versions.get(item.getSourceKey());var source=bySource.get(item.getSourceKey()); var requested=versions.get(item.getSourceKey());var source=bySource.get(item.getSourceKey());
@@ -407,7 +408,7 @@ public class InboundFbaAppointmentService {
var other=queryMapper.allocation(customerId,source.sourceKey(),source.orderId(),row.getId()); 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("最新核实量不足以覆盖当前全部批次,请先处理数量差异,不能直接采用资料"); 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()); item.setSourceVersion(source.sourceVersion());item.setCargoVerificationVersion(verified==null?null:verified.version());
try {item.setShipmentReferencesJson(verified==null?null:json.writeValueAsString(verified.shipmentReferences()));} try {item.setShipmentReferencesJson(json.writeValueAsString(effectiveShipmentReferences(source,verified,originals)));}
catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException("核实货件资料无法保存",error);} catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException("核实货件资料无法保存",error);}
} }
if(snapshot.equals(response(row,current))) return snapshot; if(snapshot.equals(response(row,current))) return snapshot;
@@ -497,7 +498,7 @@ public class InboundFbaAppointmentService {
if(!groups.isEmpty() && groups.size()==active.size() && active.stream().map(a->a.agreementKey()).distinct().count()==1) { 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 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())); 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.setAppointmentStartUtc(window.start()==null?null: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()); row.setPlannedDate(d.plannedDate());row.setPlannedAt(d.plannedAt());row.setPlannedEndAt(d.plannedEndAt());
} else { } else {
var plans=receiving.plans(key).stream().collect(Collectors.toMap(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.CustomerPlan::customerId,p->p)); var plans=receiving.plans(key).stream().collect(Collectors.toMap(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.CustomerPlan::customerId,p->p));
@@ -562,6 +563,7 @@ public class InboundFbaAppointmentService {
var verified=evidence.get(source.sourceKey()); var verified=evidence.get(source.sourceKey());
return verified==null?source.packages():verified.packageCount(); return verified==null?source.packages():verified.packageCount();
} }
/** 保存外部约号及真实已知时段;未提供确认时间时不从计划日期伪造。 */
private void apply(InboundFbaAppointment appointment,FbaAppointmentDetails details,List<AppointmentSourceService.Source> rows, private void apply(InboundFbaAppointment appointment,FbaAppointmentDetails details,List<AppointmentSourceService.Source> rows,
Map<String,AppointmentCargoVerificationService.Verification> evidence) { Map<String,AppointmentCargoVerificationService.Verification> evidence) {
String status=status(details.appointmentStatus()); String status=status(details.appointmentStatus());
@@ -572,26 +574,27 @@ public class InboundFbaAppointmentService {
appointment.setPlannedAt(details.plannedAt());appointment.setPlannedEndAt(details.plannedEndAt());appointment.setTimezone(timezone); appointment.setPlannedAt(details.plannedAt());appointment.setPlannedEndAt(details.plannedEndAt());appointment.setTimezone(timezone);
appointment.setAppointmentStatus(status);appointment.setAppointmentRemark(clean(details.appointmentRemark())); appointment.setAppointmentStatus(status);appointment.setAppointmentRemark(clean(details.appointmentRemark()));
if("SUCCESS".equals(status)) { if("SUCCESS".equals(status)) {
if(clean(details.confirmationNo())==null || details.appointmentAt()==null) throw bad("登记对方确认时请填写外部约号和确认时间"); if(clean(details.confirmationNo())==null) throw bad("登记对方确认时请填写外部约号");
var ids=rows.stream().map(AppointmentSourceService.Source::orderId).filter(Objects::nonNull).distinct().toList(); var ids=rows.stream().map(AppointmentSourceService.Source::orderId).filter(Objects::nonNull).distinct().toList();
var unverifiedIds=rows.stream().filter(s->!evidence.containsKey(s.sourceKey())).map(AppointmentSourceService.Source::orderId).filter(Objects::nonNull).distinct().toList(); var unverifiedIds=rows.stream().filter(s->!evidence.containsKey(s.sourceKey()) || evidence.get(s.sourceKey()).shipmentReferences().isEmpty()).map(AppointmentSourceService.Source::orderId).filter(Objects::nonNull).distinct().toList();
if(ids.size()!=rows.size() || rows.stream().anyMatch(s->quantity(s,evidence)==null || quantity(s,evidence)<=0) if(ids.size()!=rows.size() || rows.stream().anyMatch(s->quantity(s,evidence)==null || quantity(s,evidence)<=0)
|| evidence.values().stream().anyMatch(v->v.shipmentReferences().isEmpty())
|| !unverifiedIds.isEmpty() && queryMapper.countMissingShipmentReferences(unverifiedIds)>0) || !unverifiedIds.isEmpty() && queryMapper.countMissingShipmentReferences(unverifiedIds)>0)
throw bad("正式确认前请核实货量并补齐各票FBA货件号或追踪号"); throw bad("正式确认前请核实货量并补齐各票FBA货件号或追踪号");
final AppointmentTimePolicy.Window window; final AppointmentTimePolicy.Window window;
try {window=AppointmentTimePolicy.resolve(details.appointmentAt(),details.appointmentEndAt(),timezone,offset(details.startOffset()),offset(details.endOffset()));} try {window=AppointmentTimePolicy.resolve(details.appointmentAt(),details.appointmentEndAt(),timezone,offset(details.startOffset()),offset(details.endOffset()));}
catch(IllegalArgumentException | DateTimeException error) {throw bad(error.getMessage());} catch(IllegalArgumentException | DateTimeException error) {throw bad(error.getMessage());}
appointment.setConfirmationNo(details.confirmationNo().trim());appointment.setAppointmentAt(details.appointmentAt());appointment.setAppointmentEndAt(details.appointmentEndAt()); appointment.setConfirmationNo(details.confirmationNo().trim());appointment.setAppointmentAt(details.appointmentAt());appointment.setAppointmentEndAt(details.appointmentEndAt());
appointment.setAppointmentStartUtc(LocalDateTime.ofInstant(window.start(),ZoneOffset.UTC));appointment.setAppointmentEndUtc(window.end()==null?null:LocalDateTime.ofInstant(window.end(),ZoneOffset.UTC)); appointment.setAppointmentStartUtc(window.start()==null?null:LocalDateTime.ofInstant(window.start(),ZoneOffset.UTC));appointment.setAppointmentEndUtc(window.end()==null?null:LocalDateTime.ofInstant(window.end(),ZoneOffset.UTC));
} else { } else {
if(clean(details.confirmationNo())!=null || details.appointmentAt()!=null || details.appointmentEndAt()!=null) throw bad("待确认安排请填写计划时间;取得对方确认后再登记外部约号和确认时段"); if(clean(details.confirmationNo())!=null || details.appointmentAt()!=null || details.appointmentEndAt()!=null) throw bad("待确认安排请填写计划时间;取得对方确认后再登记外部约号和确认时段");
appointment.setConfirmationNo(null);appointment.setAppointmentAt(null);appointment.setAppointmentEndAt(null);appointment.setAppointmentStartUtc(null);appointment.setAppointmentEndUtc(null); appointment.setConfirmationNo(null);appointment.setAppointmentAt(null);appointment.setAppointmentEndAt(null);appointment.setAppointmentStartUtc(null);appointment.setAppointmentEndUtc(null);
} }
} }
/** 固化本次货量及有效货件号,优先核实记录,未补录时采用原货物资料。 */
private void writeItems(InboundFbaAppointment appointment,List<AppointmentSourceService.Source> rows,List<InboundFbaAppointmentItem> previous, private void writeItems(InboundFbaAppointment appointment,List<AppointmentSourceService.Source> rows,List<InboundFbaAppointmentItem> previous,
Map<String,AppointmentCargoVerificationService.Verification> evidence,Map<String,Integer> requested) { Map<String,AppointmentCargoVerificationService.Verification> evidence,Map<String,Integer> requested) {
var existing=previous.stream().collect(Collectors.toMap(InboundFbaAppointmentItem::getSourceKey,item->item)); var existing=previous.stream().collect(Collectors.toMap(InboundFbaAppointmentItem::getSourceKey,item->item));
var originals=verification.originalShipmentReferences(rows);
for(var source:rows) { for(var source:rows) {
var item=existing.get(source.sourceKey());boolean fresh=item==null; var item=existing.get(source.sourceKey());boolean fresh=item==null;
var allocated=queryMapper.allocation(source.customerId(),source.sourceKey(),source.orderId(),appointment.getId()); var allocated=queryMapper.allocation(source.customerId(),source.sourceKey(),source.orderId(),appointment.getId());
@@ -611,12 +614,18 @@ public class InboundFbaAppointmentService {
item.setFbaWarehouseCode(source.destinationWarehouseCode());item.setPackageCount(count);item.setActive(true); item.setFbaWarehouseCode(source.destinationWarehouseCode());item.setPackageCount(count);item.setActive(true);
var verified=evidence.get(source.sourceKey()); var verified=evidence.get(source.sourceKey());
item.setCargoVerificationVersion(verified==null?null:verified.version()); item.setCargoVerificationVersion(verified==null?null:verified.version());
try {item.setShipmentReferencesJson(verified==null?null:json.writeValueAsString(verified.shipmentReferences()));} var references=effectiveShipmentReferences(source,verified,originals);
try {item.setShipmentReferencesJson(json.writeValueAsString(references));}
catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException("核实货件资料无法保存",error);} catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException("核实货件资料无法保存",error);}
try {if(fresh)itemMapper.insert(item);else itemMapper.updateById(item);} try {if(fresh)itemMapper.insert(item);else itemMapper.updateById(item);}
catch(DuplicateKeyException duplicate){throw conflict("所选货物刚被其他安排占用,请刷新后核对");} catch(DuplicateKeyException duplicate){throw conflict("所选货物刚被其他安排占用,请刷新后核对");}
} }
} }
/** 新建及采用最新资料均优先非空核实编号,未补录时使用同客户原订单编号。 */
private static List<String> effectiveShipmentReferences(AppointmentSourceService.Source source,AppointmentCargoVerificationService.Verification verified,
Map<List<String>,List<String>> originals) {
return verified==null || verified.shipmentReferences().isEmpty()?originals.getOrDefault(List.of(source.customerId(),source.sourceKey()),List.of()):verified.shipmentReferences();
}
private FbaAppointmentResponse command(String operation,String operator,String key,Object request,Supplier<FbaAppointmentResponse> action) { private FbaAppointmentResponse command(String operation,String operator,String key,Object request,Supplier<FbaAppointmentResponse> action) {
return executeCommand(operation,operator,key,request,FbaAppointmentResponse.class,FbaAppointmentResponse::appointmentNo,action); return executeCommand(operation,operator,key,request,FbaAppointmentResponse.class,FbaAppointmentResponse::appointmentNo,action);
} }
@@ -43,14 +43,14 @@ public class AppointmentDispatchAllocationReader {
int confirmed=availability.confirmedPackages(confirmations.get(b.batchKey()),b.customerId(),source,p.destinationKey()); int confirmed=availability.confirmedPackages(confirmations.get(b.batchKey()),b.customerId(),source,p.destinationKey());
long remaining=b.packages()==null?0:Math.min(b.packages(),confirmed)-b.allocated(); long remaining=b.packages()==null?0:Math.min(b.packages(),confirmed)-b.allocated();
if(remaining>0 && b.recordVersion()!=null && b.recordVersion()>=0) { if(remaining>0 && b.recordVersion()!=null && b.recordVersion()>=0) {
var agreement=confirmations.get(b.batchKey()).agreements().stream().filter(a->"ACTIVE".equals(a.status()) var agreement=confirmations.get(b.batchKey()).agreements().stream().filter(a->"ACTIVE".equals(a.status()) && b.destinationKey().equals(a.destinationKey())
&& a.members().stream().anyMatch(m->b.batchKey().equals(m.batchKey()) && b.customerId().equals(m.customerId()))).findFirst().orElseThrow(); && a.members().stream().anyMatch(m->b.batchKey().equals(m.batchKey()) && b.customerId().equals(m.customerId()))).findFirst().orElseThrow();
var details=agreement.details(); var details=agreement.details();
options.add(new AllocationOption(b.batchKey(),b.batchNo(),b.recordVersion(),Math.toIntExact(remaining),details.confirmationNo(), options.add(new AllocationOption(b.batchKey(),b.batchNo(),b.recordVersion(),Math.toIntExact(remaining),details.confirmationNo(),
details.appointmentAt().toString(),details.appointmentEndAt()==null?null:details.appointmentEndAt().toString(),agreement.timezone())); details.appointmentAt()==null?null:details.appointmentAt().toString(),details.appointmentEndAt()==null?null:details.appointmentEndAt().toString(),agreement.timezone()));
} }
} }
if(options.stream().mapToLong(AllocationOption::availablePackages).sum()<p.packages())reasons.add("请先在预约列表确认本票FBA送仓时间及覆盖箱数,再返回刷新"); if(options.stream().mapToLong(AllocationOption::availablePackages).sum()<p.packages())reasons.add("请先在预约列表登记本票FBA外部约号及覆盖箱数,再返回刷新");
} }
rows.add(new AllocationSource(source,p.orderNo(),p.inboundCode(),p.destinationKey(),p.packages(),List.copyOf(options),List.copyOf(reasons))); rows.add(new AllocationSource(source,p.orderNo(),p.inboundCode(),p.destinationKey(),p.packages(),List.copyOf(options),List.copyOf(reasons)));
} }
@@ -1,5 +1,6 @@
package com.baiyue.lastleg.inboundplan.work; package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.appointmentplanning.AppointmentTimePolicy;
import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore; import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore;
import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*; import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -18,32 +19,22 @@ public class AppointmentDispatchAvailability {
for(int offset=0;offset<unique.size();offset+=200) for(var snapshot:receiving.snapshots(unique.subList(offset,Math.min(offset+200,unique.size()))))result.put(snapshot.batchKey(),snapshot); for(int offset=0;offset<unique.size();offset+=200) for(var snapshot:receiving.snapshots(unique.subList(offset,Math.min(offset+200,unique.size()))))result.put(snapshot.batchKey(),snapshot);
return result; return result;
} }
/** 未确认、待仓库回复、时间无效或覆盖身份不唯一时均不可作为排车依据。 */ /** 正式确认允许尚无时段;已填时段须合法,且客户、来源和目的仓覆盖必须唯一。 */
public int confirmedPackages(BatchReceiving snapshot,String customer,String source,String destination) { public int confirmedPackages(BatchReceiving snapshot,String customer,String source,String destination) {
if(snapshot==null || snapshot.pendingChanges().stream().anyMatch(p->"WAITING".equals(p.status()) && customer.equals(p.intent().customerId())))return 0; if(snapshot==null || snapshot.pendingChanges().stream().anyMatch(p->"WAITING".equals(p.status()) && customer.equals(p.intent().customerId())))return 0;
var agreements=snapshot.agreements().stream().filter(a->"ACTIVE".equals(a.status()) && destination.equals(a.destinationKey()) var agreements=snapshot.agreements().stream().filter(a->"ACTIVE".equals(a.status()) && destination.equals(a.destinationKey())
&& a.members().stream().anyMatch(m->snapshot.batchKey().equals(m.batchKey()) && customer.equals(m.customerId()))).toList(); && a.members().stream().anyMatch(m->snapshot.batchKey().equals(m.batchKey()) && customer.equals(m.customerId()))).toList();
if(agreements.size()!=1)return 0; if(agreements.size()!=1)return 0;
var agreement=agreements.get(0);var details=agreement.details(); var agreement=agreements.get(0);var details=agreement.details();
if(details==null || !"SUCCESS".equals(details.appointmentStatus()) || details.appointmentAt()==null)return 0; if(details==null || !"SUCCESS".equals(details.appointmentStatus()) || details.confirmationNo()==null || details.confirmationNo().isBlank())return 0;
var start=confirmedInstant(details.appointmentAt(),agreement.timezone(),details.startOffset()); // 与保存正式确认共用可选时段规则,不从计划日期推造送仓时间。
if(start==null)return 0; try {AppointmentTimePolicy.resolve(details.appointmentAt(),details.appointmentEndAt(),agreement.timezone(),offset(details.startOffset()),offset(details.endOffset()));}
if(details.appointmentEndAt()!=null) { catch(IllegalArgumentException|DateTimeException error){return 0;}
var end=confirmedInstant(details.appointmentEndAt(),agreement.timezone(),details.endOffset());
if(end==null || end.isBefore(start))return 0;
}
var members=agreement.members().stream().filter(m->snapshot.batchKey().equals(m.batchKey()) && customer.equals(m.customerId())).toList(); var members=agreement.members().stream().filter(m->snapshot.batchKey().equals(m.batchKey()) && customer.equals(m.customerId())).toList();
if(members.size()!=1)return 0; if(members.size()!=1)return 0;
var sources=members.get(0).sources().stream().filter(s->source.equals(s.sourceKey()) && destination.equals(s.destinationKey())).toList(); var sources=members.get(0).sources().stream().filter(s->source.equals(s.sourceKey()) && destination.equals(s.destinationKey())).toList();
return sources.size()==1?Math.max(0,sources.get(0).packageCount()):0; return sources.size()==1?Math.max(0,sources.get(0).packageCount()):0;
} }
/** 夏令时歧义须有明确偏移;不能把当地时间按服务器时区猜测。 */ /** 留空的夏令时时差保持未知,已填写值由正式时段规则核验。 */
private Instant confirmedInstant(LocalDateTime at,String timezone,String offset) { private ZoneOffset offset(String value) {return value==null || value.isBlank()?null:ZoneOffset.of(value.trim());}
try {
var offsets=ZoneId.of(timezone).getRules().getValidOffsets(at);
if(offsets.isEmpty() || offset==null && offsets.size()!=1)return null;
var selected=offset==null?offsets.get(0):ZoneOffset.of(offset);
return offsets.contains(selected)?at.toInstant(selected):null;
} catch(DateTimeException|NullPointerException error){return null;}
}
} }
@@ -31,6 +31,7 @@ public class AppointmentWorkAppointmentChanges {
} }
} }
record Receiving(Agreement agreement,Pending pending,Slot target,Slot confirmed,String timezone,boolean cancelled,String status) { } 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) { static Receiving receiving(FbaAppointmentResponse batch,String customer,BatchReceiving snapshot) {
if(snapshot==null || !batch.batchKey().equals(snapshot.batchKey())) throw AppointmentWorkPolicy.unavailable("收货预约批量读取不完整"); 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(); 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();
@@ -44,7 +45,7 @@ public class AppointmentWorkAppointmentChanges {
String status=null,timezone=null;Slot target=null,confirmed=null; String status=null,timezone=null;Slot target=null,confirmed=null;
if(agreement!=null) { if(agreement!=null) {
var details=agreement.details();timezone=agreement.timezone(); var details=agreement.details();timezone=agreement.timezone();
if(details==null || !"SUCCESS".equals(details.appointmentStatus()) || details.appointmentAt()==null) throw AppointmentWorkPolicy.unavailable("有效收货预约缺少正式确认时间"); if(details==null || !"SUCCESS".equals(details.appointmentStatus()) || details.confirmationNo()==null || details.confirmationNo().isBlank()) throw AppointmentWorkPolicy.unavailable("有效收货预约缺少外部约号");
target=target(details,timezone);status=details.appointmentStatus(); target=target(details,timezone);status=details.appointmentStatus();
confirmed=slot(details.appointmentAt(),details.appointmentEndAt(),timezone); confirmed=slot(details.appointmentAt(),details.appointmentEndAt(),timezone);
} else if(!plans.isEmpty()) { } else if(!plans.isEmpty()) {
@@ -98,7 +98,7 @@ public class AppointmentWorkDispatchCandidates {
if(item==null) { reasons.add("托盘含本批之外的货物,请先拆托或选择完整的关联批次"); continue; } if(item==null) { reasons.add("托盘含本批之外的货物,请先拆托或选择完整的关联批次"); continue; }
long used=mapper.allocated(batch,part.customerId(),ref.sourceKey(),part.destinationKey(),-1); long used=mapper.allocated(batch,part.customerId(),ref.sourceKey(),part.destinationKey(),-1);
if(item.packageCount()==null || used+count>item.packageCount()) reasons.add("本托货量超过该来源本批剩余可排量,请先核对分批或拆托"); if(item.packageCount()==null || used+count>item.packageCount()) reasons.add("本托货量超过该来源本批剩余可排量,请先核对分批或拆托");
if(used+count>availability.confirmedPackages(confirmation,part.customerId(),ref.sourceKey(),part.destinationKey()))reasons.add("FBA货物须先预约成功并明确送仓时间,再加入排车"); if(used+count>availability.confirmedPackages(confirmation,part.customerId(),ref.sourceKey(),part.destinationKey()))reasons.add("FBA货物须先取得外部约号并确认覆盖箱数,再加入排车");
if(appointment.version()==null || appointment.version()<0) throw unavailable("原预约批次版本缺失"); if(appointment.version()==null || appointment.version()<0) throw unavailable("原预约批次版本缺失");
allocations.add(new Allocation(candidate.planPalletId(),batch,appointment.version().longValue(),part.customerId(),ref.sourceKey(),part.destinationKey(),count)); allocations.add(new Allocation(candidate.planPalletId(),batch,appointment.version().longValue(),part.customerId(),ref.sourceKey(),part.destinationKey(),count));
} }
@@ -108,6 +108,7 @@ components:
allocatedPackages: {type: integer, format: int64} allocatedPackages: {type: integer, format: int64}
unknownAllocationCount: {type: integer, format: int64} unknownAllocationCount: {type: integer, format: int64}
effectivePackages: {type: integer, nullable: true, description: 独立核实箱数;没有核实记录时采用原来源箱数} effectivePackages: {type: integer, nullable: true, description: 独立核实箱数;没有核实记录时采用原来源箱数}
effectiveShipmentReferences: {type: array, items: {type: string}, description: 有效货件号和追踪号;非空独立核实值优先,未补录货件时带入同客户原订单资料,去空去重;读取不会新增核实记录}
availablePackages: {type: integer, format: int64, nullable: true} availablePackages: {type: integer, format: int64, nullable: true}
sourceChanged: {type: boolean} sourceChanged: {type: boolean}
allowedActions: {type: array, items: {type: string, enum: [VERIFY_CARGO]}} allowedActions: {type: array, items: {type: string, enum: [VERIFY_CARGO]}}
@@ -218,7 +218,7 @@ components:
plannedAt: {type: string, nullable: true, description: 已知的计划开始当地时段,日期必须与plannedDate一致} plannedAt: {type: string, nullable: true, description: 已知的计划开始当地时段,日期必须与plannedDate一致}
plannedEndAt: {type: string, nullable: true} plannedEndAt: {type: string, nullable: true}
confirmationNo: {type: string, maxLength: 128, nullable: true, description: 仅SUCCESS登记收货方的外部约号;同一FBA仓不得重复} confirmationNo: {type: string, maxLength: 128, nullable: true, description: 仅SUCCESS登记收货方的外部约号;同一FBA仓不得重复}
appointmentAt: {type: string, nullable: true, description: 对方确认开始当地时间;SUCCESS必填,PENDING不能当作计划填写} appointmentAt: {type: string, nullable: true, description: 对方确认开始当地时间;SUCCESS可留空,缺失时不从计划日期推造,填写时须校验地区时区及夏令时;PENDING不能当作计划填写}
appointmentEndAt: {type: string, nullable: true, description: 明确结束时间时必须晚于开始时间;未知留空,不推算} appointmentEndAt: {type: string, nullable: true, description: 明确结束时间时必须晚于开始时间;未知留空,不推算}
appointmentStatus: {type: string, enum: [PENDING, SUCCESS, FAILED]} appointmentStatus: {type: string, enum: [PENDING, SUCCESS, FAILED]}
appointmentRemark: {type: string, maxLength: 1000, nullable: true} appointmentRemark: {type: string, maxLength: 1000, nullable: true}
@@ -7161,7 +7161,8 @@ components:
confirmationNo: {type: string, nullable: true} confirmationNo: {type: string, nullable: true}
appointmentAt: appointmentAt:
type: string type: string
description: 目的仓当地确认开始时间,须结合timezone解释,不按浏览器时区转换。 nullable: true
description: 目的仓当地确认开始时间;未提供时保持为空,已提供时结合timezone解释,不按浏览器时区转换。
appointmentEndAt: {type: string, nullable: true} appointmentEndAt: {type: string, nullable: true}
timezone: {type: string} timezone: {type: string}
DispatchAppointmentAllocationCommand: DispatchAppointmentAllocationCommand:
@@ -9,17 +9,17 @@ import static org.mockito.Mockito.*;
class AppointmentBatchPreviewServiceTest { class AppointmentBatchPreviewServiceTest {
private static AppointmentCargoVerificationService verification() { private static AppointmentCargoVerificationService verification() {
var service=mock(AppointmentCargoVerificationService.class); var service=mock(AppointmentCargoVerificationService.class);
when(service.inspect(any())).thenAnswer(call->{AppointmentSourceService.Source source=call.getArgument(0);return new AppointmentCargoVerificationService.Context(source,null,0,0,source.packages(),source.packages()==null?null:source.packages().longValue(),false,List.of("VERIFY_CARGO"),List.of());}); when(service.inspect(any())).thenAnswer(call->{AppointmentSourceService.Source source=call.getArgument(0);return new AppointmentCargoVerificationService.Context(source,null,0,0,source.packages(),List.of(),source.packages()==null?null:source.packages().longValue(),false,List.of("VERIFY_CARGO"),List.of());});
return service; return service;
} }
@Test void previewUsesVerifiedRemainingQuantityAndDoesNotRejectAnEntirePartiallyAllocatedSource() { @Test void previewUsesVerifiedRemainingQuantityAndDoesNotRejectAnEntirePartiallyAllocatedSource() {
var sources=mock(AppointmentSourceService.class);var mapper=mock(AppointmentBatchPreviewMapper.class);var verification=verification(); var sources=mock(AppointmentSourceService.class);var mapper=mock(AppointmentBatchPreviewMapper.class);var verification=verification();
var refs=List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"));var source=source("ORDER:1","A",1L,"100","FBA:ONT8"); var refs=List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"));var source=source("ORDER:1","A",1L,"100","FBA:ONT8");
when(sources.resolveBatch(refs)).thenReturn(List.of(source));when(mapper.flags(List.of(1L))).thenReturn(List.of(new AppointmentBatchPreviewMapper.Flags(1L,false,false,true))); when(sources.resolveBatch(refs)).thenReturn(List.of(source));when(mapper.flags(List.of(1L))).thenReturn(List.of(new AppointmentBatchPreviewMapper.Flags(1L,false,false,true)));
when(verification.inspect(source)).thenReturn(new AppointmentCargoVerificationService.Context(source,null,6,0,10,4L,false,List.of("VERIFY_CARGO"),List.of())); when(verification.inspect(source)).thenReturn(new AppointmentCargoVerificationService.Context(source,null,6,0,10,List.of(),4L,false,List.of("VERIFY_CARGO"),List.of()));
var service=new AppointmentBatchPreviewService(sources,mapper,verification); var service=new AppointmentBatchPreviewService(sources,mapper,verification);
assertEquals(4,service.preview(refs).group().packages()); assertEquals(4,service.preview(refs).group().packages());
when(verification.inspect(source)).thenReturn(new AppointmentCargoVerificationService.Context(source,null,10,0,10,0L,false,List.of("VERIFY_CARGO"),List.of())); when(verification.inspect(source)).thenReturn(new AppointmentCargoVerificationService.Context(source,null,10,0,10,List.of(),0L,false,List.of("VERIFY_CARGO"),List.of()));
assertThrows(ResponseStatusException.class,()->service.preview(refs)); assertThrows(ResponseStatusException.class,()->service.preview(refs));
} }
private static AppointmentSourceService.Source source(String key,String customer,Long orderId,String origin,String destination) { private static AppointmentSourceService.Source source(String key,String customer,Long orderId,String origin,String destination) {
@@ -13,7 +13,7 @@ import static org.mockito.Mockito.*;
class AppointmentCargoVerificationBatchTest { class AppointmentCargoVerificationBatchTest {
@Test void inspectionUsesTwoBoundedBatchesAndRetainsPerSourcePrerequisitesAndAllocations() { @Test void inspectionUsesTwoBoundedBatchesAndRetainsPerSourcePrerequisitesAndAllocations() {
var sources=mock(AppointmentSourceService.class);var mapper=mock(AppointmentCargoVerificationMapper.class); var sources=mock(AppointmentSourceService.class);var mapper=mock(AppointmentCargoVerificationMapper.class);
var service=new AppointmentCargoVerificationService(sources,mapper,new ObjectMapper()); var service=new AppointmentCargoVerificationService(sources,mapper,new ObjectMapper(),new AppointmentCargoReferenceRepository(mapper));
var refs=IntStream.rangeClosed(1,200).mapToObj(i->new AppointmentCoverageService.SourceRef("C","ORDER:"+i,"FBA:ONT8")).toList(); var refs=IntStream.rangeClosed(1,200).mapToObj(i->new AppointmentCoverageService.SourceRef("C","ORDER:"+i,"FBA:ONT8")).toList();
when(sources.inspectBatch(anyList())).thenAnswer(call->call.<List<AppointmentCoverageService.SourceRef>>getArgument(0).stream().map(ref-> when(sources.inspectBatch(anyList())).thenAnswer(call->call.<List<AppointmentCoverageService.SourceRef>>getArgument(0).stream().map(ref->
ref.sourceKey().equals("ORDER:2")?new AppointmentSourceService.Resolution(ref,null,new ResponseStatusException(HttpStatus.BAD_REQUEST,"接仓点不在启用的美国仓目录中")): ref.sourceKey().equals("ORDER:2")?new AppointmentSourceService.Resolution(ref,null,new ResponseStatusException(HttpStatus.BAD_REQUEST,"接仓点不在启用的美国仓目录中")):
@@ -25,7 +25,7 @@ class AppointmentCargoVerificationBatchTest {
assertEquals("接仓点不在启用的美国仓目录中",assertThrows(ResponseStatusException.class,()->result.get(1).requireContext()).getReason()); assertEquals("接仓点不在启用的美国仓目录中",assertThrows(ResponseStatusException.class,()->result.get(1).requireContext()).getReason());
assertEquals(10L,result.get(199).requireContext().availablePackages()); assertEquals(10L,result.get(199).requireContext().availablePackages());
verify(sources,times(2)).inspectBatch(argThat(batch->batch.size()==100)); verify(sources,times(2)).inspectBatch(argThat(batch->batch.size()==100));
verify(mapper,times(2)).latestBatch(anyList());verify(mapper,times(2)).allocatedBatch(anyList());verifyNoMoreInteractions(mapper,sources); verify(mapper,times(2)).latestBatch(anyList());verify(mapper,times(2)).allocatedBatch(anyList());verify(mapper,times(2)).originalShipmentReferences(anyList());verifyNoMoreInteractions(mapper,sources);
when(sources.inspectBatch(anyList())).thenThrow(new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"目录不可用")); when(sources.inspectBatch(anyList())).thenThrow(new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"目录不可用"));
assertEquals(503,assertThrows(ResponseStatusException.class,()->service.inspectBatch(refs)).getStatusCode().value()); assertEquals(503,assertThrows(ResponseStatusException.class,()->service.inspectBatch(refs)).getStatusCode().value());
} }
@@ -24,6 +24,21 @@ class AppointmentCoverageServiceTest {
assertEquals("America/Los_Angeles",result.appointments().get(0).timezone()); assertEquals("EXT-1",result.appointments().get(0).confirmationNo());assertFalse(result.missingFields().contains("APPOINTMENT_TIMEZONE_UNVERIFIED")); assertEquals("America/Los_Angeles",result.appointments().get(0).timezone()); assertEquals("EXT-1",result.appointments().get(0).confirmationNo());assertFalse(result.missingFields().contains("APPOINTMENT_TIMEZONE_UNVERIFIED"));
assertFalse(result.changeReviewRequired()); verifyNoInteractions(gateway); assertFalse(result.changeReviewRequired()); verifyNoInteractions(gateway);
} }
@Test void confirmedNumberWithoutATimeRemainsConfirmedAndQuantityOnlyVerificationKeepsOriginalReferences() {
var mapper=mock(AppointmentCoverageMapper.class);
when(mapper.sources(anyList())).thenReturn(List.of(order()));
when(mapper.appointments(anyList())).thenReturn(List.of(new AppointmentCoverageMapper.Fact(1L,"A","AP-1","SUCCESS",null,null,AFTER,10L,20,"ONT8","ORDER:1","ORDER:2","EXT-1","America/Los_Angeles",1)));
var evidence=new AppointmentCargoVerification();evidence.setCustomerId("A");evidence.setSourceKey("ORDER:1");evidence.setDestinationKey("FBA:ONT8");
evidence.setSourceVersion("ORDER:2");evidence.setSourcePackages(20);evidence.setPackageCount(20);evidence.setVersion(1);evidence.setShipmentReferencesJson("[]");
when(mapper.verifications(anyList())).thenReturn(List.of(evidence));
var service=new AppointmentCoverageService(mapper,mock(AppointmentExternalSourceGateway.class));
var refs=List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"));
var result=service.query(refs).get(0);
assertEquals("CONFIRMED",result.status());assertEquals("EXT-1",result.appointments().get(0).confirmationNo());
assertNull(result.appointments().get(0).confirmedAt());assertFalse(result.missingFields().contains("FBA_SHIPMENT_REFERENCES"));
when(mapper.sources(anyList())).thenReturn(List.of(new AppointmentCoverageMapper.Source(1L,"A","FBA","ONT8",20,2,BEFORE,true)));
assertTrue(service.query(refs).get(0).missingFields().contains("FBA_SHIPMENT_REFERENCES"));
}
@Test void partialPendingAndDuplicateClaimsNeverAppearAsCompleteCoverage() { @Test void partialPendingAndDuplicateClaimsNeverAppearAsCompleteCoverage() {
var mapper=mock(AppointmentCoverageMapper.class); when(mapper.sources(anyList())).thenReturn(List.of(order())); var mapper=mock(AppointmentCoverageMapper.class); when(mapper.sources(anyList())).thenReturn(List.of(order()));
var service=new AppointmentCoverageService(mapper,mock(AppointmentExternalSourceGateway.class)); var service=new AppointmentCoverageService(mapper,mock(AppointmentExternalSourceGateway.class));
@@ -26,6 +26,54 @@ class AppointmentCargoVerificationPersistenceTest {
private static void flags(AnnotationConfigApplicationContext context) { private static void flags(AnnotationConfigApplicationContext context) {
when(context.getBean(AppointmentBatchPreviewMapper.class).flags(List.of(1L))).thenReturn(List.of(new AppointmentBatchPreviewMapper.Flags(1L,false,false,false))); when(context.getBean(AppointmentBatchPreviewMapper.class).flags(List.of(1L))).thenReturn(List.of(new AppointmentBatchPreviewMapper.Flags(1L,false,false,false)));
} }
@Test void readsOriginalShipmentAndTrackingNumbersForTheAuthorizedCustomerAndKeepsExplicitVerificationFirst() {
try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) {
var db=new JdbcTemplate(context.getBean(DataSource.class));
db.update("UPDATE ll_order SET customer_id='A' WHERE id=1");
db.update("INSERT INTO ll_order_detail(id,order_id,customer_id,shipment_no,fba_tracking_no) VALUES(1,1,'A',' FBA-ORIGINAL ','TRACK-ORIGINAL'),(2,1,'B','OTHER-CUSTOMER','OTHER-TRACK')");
var cargo=context.getBean(AppointmentCargoVerificationService.class);
var source=context.getBean(AppointmentSourceService.class).resolveBatch(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"))).get(0);
var first=cargo.inspect(source);
assertNull(first.verification());assertEquals(List.of("FBA-ORIGINAL","TRACK-ORIGINAL"),first.effectiveShipmentReferences());
cargo.save("ORDER:1","A",save(0,10,"quantity-only"),"operator","客服");
assertEquals(first.effectiveShipmentReferences(),cargo.inspect(source).effectiveShipmentReferences());
var details=new FbaAppointmentDetails(java.time.LocalDate.of(2026,10,28),null,null,"EXT-ORIGINAL",null,null,"SUCCESS",null,null,null);
var appointment=context.getBean(InboundFbaAppointmentService.class).createFromSources("operator",appointment("reuse-original",details));
assertEquals(first.effectiveShipmentReferences(),appointment.items().get(0).shipmentReferences());
assertNull(appointment.appointmentAt());assertEquals("SUCCESS",appointment.appointmentStatus());
cargo.save("ORDER:1","A",save(1,10,"explicit","FBA-VERIFIED"),"operator","客服");
assertEquals(List.of("FBA-VERIFIED"),cargo.inspect(source).effectiveShipmentReferences());
assertEquals(" FBA-ORIGINAL ",db.queryForObject("SELECT shipment_no FROM ll_order_detail WHERE id=1",String.class));
db.update("UPDATE ll_order SET order_type='WAREHOUSE_TRANSFER' WHERE id=1");
db.update("INSERT INTO ll_warehouse_transfer_order(id,lastleg_order_id,customer_id,fba_shipment_no,tracking_no) VALUES(1,1,'A','TRANSFER-FBA','TRANSFER-TRACK')");
assertEquals(List.of("TRANSFER-FBA","TRANSFER-TRACK"),context.getBean(AppointmentCargoReferenceRepository.class).originalReferences(List.of(source)).get(List.of("A","ORDER:1")));
}
}
/** 采用最新资料与新建共用货件号优先级:未核实、仅核箱数和明确核实均写入真实快照。 */
@Test void adoptingLatestCargoKeepsOriginalReferencesUntilExplicitVerificationReplacesThem() {
try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) {
var db=new JdbcTemplate(context.getBean(DataSource.class));
db.update("UPDATE ll_order SET customer_id='A' WHERE id=1");
db.update("INSERT INTO ll_order_detail(id,order_id,customer_id,shipment_no,fba_tracking_no) VALUES(1,1,'A','FBA-ORIGINAL','TRACK-ORIGINAL'),(2,1,'B','OTHER-CUSTOMER','OTHER-TRACK')");
var cargo=context.getBean(AppointmentCargoVerificationService.class);
var appointments=context.getBean(InboundFbaAppointmentService.class);
var created=appointments.createFromSources("operator",appointment("refresh-original",confirmed()));
var transaction=new org.springframework.transaction.support.TransactionTemplate(context.getBean(org.springframework.jdbc.datasource.DataSourceTransactionManager.class));
var original=List.of("FBA-ORIGINAL","TRACK-ORIGINAL");
assertEquals(original,created.items().get(0).shipmentReferences());
db.update("UPDATE ll_order_detail SET shipment_no='FBA-LATEST' WHERE id=1");
var latestOriginal=List.of("FBA-LATEST","TRACK-ORIGINAL");
for(int version=0;version<=2;version++) {
if(version==1)cargo.save("ORDER:1","A",save(0,10,"refresh-quantity-only"),"operator","客服");
if(version==2)cargo.save("ORDER:1","A",save(1,10,"refresh-explicit","FBA-VERIFIED"),"operator","客服");
Integer expectedVersion=version==0?null:version;
var latest=transaction.execute(status->appointments.refreshReceivingCargo(created.batchKey(),"A",List.of(new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.CargoVersion("ORDER:1","FBA:ONT8","ORDER:1",expectedVersion))));
assertNotNull(latest);assertEquals(version==2?List.of("FBA-VERIFIED"):latestOriginal,latest.items().get(0).shipmentReferences());
assertEquals(latest,appointments.detail(created.appointmentNo()),"刷新后的货件号必须实际持久化");
}
assertEquals("FBA-LATEST",db.queryForObject("SELECT shipment_no FROM ll_order_detail WHERE id=1",String.class));
}
}
@Test void savesWithoutAppointmentOrConfirmationAndReplaysOriginalVersionAfterLaterEdits() { @Test void savesWithoutAppointmentOrConfirmationAndReplaysOriginalVersionAfterLaterEdits() {
try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) { try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) {
var service=context.getBean(AppointmentCargoVerificationService.class); var service=context.getBean(AppointmentCargoVerificationService.class);
@@ -211,7 +211,9 @@ class FbaAppointmentPersistenceTest {
int start=baseline.indexOf("CREATE TABLE IF NOT EXISTS "+table+" (");assertTrue(start>=0); 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); 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,customer_id VARCHAR(128),customer_name VARCHAR(128))");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),order_type VARCHAR(32) DEFAULT 'FBA')");statement.execute("INSERT INTO ll_order(id) VALUES(1),(2)");
statement.execute("CREATE TABLE ll_order_detail(id BIGINT PRIMARY KEY,order_id BIGINT,customer_id VARCHAR(128),shipment_no VARCHAR(128),fba_tracking_no VARCHAR(128))");
statement.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT PRIMARY KEY,lastleg_order_id BIGINT,customer_id VARCHAR(128),fba_shipment_no VARCHAR(128),tracking_no VARCHAR(128))");
statement.execute("CREATE TABLE ll_dispatch_item(id BIGINT,order_id BIGINT,customer_id VARCHAR(128),dispatch_order_id BIGINT,plan_pallet_id BIGINT,source_stage VARCHAR(32),package_count INT)"); statement.execute("CREATE TABLE ll_dispatch_item(id BIGINT,order_id BIGINT,customer_id VARCHAR(128),dispatch_order_id BIGINT,plan_pallet_id BIGINT,source_stage VARCHAR(32),package_count INT)");
statement.execute("CREATE TABLE ll_dispatch_order(id BIGINT,dispatch_no VARCHAR(64),record_version BIGINT,status VARCHAR(32))"); statement.execute("CREATE TABLE ll_dispatch_order(id BIGINT,dispatch_no VARCHAR(64),record_version BIGINT,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);} String work;try(var input=FbaAppointmentPersistenceTest.class.getResourceAsStream("/db/appointment-work.sql")){work=new String(Objects.requireNonNull(input).readAllBytes(),StandardCharsets.UTF_8);}
@@ -254,7 +256,8 @@ class FbaAppointmentPersistenceTest {
} }
@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 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 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 AppointmentCargoReferenceRepository cargoReferences(AppointmentCargoVerificationMapper mapper){return new AppointmentCargoReferenceRepository(mapper);}
@Bean AppointmentCargoVerificationService verificationService(AppointmentSourceService sources,AppointmentCargoVerificationMapper mapper,ObjectMapper json,AppointmentCargoReferenceRepository references){return new AppointmentCargoVerificationService(sources,mapper,json,references);}
@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;} @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;}
@Bean AppointmentBatchPreviewMapper flags(){var mock=mock(AppointmentBatchPreviewMapper.class);when(mock.flags(anyList())).thenAnswer(call->{List<Long> ids=call.getArgument(0);return ids.stream().map(id->new AppointmentBatchPreviewMapper.Flags(id,false,false,false)).toList();});return mock;} @Bean AppointmentBatchPreviewMapper flags(){var mock=mock(AppointmentBatchPreviewMapper.class);when(mock.flags(anyList())).thenAnswer(call->{List<Long> ids=call.getArgument(0);return ids.stream().map(id->new AppointmentBatchPreviewMapper.Flags(id,false,false,false)).toList();});return mock;}
@Bean ObjectMapper json(){return new ObjectMapper().findAndRegisterModules();} @Bean ObjectMapper json(){return new ObjectMapper().findAndRegisterModules();}
@@ -138,6 +138,16 @@ class InboundFbaAppointmentServiceTest {
assertThrows(ResponseStatusException.class,()->service.createFromSources("other",request("original",pending(null),"ORDER:1","ORDER:2"))); assertThrows(ResponseStatusException.class,()->service.createFromSources("other",request("original",pending(null),"ORDER:1","ORDER:2")));
verify(headers,times(1)).insert(any(InboundFbaAppointment.class)); verify(headers,times(1)).insert(any(InboundFbaAppointment.class));
} }
@Test void savesExternalConfirmationWithoutInventingAConfirmedTimeFromThePlannedDate() {
var details=new FbaAppointmentDetails(LocalDate.of(2026,10,28),null,null,"EXT-NO-TIME",null,null,"SUCCESS",null,null,null);
var result=service.createFromSources("operator",request("confirmed-without-time",details,"ORDER:1"));
assertEquals("SUCCESS",result.appointmentStatus());assertEquals("EXT-NO-TIME",result.confirmationNo());
assertEquals(LocalDate.of(2026,10,28),result.plannedDate());assertNull(result.appointmentAt());
assertNull(result.appointmentEndAt());assertNull(result.appointmentStartUtc());assertNull(result.appointmentEndUtc());
assertEquals(service.detail(result.appointmentNo()),result);
var invalid=new FbaAppointmentDetails(null,null,null,"EXT-END-ONLY",null,at,"SUCCESS",null,null,null);
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("end-only",invalid,"ORDER:2")));
}
@Test void confirmationNeedsExternalNumberAndLocationTimezone() { @Test void confirmationNeedsExternalNumberAndLocationTimezone() {
var missing=new FbaAppointmentDetails(null,null,null,null,at,null,"SUCCESS",null,null,null); var missing=new FbaAppointmentDetails(null,null,null,null,at,null,"SUCCESS",null,null,null);
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("no-number",missing,"ORDER:1"))); assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("no-number",missing,"ORDER:1")));
@@ -12,17 +12,29 @@ import static org.mockito.Mockito.*;
/** 选入排车前的确认资格按真实客户和逐票覆盖核对。 */ /** 选入排车前的确认资格按真实客户和逐票覆盖核对。 */
class AppointmentDispatchAvailabilityTest { class AppointmentDispatchAvailabilityTest {
private final AppointmentDispatchAvailability policy=new AppointmentDispatchAvailability(mock(FbaReceivingStore.class)); private final AppointmentDispatchAvailability policy=new AppointmentDispatchAvailability(mock(FbaReceivingStore.class));
/** 预约头成功不能替代当前客户的有效确认,取消及无时间均不放行。 */ /** 正式确认可以尚无时段,仍须匹配当前客户、来源和目的仓;取消和未确认均不放行。 */
@Test void onlyCurrentConfirmedCustomerSourceAndDestinationCanEnterDispatch() { @Test void onlyCurrentConfirmedCustomerSourceAndDestinationCanEnterDispatch() {
assertThat(count(snapshot("ACTIVE","SUCCESS","America/New_York",LocalDateTime.of(2026,10,26,10,0),"A"),"A")).isEqualTo(20); assertThat(count(snapshot("ACTIVE","SUCCESS","America/New_York",LocalDateTime.of(2026,10,26,10,0),"A"),"A")).isEqualTo(20);
assertThat(count(snapshot("ACTIVE","SUCCESS","America/New_York",LocalDateTime.of(2026,10,26,10,0),"B"),"A")).isZero(); assertThat(count(snapshot("ACTIVE","SUCCESS","America/New_York",LocalDateTime.of(2026,10,26,10,0),"B"),"A")).isZero();
assertThat(count(snapshot("CANCELLED","SUCCESS","America/New_York",LocalDateTime.of(2026,10,26,10,0),"A"),"A")).isZero(); assertThat(count(snapshot("CANCELLED","SUCCESS","America/New_York",LocalDateTime.of(2026,10,26,10,0),"A"),"A")).isZero();
assertThat(count(snapshot("ACTIVE","PENDING","America/New_York",LocalDateTime.of(2026,10,26,10,0),"A"),"A")).isZero(); assertThat(count(snapshot("ACTIVE","PENDING","America/New_York",LocalDateTime.of(2026,10,26,10,0),"A"),"A")).isZero();
assertThat(count(snapshot("ACTIVE","SUCCESS","America/New_York",null,"A"),"A")).isZero(); assertThat(count(snapshot("ACTIVE","SUCCESS","America/New_York",null,"A"),"A")).isEqualTo(20);
var confirmed=snapshot("ACTIVE","SUCCESS","America/New_York",LocalDateTime.of(2026,10,26,10,0),"A"); var confirmed=snapshot("ACTIVE","SUCCESS","America/New_York",LocalDateTime.of(2026,10,26,10,0),"A");
assertThat(policy.confirmedPackages(confirmed,"A","ORDER:2","FBA:ACY2")).isZero(); assertThat(policy.confirmedPackages(confirmed,"A","ORDER:2","FBA:ACY2")).isZero();
assertThat(policy.confirmedPackages(confirmed,"A","ORDER:1","FBA:ONT8")).isZero(); assertThat(policy.confirmedPackages(confirmed,"A","ORDER:1","FBA:ONT8")).isZero();
} }
/** 没有确认时间不代表可以省略外部约号,或单独填写结束时间及偏移。 */
@Test void untimedConfirmationStillRequiresExternalNumberAndConsistentOptionalWindow() {
var before=snapshot("ACTIVE","SUCCESS","America/New_York",null,"A");var old=before.agreements().get(0);
for(var details:List.of(
new FbaAppointmentDetails(null,null,null," ",null,null,"SUCCESS",null,null,null),
new FbaAppointmentDetails(null,null,null,"CONF-1",null,LocalDateTime.of(2026,10,26,10,0),"SUCCESS",null,null,null),
new FbaAppointmentDetails(null,null,null,"CONF-1",null,null,"SUCCESS",null,"-04:00",null),
new FbaAppointmentDetails(null,null,null,"CONF-1",null,null,"SUCCESS",null,null,"-04:00"))) {
var agreement=new Agreement(old.agreementKey(),old.version(),old.status(),old.destinationKey(),details,old.timezone(),old.members(),old.warehouseEvidence());
assertThat(count(new BatchReceiving(before.batchKey(),List.of(agreement),List.of(),List.of()),"A")).isZero();
}
}
/** 夏令时歧义及未维护时区不能猜成一个可排时间。 */ /** 夏令时歧义及未维护时区不能猜成一个可排时间。 */
@Test void invalidOrAmbiguousLocalTimeCannotAuthorizeDispatch() { @Test void invalidOrAmbiguousLocalTimeCannotAuthorizeDispatch() {
assertThat(count(snapshot("ACTIVE","SUCCESS",null,LocalDateTime.of(2026,10,26,10,0),"A"),"A")).isZero(); assertThat(count(snapshot("ACTIVE","SUCCESS",null,LocalDateTime.of(2026,10,26,10,0),"A"),"A")).isZero();
@@ -192,7 +192,7 @@ class AppointmentWorkDeliveryProviderTest {
@Test void handledPrerequisiteCommitsTheCallerTransactionThroughTheRealCargoProxy() { @Test void handledPrerequisiteCommitsTheCallerTransactionThroughTheRealCargoProxy() {
var sourceService=mock(com.baiyue.lastleg.appointmentplanning.AppointmentSourceService.class); var sourceService=mock(com.baiyue.lastleg.appointmentplanning.AppointmentSourceService.class);
when(sourceService.inspectBatch(List.of(new SourceRef("C","ORDER:11",null)))).thenReturn(List.of(new Resolution(new SourceRef("C","ORDER:11",null),null,new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST,"当前货源类型不支持FBA预约")))); when(sourceService.inspectBatch(List.of(new SourceRef("C","ORDER:11",null)))).thenReturn(List.of(new Resolution(new SourceRef("C","ORDER:11",null),null,new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST,"当前货源类型不支持FBA预约"))));
var cargo=new AppointmentCargoVerificationService(sourceService,mock(com.baiyue.lastleg.appointmentplanning.AppointmentCargoVerificationMapper.class),new com.fasterxml.jackson.databind.ObjectMapper()); var cargo=new AppointmentCargoVerificationService(sourceService,mock(com.baiyue.lastleg.appointmentplanning.AppointmentCargoVerificationMapper.class),new com.fasterxml.jackson.databind.ObjectMapper(),mock(com.baiyue.lastleg.appointmentplanning.AppointmentCargoReferenceRepository.class));
var ds=new org.h2.jdbcx.JdbcDataSource();ds.setURL("jdbc:h2:mem:work-read-tx-"+java.util.UUID.randomUUID()+";DB_CLOSE_DELAY=-1"); var ds=new org.h2.jdbcx.JdbcDataSource();ds.setURL("jdbc:h2:mem:work-read-tx-"+java.util.UUID.randomUUID()+";DB_CLOSE_DELAY=-1");
var manager=new org.springframework.jdbc.datasource.DataSourceTransactionManager(ds); var manager=new org.springframework.jdbc.datasource.DataSourceTransactionManager(ds);
var proxy=new org.springframework.aop.framework.ProxyFactory(cargo);proxy.setProxyTargetClass(true); var proxy=new org.springframework.aop.framework.ProxyFactory(cargo);proxy.setProxyTargetClass(true);
@@ -210,6 +210,26 @@ class AppointmentWorkDispatchPersistenceTest {
assertEquals(1,rows.size());assertEquals("DISPATCH:1",rows.get(0).get("BATCH_KEY")); assertEquals(1,rows.size());assertEquals("DISPATCH:1",rows.get(0).get("BATCH_KEY"));
assertEquals("101",rows.get(0).get("CUSTOMER_ID"));assertEquals(physical.packages(),rows.get(0).get("PACKAGE_COUNT")); assertEquals("101",rows.get(0).get("CUSTOMER_ID"));assertEquals(physical.packages(),rows.get(0).get("PACKAGE_COUNT"));
} }
/** 没有确认时段的正式预约仍能带入本车,读写分配均保留真实数量且不虚构时间。 */
@Test void confirmedAppointmentWithoutTimeCanBeSelectedAndLinkedToDispatch() {
var original=confirmed("101");
var details=new FbaAppointmentDetails(null,null,null,original.details().confirmationNo(),null,null,"SUCCESS",null,null,null);
var untimed=new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Agreement(original.agreementKey(),original.version(),original.status(),original.destinationKey(),details,original.timezone(),original.members(),original.warehouseEvidence());
when(receiving.currentAgreement(batch,"101")).thenReturn(untimed);
when(receiving.snapshots(anyList())).thenReturn(List.of(new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.BatchReceiving(batch,List.of(untimed,confirmed("202")),List.of(),List.of())));
var identity=new CandidatePhysical("101",111L);
var reference=candidates.batchReferences(List.of(identity)).get(identity);
assertTrue(reference.blockReasons().isEmpty());assertEquals(80,reference.appointmentBatches().get(0).sources().get(0).packageCount());
var candidate=candidates.read(batch).candidates().stream().filter(c->"101".equals(c.customerId())).findFirst().orElseThrow();
assertTrue(candidate.blockReasons().isEmpty());assertEquals(80,candidate.packageCount());
var source=binding.allocationOptions(1,List.of(identity)).get(identity).get(0);
assertTrue(source.blockReasons().isEmpty());assertEquals(1,source.options().size());
var option=source.options().get(0);assertEquals("CONF-101",option.confirmationNo());assertEquals(80,option.availablePackages());
assertNull(option.appointmentAt());assertNull(option.appointmentEndAt());
tx.executeWithoutResult(s->{bind(11,21,22);binding.bindItems(1,List.of(item(31)),List.of());});
assertEquals(80,jdbc.queryForObject("SELECT package_count FROM ll_dispatch_appointment_allocation WHERE dispatch_item_id=11",Integer.class));
assertNull(tx.execute(status->binding.reviewBlockReason("D-1",null,null)));
}
/** 查询本车重分配量排除本车原占用,保留其他车占用及真实业务标识。 */ /** 查询本车重分配量排除本车原占用,保留其他车占用及真实业务标识。 */
@Test void ownedAllocationOptionsExcludeOnlyThisDispatchAndKeepOtherCarsQuota() { @Test void ownedAllocationOptionsExcludeOnlyThisDispatchAndKeepOtherCarsQuota() {
tx.executeWithoutResult(s->bind(11)); tx.executeWithoutResult(s->bind(11));