From 4f2d90b490f355b3f575bf0aedf000e5f2a5b3b2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 28 Oct 2026 11:57:09 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E9=A2=84=E7=BA=A6=E8=B4=A7?= =?UTF-8?q?=E7=89=A9=E8=B5=84=E6=96=99=E4=B8=8E=E5=8F=AF=E9=80=89=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E6=97=B6=E9=97=B4=E5=B9=B6=E4=BF=AE=E5=A4=8D=E6=8E=92?= =?UTF-8?q?=E8=BD=A6=E8=A1=94=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AppointmentCargoReferenceRepository.java | 31 ++++++++++++ .../AppointmentCargoVerificationMapper.java | 17 +++++++ .../AppointmentCargoVerificationService.java | 29 +++++++---- .../AppointmentCoverageService.java | 6 ++- .../AppointmentTimePolicy.java | 8 +++- .../AppointmentWorkDispatchPort.java | 3 +- .../service/InboundFbaAppointmentService.java | 25 ++++++---- .../AppointmentDispatchAllocationReader.java | 6 +-- .../work/AppointmentDispatchAvailability.java | 25 ++++------ .../AppointmentWorkAppointmentChanges.java | 3 +- .../AppointmentWorkDispatchCandidates.java | 2 +- .../appointment-cargo-verification-v1.yaml | 1 + .../openapi/fba-appointment-calendar-v1.yaml | 2 +- .../openapi/overseas-business-v1.yaml | 3 +- .../AppointmentBatchPreviewServiceTest.java | 6 +-- ...AppointmentCargoVerificationBatchTest.java | 4 +- .../AppointmentCoverageServiceTest.java | 15 ++++++ ...tmentCargoVerificationPersistenceTest.java | 48 +++++++++++++++++++ .../FbaAppointmentPersistenceTest.java | 7 ++- .../InboundFbaAppointmentServiceTest.java | 10 ++++ .../AppointmentDispatchAvailabilityTest.java | 16 ++++++- .../AppointmentWorkDeliveryProviderTest.java | 2 +- ...ppointmentWorkDispatchPersistenceTest.java | 20 ++++++++ 23 files changed, 233 insertions(+), 56 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoReferenceRepository.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoReferenceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoReferenceRepository.java new file mode 100644 index 000000000..9218c70bb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoReferenceRepository.java @@ -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> originalReferences(List 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.>of() + : mapper.originalShipmentReferences(orders).stream().collect(Collectors.groupingBy( + row -> new AppointmentCoverageMapper.OrderRef(row.customerId(), row.orderId()))); + var result = new HashMap, List>(); + 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); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationMapper.java index dc4710531..3cf65ed73 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationMapper.java @@ -8,6 +8,23 @@ import org.apache.ibatis.annotations.Select; @Mapper public interface AppointmentCargoVerificationMapper extends BaseMapper { + record ShipmentReference(String customerId, Long orderId, String shipmentReference) { } + + /** 按已授权的客户与原订单读取原有货件号和追踪号,仓储转运读取其正式来源。 */ + @Select({""}) + java.util.List originalShipmentReferences(@Param("orders") java.util.List orders); + /** 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") @Options(flushCache = Options.FlushCachePolicy.TRUE, useCache = false) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java index e0bc99167..58f5c4cb4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java @@ -21,10 +21,12 @@ public class AppointmentCargoVerificationService { private final AppointmentSourceService sources; private final AppointmentCargoVerificationMapper mapper; private final ObjectMapper json; + private final AppointmentCargoReferenceRepository references; + /** 核实记录与原货物资料分别读取,已有货件号无需重复录入。 */ public AppointmentCargoVerificationService(AppointmentSourceService sources, AppointmentCargoVerificationMapper mapper, - ObjectMapper json) { - this.sources=sources; this.mapper=mapper; this.json=json; + ObjectMapper json, AppointmentCargoReferenceRepository references) { + 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, @Min(0) int expectedVersion, @Positive Integer packageCount, @@ -34,7 +36,7 @@ public class AppointmentCargoVerificationService { Integer packageCount, List shipmentReferences, String remark, String operatorName, Instant verifiedAt) { } public record Context(AppointmentSourceService.Source source, Verification verification, - long allocatedPackages, long unknownAllocationCount, Integer effectivePackages, + long allocatedPackages, long unknownAllocationCount, Integer effectivePackages, List effectiveShipmentReferences, Long availablePackages, boolean sourceChanged, List allowedActions, List blockReasons) { } @@ -50,12 +52,13 @@ public class AppointmentCargoVerificationService { public record Inspection(AppointmentCoverageService.SourceRef reference,Context context,ResponseStatusException error) { 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 inspectBatch(List refs) { var result=new ArrayList(); for(int start=0;startr.error()==null).map(AppointmentSourceService.Resolution::source).toList()); var valid=resolved.stream().filter(r->r.error()==null).map(AppointmentSourceService.Resolution::reference).toList(); var latest=new HashMap,AppointmentCargoVerification>(); var allocations=new HashMap,AppointmentCargoVerificationMapper.Allocation>(); @@ -65,18 +68,19 @@ public class AppointmentCargoVerificationService { } for(var resolution:resolved) { 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); } - /** Uses an already-authorized batched source resolution; never repeats warehouse HTTP lookups per row. */ + /** 使用已授权来源读取本票核实记录及原有货件资料。 */ public Context inspect(AppointmentSourceService.Source source) { var row=mapper.latest(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 originals) { boolean changed=row!=null && changed(row,source); Integer total=row==null?source.packages():row.getPackageCount(); var reasons=new ArrayList<>(source.blockReasons()); @@ -88,10 +92,17 @@ public class AppointmentCargoVerificationService { ?null:total.longValue()-allocation.packages(); // Actions describe this endpoint's business capability; controller permissions remain mandatory. var actions=source.canFollowup()?List.of("VERIFY_CARGO"):List.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)); } + /** 对预约明细和核实表单提供同一批原始货件资料,不改写原订单。 */ + public Map,List> originalShipmentReferences(List sources) { + return references.originalReferences(sources); + } + @Transactional(isolation=Isolation.READ_COMMITTED) public Verification save(String sourceKey,String customerId,Save request,String operatorId,String operatorName) { validate(request,operatorId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageService.java index bd0212c61..4b6824edc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageService.java @@ -20,6 +20,7 @@ public class AppointmentCoverageService { this.mapper=mapper; this.external=external; } + /** 按真实货物与外部约号核对覆盖,确认时段缺失不伪造时间也不撤销已确认事实。 */ public List query(List input) { 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()))) { @@ -85,7 +86,7 @@ public class AppointmentCoverageService { 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; } 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(); 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()); @@ -99,7 +100,8 @@ public class AppointmentCoverageService { else if (confirmed"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"); 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)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentTimePolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentTimePolicy.java index 97198db55..105937248 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentTimePolicy.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentTimePolicy.java @@ -7,15 +7,19 @@ import java.time.ZoneId; import java.time.ZoneOffset; import java.util.List; -/** Resolves a location's appointment window without using the operator's system time zone. */ +/** 解析预约地点的真实确认时段,不使用操作人的系统时区或虚构确认时间。 */ public final class AppointmentTimePolicy { private AppointmentTimePolicy() { } public record Window(Instant start, Instant end, String timezone) { } + /** 未提供确认时段时保持为空;提供时仍校验地区时区、夏令时及先后顺序。 */ public static Window resolve(LocalDateTime start, LocalDateTime end, String timezone, 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("请先补齐预约地点时区"); final ZoneId zone; try { zone = ZoneId.of(timezone); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java index 9a5ff8842..f11b6cfa8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java @@ -18,6 +18,7 @@ public interface AppointmentWorkDispatchPort { Map candidateBatches(List candidates); /** 只读取本车已选实物的逐票预约选项;本车原占用不扣减本车可重新分配量。 */ Map> allocationOptions(long dispatchId,List items); + /** 正式确认的已知时段原样返回;未提供确认时间时保持为空。 */ record AllocationOption(String batchKey,String batchNo,Long recordVersion,int availablePackages, String confirmationNo,String appointmentAt,String appointmentEndAt,String timezone) { } record AllocationSource(String sourceKey,String orderNo,String inboundCode,String destinationKey,Integer packageCount, @@ -33,7 +34,7 @@ public interface AppointmentWorkDispatchPort { String departureBlockReason(String dispatchNo); record ReceivingWindow(String batchKey,String batchNo,String customerId,String destinationKey,String confirmationNo, 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); List reviewEvidence(String dispatchNo); void removeItem(long dispatchId,long itemId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java index b58948ce6..7d7653e5d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java @@ -381,7 +381,7 @@ public class InboundFbaAppointmentService { 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 expected) { 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 resolved=resolveForWrite(refs);var evidence=verification(resolved); + var originals=verification.originalShipmentReferences(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()); @@ -407,7 +408,7 @@ public class InboundFbaAppointmentService { 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()));} + try {item.setShipmentReferencesJson(json.writeValueAsString(effectiveShipmentReferences(source,verified,originals)));} catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException("核实货件资料无法保存",error);} } 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) { 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.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()); } else { 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()); return verified==null?source.packages():verified.packageCount(); } + /** 保存外部约号及真实已知时段;未提供确认时间时不从计划日期伪造。 */ private void apply(InboundFbaAppointment appointment,FbaAppointmentDetails details,List rows, Map evidence) { String status=status(details.appointmentStatus()); @@ -572,26 +574,27 @@ public class InboundFbaAppointmentService { appointment.setPlannedAt(details.plannedAt());appointment.setPlannedEndAt(details.plannedEndAt());appointment.setTimezone(timezone); appointment.setAppointmentStatus(status);appointment.setAppointmentRemark(clean(details.appointmentRemark())); 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 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) - || evidence.values().stream().anyMatch(v->v.shipmentReferences().isEmpty()) || !unverifiedIds.isEmpty() && queryMapper.countMissingShipmentReferences(unverifiedIds)>0) throw bad("正式确认前请核实货量并补齐各票FBA货件号或追踪号"); final AppointmentTimePolicy.Window window; try {window=AppointmentTimePolicy.resolve(details.appointmentAt(),details.appointmentEndAt(),timezone,offset(details.startOffset()),offset(details.endOffset()));} catch(IllegalArgumentException | DateTimeException error) {throw bad(error.getMessage());} 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 { 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); } } + /** 固化本次货量及有效货件号,优先核实记录,未补录时采用原货物资料。 */ private void writeItems(InboundFbaAppointment appointment,List rows,List previous, Map evidence,Map requested) { var existing=previous.stream().collect(Collectors.toMap(InboundFbaAppointmentItem::getSourceKey,item->item)); + var originals=verification.originalShipmentReferences(rows); for(var source:rows) { var item=existing.get(source.sourceKey());boolean fresh=item==null; 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); var verified=evidence.get(source.sourceKey()); 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);} try {if(fresh)itemMapper.insert(item);else itemMapper.updateById(item);} catch(DuplicateKeyException duplicate){throw conflict("所选货物刚被其他安排占用,请刷新后核对");} } } + /** 新建及采用最新资料均优先非空核实编号,未补录时使用同客户原订单编号。 */ + private static List effectiveShipmentReferences(AppointmentSourceService.Source source,AppointmentCargoVerificationService.Verification verified, + Map,List> 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 action) { return executeCommand(operation,operator,key,request,FbaAppointmentResponse.class,FbaAppointmentResponse::appointmentNo,action); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAllocationReader.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAllocationReader.java index cb62d2540..3a2d608e3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAllocationReader.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAllocationReader.java @@ -43,14 +43,14 @@ public class AppointmentDispatchAllocationReader { 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(); 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(); var details=agreement.details(); 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()"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()) && a.members().stream().anyMatch(m->snapshot.batchKey().equals(m.batchKey()) && customer.equals(m.customerId()))).toList(); if(agreements.size()!=1)return 0; var agreement=agreements.get(0);var details=agreement.details(); - if(details==null || !"SUCCESS".equals(details.appointmentStatus()) || details.appointmentAt()==null)return 0; - var start=confirmedInstant(details.appointmentAt(),agreement.timezone(),details.startOffset()); - if(start==null)return 0; - if(details.appointmentEndAt()!=null) { - var end=confirmedInstant(details.appointmentEndAt(),agreement.timezone(),details.endOffset()); - if(end==null || end.isBefore(start))return 0; - } + if(details==null || !"SUCCESS".equals(details.appointmentStatus()) || details.confirmationNo()==null || details.confirmationNo().isBlank())return 0; + // 与保存正式确认共用可选时段规则,不从计划日期推造送仓时间。 + try {AppointmentTimePolicy.resolve(details.appointmentAt(),details.appointmentEndAt(),agreement.timezone(),offset(details.startOffset()),offset(details.endOffset()));} + catch(IllegalArgumentException|DateTimeException error){return 0;} var members=agreement.members().stream().filter(m->snapshot.batchKey().equals(m.batchKey()) && customer.equals(m.customerId())).toList(); if(members.size()!=1)return 0; 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; } - /** 夏令时歧义须有明确偏移;不能把当地时间按服务器时区猜测。 */ - private Instant confirmedInstant(LocalDateTime at,String timezone,String offset) { - 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;} - } + /** 留空的夏令时时差保持未知,已填写值由正式时段规则核验。 */ + private ZoneOffset offset(String value) {return value==null || value.isBlank()?null:ZoneOffset.of(value.trim());} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkAppointmentChanges.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkAppointmentChanges.java index 5f3a6cad0..435a1510f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkAppointmentChanges.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkAppointmentChanges.java @@ -31,6 +31,7 @@ public class AppointmentWorkAppointmentChanges { } } 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(); @@ -44,7 +45,7 @@ public class AppointmentWorkAppointmentChanges { 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("有效收货预约缺少正式确认时间"); + if(details==null || !"SUCCESS".equals(details.appointmentStatus()) || details.confirmationNo()==null || details.confirmationNo().isBlank()) throw AppointmentWorkPolicy.unavailable("有效收货预约缺少外部约号"); target=target(details,timezone);status=details.appointmentStatus(); confirmed=slot(details.appointmentAt(),details.appointmentEndAt(),timezone); } else if(!plans.isEmpty()) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java index 96948f13c..f335c573b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java @@ -98,7 +98,7 @@ public class AppointmentWorkDispatchCandidates { if(item==null) { reasons.add("托盘含本批之外的货物,请先拆托或选择完整的关联批次"); continue; } long used=mapper.allocated(batch,part.customerId(),ref.sourceKey(),part.destinationKey(),-1); 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("原预约批次版本缺失"); allocations.add(new Allocation(candidate.planPalletId(),batch,appointment.version().longValue(),part.customerId(),ref.sourceKey(),part.destinationKey(),count)); } diff --git a/lastleg-service/src/main/resources/openapi/appointment-cargo-verification-v1.yaml b/lastleg-service/src/main/resources/openapi/appointment-cargo-verification-v1.yaml index c779987f0..9a9e0cdb4 100644 --- a/lastleg-service/src/main/resources/openapi/appointment-cargo-verification-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/appointment-cargo-verification-v1.yaml @@ -108,6 +108,7 @@ components: allocatedPackages: {type: integer, format: int64} unknownAllocationCount: {type: integer, format: int64} effectivePackages: {type: integer, nullable: true, description: 独立核实箱数;没有核实记录时采用原来源箱数} + effectiveShipmentReferences: {type: array, items: {type: string}, description: 有效货件号和追踪号;非空独立核实值优先,未补录货件时带入同客户原订单资料,去空去重;读取不会新增核实记录} availablePackages: {type: integer, format: int64, nullable: true} sourceChanged: {type: boolean} allowedActions: {type: array, items: {type: string, enum: [VERIFY_CARGO]}} diff --git a/lastleg-service/src/main/resources/openapi/fba-appointment-calendar-v1.yaml b/lastleg-service/src/main/resources/openapi/fba-appointment-calendar-v1.yaml index 8031a7cbb..4be6df0f3 100644 --- a/lastleg-service/src/main/resources/openapi/fba-appointment-calendar-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fba-appointment-calendar-v1.yaml @@ -218,7 +218,7 @@ components: plannedAt: {type: string, nullable: true, description: 已知的计划开始当地时段,日期必须与plannedDate一致} plannedEndAt: {type: string, nullable: true} 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: 明确结束时间时必须晚于开始时间;未知留空,不推算} appointmentStatus: {type: string, enum: [PENDING, SUCCESS, FAILED]} appointmentRemark: {type: string, maxLength: 1000, nullable: true} diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 5ca5d0706..a98f2ab54 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -7161,7 +7161,8 @@ components: confirmationNo: {type: string, nullable: true} appointmentAt: type: string - description: 目的仓当地确认开始时间,须结合timezone解释,不按浏览器时区转换。 + nullable: true + description: 目的仓当地确认开始时间;未提供时保持为空,已提供时结合timezone解释,不按浏览器时区转换。 appointmentEndAt: {type: string, nullable: true} timezone: {type: string} DispatchAppointmentAllocationCommand: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentBatchPreviewServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentBatchPreviewServiceTest.java index 8433d7474..26530a473 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentBatchPreviewServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentBatchPreviewServiceTest.java @@ -9,17 +9,17 @@ import static org.mockito.Mockito.*; class AppointmentBatchPreviewServiceTest { private static AppointmentCargoVerificationService verification() { 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; } @Test void previewUsesVerifiedRemainingQuantityAndDoesNotRejectAnEntirePartiallyAllocatedSource() { 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"); 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); 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)); } private static AppointmentSourceService.Source source(String key,String customer,Long orderId,String origin,String destination) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationBatchTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationBatchTest.java index ea89cc6e8..8fcfa3fea 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationBatchTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationBatchTest.java @@ -13,7 +13,7 @@ import static org.mockito.Mockito.*; class AppointmentCargoVerificationBatchTest { @Test void inspectionUsesTwoBoundedBatchesAndRetainsPerSourcePrerequisitesAndAllocations() { 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(); when(sources.inspectBatch(anyList())).thenAnswer(call->call.>getArgument(0).stream().map(ref-> 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(10L,result.get(199).requireContext().availablePackages()); 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,"目录不可用")); assertEquals(503,assertThrows(ResponseStatusException.class,()->service.inspectBatch(refs)).getStatusCode().value()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageServiceTest.java index fe14d1dd4..f43b389dd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageServiceTest.java @@ -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")); 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() { var mapper=mock(AppointmentCoverageMapper.class); when(mapper.sources(anyList())).thenReturn(List.of(order())); var service=new AppointmentCoverageService(mapper,mock(AppointmentExternalSourceGateway.class)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/AppointmentCargoVerificationPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/AppointmentCargoVerificationPersistenceTest.java index 14b236581..754ccec90 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/AppointmentCargoVerificationPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/AppointmentCargoVerificationPersistenceTest.java @@ -26,6 +26,54 @@ class AppointmentCargoVerificationPersistenceTest { 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))); } + @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() { try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) { var service=context.getBean(AppointmentCargoVerificationService.class); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java index 7bbb3f4ae..6bbb79a79 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java @@ -211,7 +211,9 @@ 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,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_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);} @@ -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 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 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 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();} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java index 3be3975fd..beede3d91 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java @@ -138,6 +138,16 @@ class InboundFbaAppointmentServiceTest { assertThrows(ResponseStatusException.class,()->service.createFromSources("other",request("original",pending(null),"ORDER:1","ORDER:2"))); 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() { 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"))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAvailabilityTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAvailabilityTest.java index 175457499..fa63bd218 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAvailabilityTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAvailabilityTest.java @@ -12,17 +12,29 @@ import static org.mockito.Mockito.*; /** 选入排车前的确认资格按真实客户和逐票覆盖核对。 */ class AppointmentDispatchAvailabilityTest { private final AppointmentDispatchAvailability policy=new AppointmentDispatchAvailability(mock(FbaReceivingStore.class)); - /** 预约头成功不能替代当前客户的有效确认,取消及无时间均不放行。 */ + /** 正式确认可以尚无时段,仍须匹配当前客户、来源和目的仓;取消和未确认均不放行。 */ @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),"B"),"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","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"); assertThat(policy.confirmedPackages(confirmed,"A","ORDER:2","FBA:ACY2")).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() { assertThat(count(snapshot("ACTIVE","SUCCESS",null,LocalDateTime.of(2026,10,26,10,0),"A"),"A")).isZero(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProviderTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProviderTest.java index acb30573e..c25e325a4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProviderTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProviderTest.java @@ -192,7 +192,7 @@ class AppointmentWorkDeliveryProviderTest { @Test void handledPrerequisiteCommitsTheCallerTransactionThroughTheRealCargoProxy() { 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预约")))); - 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 manager=new org.springframework.jdbc.datasource.DataSourceTransactionManager(ds); var proxy=new org.springframework.aop.framework.ProxyFactory(cargo);proxy.setProxyTargetClass(true); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java index 3f11ca6af..2b19e1100 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java @@ -210,6 +210,26 @@ class AppointmentWorkDispatchPersistenceTest { 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")); } + /** 没有确认时段的正式预约仍能带入本车,读写分配均保留真实数量且不虚构时间。 */ + @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() { tx.executeWithoutResult(s->bind(11));