feat: add self-operated analytics and enforce Lastleg module boundaries

This commit is contained in:
2026-10-12 20:27:35 +08:00
parent c06e788b0f
commit a1f1d93902
174 changed files with 13514 additions and 490 deletions
@@ -1,42 +1,6 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem;
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import java.util.List;
import java.util.Objects;
/** Called by actual incoming materialization, never as a migration or historical data repair. */
@Service
public class AppointmentSourceLinker {
private final LastlegOrderMapper orders;
private final InboundFbaAppointmentItemMapper items;
private final AppointmentExternalSourceGateway bookings;
public AppointmentSourceLinker(LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings) {this.orders=orders;this.items=items;this.bookings=bookings;}
@Transactional(propagation=Propagation.MANDATORY)
public void materialized(String customerId,String bookingNo,Long orderId) {
var ref=new AppointmentExternalSourceGateway.SourceRef(customerId,bookingNo);
bookings.lockForAppointment(List.of(ref));
var matched=bookings.resolve(List.of(ref));
if(matched.size()!=1 || !Objects.equals(matched.get(0).customerId(),customerId) || !Objects.equals(matched.get(0).bookingNo(),bookingNo) || !Objects.equals(matched.get(0).orderId(),orderId)) throw conflict("来货与原登记的归属不一致,请核对");
var order=orderId==null?null:orders.selectById(orderId);
if(order==null || !Objects.equals(order.getCustomerId(),customerId)) throw conflict("来货尚未形成对应客户的真实货物");
var rows=items.selectList(new LambdaQueryWrapper<InboundFbaAppointmentItem>().eq(InboundFbaAppointmentItem::getSourceKey,"BOOKING:"+bookingNo)
.eq(InboundFbaAppointmentItem::getCustomerId,customerId).eq(InboundFbaAppointmentItem::getActive,true).last("FOR UPDATE"));
for(var row:rows) {
if(row.getOrderId()!=null && !row.getOrderId().equals(orderId)) throw conflict("原预约已关联其他来货,不能自动改绑");
if(Objects.equals(row.getOrderId(),orderId) && Objects.equals(row.getInboundCode(),order.getInboundCode())) continue;
try {items.update(null,new LambdaUpdateWrapper<InboundFbaAppointmentItem>().eq(InboundFbaAppointmentItem::getId,row.getId()).eq(InboundFbaAppointmentItem::getActive,true)
.set(InboundFbaAppointmentItem::getOrderId,orderId).set(InboundFbaAppointmentItem::getInboundCode,order.getInboundCode()));}
catch(org.springframework.dao.DuplicateKeyException duplicate){throw conflict("该来货已有其他有效预约,请先核对原安排");}
}
}
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
/** Associates a newly materialized source with its receiving appointment in the caller's transaction. */
public interface AppointmentSourceLinker {
void materialized(String customerId, String bookingNo, Long orderId);
}
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.delivery.fleet;
import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper;
import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy;
import com.baiyue.lastleg.fleet.business.FleetPoolDeliveryEvidence;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.HashSet;
/** Same-transaction delivery proof; no persistence model escapes into fleet. */
@Component
public class FleetPoolDeliveryAdapter implements FleetPoolDeliveryEvidence {
private final DeliverySourceEvidenceMapper evidence;
public FleetPoolDeliveryAdapter(DeliverySourceEvidenceMapper evidence){this.evidence=evidence;}
@Override public boolean fullySigned(String customerId,String incomingNo,long orderId,int expectedPackages) {
var dispatchIds=evidence.dispatchIds(customerId,orderId);
if(dispatchIds==null||dispatchIds.isEmpty()||new HashSet<>(dispatchIds).size()!=dispatchIds.size())return false;
var deliveries=new ArrayList<SourceDeliveryCompletionPolicy.Delivery>();
var picks=new ArrayList<SourceDeliveryCompletionPolicy.Pick>();
var returns=new ArrayList<SourceDeliveryCompletionPolicy.Returned>();
for(Long dispatchId:dispatchIds){
if(dispatchId==null||dispatchId<=0)return false;
var d=evidence.deliveries(customerId,dispatchId);var p=evidence.picks(customerId,dispatchId);
var r=evidence.returns(customerId,dispatchId);
if(d==null||d.isEmpty()||p==null||p.isEmpty()||r==null)return false;
deliveries.addAll(d);picks.addAll(p);returns.addAll(r);
}
return SourceDeliveryCompletionPolicy.evaluate(new SourceDeliveryCompletionPolicy.Source(incomingNo,orderId,expectedPackages),
deliveries,picks,returns,true).fullySigned();
}
}
@@ -1,4 +1,5 @@
package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.SignedDelivery;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*;
@@ -115,7 +116,9 @@ public class DeliveryDocumentService {
orders.updateById(order);
if (autoCompleted) {
var completion = completeRelatedOrders(order, operatorId, now);
fleetPool.signed(order, completion.sourcePackages(), operatorId, now);
fleetPool.signed(new SignedDelivery(
order.getCustomerId(),order.getDispatchNo(),order.getDeliveryNo(),"DELIVERED".equals(order.getStatus()),order.getSignedAt()),
completion.sourcePackages(),operatorId,now);
deliveryCompletedOutbox.publish(order, completion.orders(), completion.proofs(), now.atZone(executionZone).toInstant());
customerVisits.ensureForCompletedDelivery(order, now);
}
@@ -0,0 +1,6 @@
package com.baiyue.lastleg.fleet.business;
/** Delivery owns source attribution, returns and full-signature proof. */
public interface FleetPoolDeliveryEvidence {
boolean fullySigned(String customerId,String incomingNo,long orderId,int expectedPackages);
}
@@ -1,15 +1,12 @@
package com.baiyue.lastleg.fleet.business;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.fleet.trip.FleetBookingIncomingGateway;
import com.baiyue.lastleg.looseboard.entity.*;
import com.baiyue.lastleg.looseboard.mapper.*;
import com.baiyue.lastleg.trucking.entity.*;
import com.baiyue.lastleg.trucking.mapper.*;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper;
import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@@ -30,13 +27,13 @@ public class FleetPoolOrderBridge {
private final LocalLooseboardHistoryMapper looseHistory;
private final UsTruckOrderMapper trucks;
private final UsTruckOrderStatusHistoryMapper truckHistory;
private final LastlegOrderNoGenerator numbers;
private final FleetPoolOrderNumberGenerator numbers;
private final ObjectMapper json;
private final DeliverySourceEvidenceMapper deliveryEvidence;
private final FleetPoolDeliveryEvidence deliveryEvidence;
private final com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources;
public FleetPoolOrderBridge(FleetPoolOrderMapper links,LocalLooseboardOrderMapper loose,
LocalLooseboardHistoryMapper looseHistory,UsTruckOrderMapper trucks,
UsTruckOrderStatusHistoryMapper truckHistory,LastlegOrderNoGenerator numbers,ObjectMapper json,DeliverySourceEvidenceMapper deliveryEvidence,
UsTruckOrderStatusHistoryMapper truckHistory,FleetPoolOrderNumberGenerator numbers,ObjectMapper json,FleetPoolDeliveryEvidence deliveryEvidence,
com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources) {
this.links=links;this.loose=loose;this.looseHistory=looseHistory;this.trucks=trucks;
this.truckHistory=truckHistory;this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence;
@@ -111,7 +108,7 @@ public class FleetPoolOrderBridge {
if("WAITING_INBOUND".equals(row.getFulfillmentStage()))move(row,"RECEIVING",operator,"PDA开始实际收货 "+incomingNo,LocalDateTime.now());
}
@Transactional(propagation=Propagation.MANDATORY)
public void acceptedReceipt(long bookingId,FleetBookingIncomingGateway.Evidence evidence,int palletSpaces,String operator) {
public void acceptedReceipt(long bookingId,Receipt evidence,int palletSpaces,String operator) {
var row=requiredBooking(bookingId);
if(evidence==null||!Objects.equals(row.getIncomingNo(),evidence.incomingNo())
||!Objects.equals(row.getWarehouseId(),evidence.warehouseId())||!Objects.equals(row.getDestinationKey(),evidence.destinationKey())
@@ -157,33 +154,19 @@ public class FleetPoolOrderBridge {
}
}
@Transactional(propagation=Propagation.MANDATORY)
public void signed(DeliveryOrder delivery,Map<Long,Long> actualSourcePackages,String operator,LocalDateTime at) {
if(delivery.getDispatchNo()==null)return;
for(var row:links.lockDispatch(delivery.getCustomerId(),delivery.getDispatchNo())) {
public void signed(SignedDelivery delivery,Map<Long,Long> actualSourcePackages,String operator,LocalDateTime at) {
if(delivery.dispatchNo()==null)return;
for(var row:links.lockDispatch(delivery.customerId(),delivery.dispatchNo())) {
if(!actualSourcePackages.containsKey(row.getLastlegOrderId()))continue;
if(row.getActualPackageCount()==null||actualSourcePackages.get(row.getLastlegOrderId())!=row.getActualPackageCount().longValue())throw conflict("商业单签收实际来源数量不完整");
if("COMPLETED".equals(row.getFulfillmentStage()))continue;
if(!"IN_TRANSIT".equals(row.getFulfillmentStage()))throw conflict("商业单须实际出库后才能签收完成");
if("DELIVERED".equals(delivery.getStatus())&&delivery.getSignedAt()!=null&&wholeSourceSigned(row))
move(row,"COMPLETED",operator,"真实全部签收 "+delivery.getDeliveryNo(),at);
if(delivery.delivered()&&delivery.signedAt()!=null&&wholeSourceSigned(row))
move(row,"COMPLETED",operator,"真实全部签收 "+delivery.deliveryNo(),at);
}
}
private boolean wholeSourceSigned(FleetPoolOrderLink row) {
var dispatchIds=deliveryEvidence.dispatchIds(row.getCustomerId(),row.getLastlegOrderId());
if(dispatchIds==null||dispatchIds.isEmpty()||new HashSet<>(dispatchIds).size()!=dispatchIds.size())return false;
var deliveries=new ArrayList<SourceDeliveryCompletionPolicy.Delivery>();
var picks=new ArrayList<SourceDeliveryCompletionPolicy.Pick>();
var returns=new ArrayList<SourceDeliveryCompletionPolicy.Returned>();
for(Long dispatchId:dispatchIds) {
if(dispatchId==null||dispatchId<=0)return false;
var d=deliveryEvidence.deliveries(row.getCustomerId(),dispatchId);
var p=deliveryEvidence.picks(row.getCustomerId(),dispatchId);
var r=deliveryEvidence.returns(row.getCustomerId(),dispatchId);
if(d==null||d.isEmpty()||p==null||p.isEmpty()||r==null)return false;
deliveries.addAll(d);picks.addAll(p);returns.addAll(r);
}
return SourceDeliveryCompletionPolicy.evaluate(new SourceDeliveryCompletionPolicy.Source(row.getIncomingNo(),
row.getLastlegOrderId(),row.getActualPackageCount()),deliveries,picks,returns,true).fullySigned();
return deliveryEvidence.fullySigned(row.getCustomerId(),row.getIncomingNo(),row.getLastlegOrderId(),row.getActualPackageCount());
}
public Map<Long,String> businessOrderNumbers(Collection<Long> ids) {
if(ids==null||ids.isEmpty())return Map.of();var result=new HashMap<Long,String>();
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.fleet.business;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.Instant;
import java.util.List;
public final class FleetPoolOrderModels {
private FleetPoolOrderModels() { }
@@ -13,6 +14,14 @@ public final class FleetPoolOrderModels {
BigDecimal weightKg, BigDecimal volumeCbm, String currency, String contactName, String contactPhone,
String remark) { }
public record BusinessOrderRef(String businessType, String businessOrderNo) { }
/** Receiving facts needed by commercial fulfillment, independent of trip planning DTOs. */
public record ReceivedPallet(String warehousePalletId,int packages) { }
public record Receipt(String incomingNo,String warehouseId,String destinationKey,String status,
List<ReceivedPallet> pallets,int receivedPackages) {
public Receipt { pallets=List.copyOf(pallets); }
}
public record SignedDelivery(String customerId,String dispatchNo,String deliveryNo,
boolean delivered,LocalDateTime signedAt) { }
public record OrderKey(String customerId, String businessType, long businessOrderId) { }
public record Arrangement(String sourceType, String businessType, String businessOrderNo, String bookingNo,
String tripNo, Instant plannedDepartureAt, String originWarehouseName, String destinationLabel,
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.fleet.business;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.regex.Pattern;
/** Commercial pool orders own LB/TR numbering; no tail-leg order sequence is consumed. */
@Component
public class FleetPoolOrderNumberGenerator {
private static final Pattern LOCATION=Pattern.compile("LAX|ONT|NYC|JFK|LGB|EWR|SFO|SEA|DFW|ORD|ATL|MIA|PHX|LAS|YYZ|YVR|AMS|LHR|FRA|CDG|HKG|NRT|SYD");
public String next(String businessType,String destination,long uniqueId){
String prefix=switch(businessType){case "TRUCK"->"TR";case "LOOSEBOARD"->"LB";default->throw new IllegalArgumentException("Unsupported pool business type");};
return prefix+"-"+location(destination)+"-"+LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)
+"-"+String.format(Locale.ROOT,"%06d",Math.floorMod(uniqueId,1_000_000));
}
private static String location(String value){
String code=value==null?"":value.trim().toUpperCase(Locale.ROOT);var match=LOCATION.matcher(code);
if(Pattern.matches(".*("+LOCATION.pattern()+").*",code)&&match.find())return match.group();
return code.matches("[A-Z0-9]{2,8}")?code:"WH";
}
}
@@ -1,4 +1,6 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Receipt;
import com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.ReceivedPallet;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
@@ -168,7 +170,10 @@ public class FleetBookingService {
if(!"READY_FOR_DISPATCH".equals(confirmed.status())||!Objects.equals(confirmed.receiptHash(),evidence.receiptHash()))throw conflict("来货实收确认未完成或内容发生变化,请重新核对");
row.setReceiptJson(write(new FleetBookingModels.AcceptedReceipt(confirmed,java.util.List.copyOf(request.pallets()),operator,java.time.Instant.now())));
row.setStatus("RECEIVED");row.setReceiptKey(request.idempotencyKey());row.setReceiptHash(hash);
if(row.getOfferId()!=null)poolOrders.acceptedReceipt(row.getId(),confirmed,totalSpaces,operator);
if(row.getOfferId()!=null)poolOrders.acceptedReceipt(row.getId(),new Receipt(
confirmed.incomingNo(),confirmed.warehouseId(),confirmed.destinationKey(),confirmed.status(),
confirmed.pallets().stream().map(p->new ReceivedPallet(p.warehousePalletId(),p.packages())).toList(),
confirmed.receivedPackages()),totalSpaces,operator);
save(trip,row,operator,"BOOKING_RECEIPT","确认外接实收货量及约定运费 "+row.getBookingNo());return result(trip,row,false);
}
@Transactional
@@ -1,4 +1,4 @@
package com.baiyue.lastleg.appointmentplanning;
package com.baiyue.lastleg.inboundplan.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
@@ -10,6 +10,8 @@ import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/platform/v1/last-leg/appointment-planning/appointments")
@@ -19,13 +21,17 @@ public class AppointmentSourceCreateController {
@PostMapping
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list') and hasAuthority('"+OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT+"')")
public ApiResponse<FbaAppointmentResponse> create(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody SourceFbaAppointmentRequest request) {
AppointmentPlanningController.requirePlatform(user);
requirePlatform(user);
return ApiResponse.success(service.createFromSources(String.valueOf(user.userId()),request));
}
@GetMapping("/unscheduled")
@PreAuthorize("hasAuthority('platform:appointment:list')")
public ApiResponse<PageResult<FbaAppointmentResponse>> unscheduled(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam(defaultValue="1") int page,@RequestParam(defaultValue="20") int size) {
AppointmentPlanningController.requirePlatform(user);
requirePlatform(user);
return ApiResponse.success(service.unscheduled(page,size));
}
private static void requirePlatform(AuthenticatedUser user) {
if (user == null || user.userId()==null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId())) throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
}
@@ -1,6 +1,5 @@
package com.baiyue.lastleg.inboundplan.dto;
import com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.util.List;
@@ -11,4 +10,6 @@ public record SourceFbaAppointmentRequest(
@NotBlank @Size(max=64) String fbaWarehouseCode,
@NotNull @Valid FbaAppointmentDetails details,
@NotBlank @Size(max=64) String idempotencyKey
) { }
) {
public record SourceRef(String customerId, String sourceKey, String destinationKey) { }
}
@@ -0,0 +1,46 @@
package com.baiyue.lastleg.inboundplan.service;
import com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker;
import com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem;
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import java.util.List;
import java.util.Objects;
/** Called by actual incoming materialization, never as a migration or historical data repair. */
@Service
public class InboundAppointmentSourceLinker implements AppointmentSourceLinker {
private final LastlegOrderMapper orders;
private final InboundFbaAppointmentItemMapper items;
private final AppointmentExternalSourceGateway bookings;
public InboundAppointmentSourceLinker(LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings) {this.orders=orders;this.items=items;this.bookings=bookings;}
@Override
@Transactional(propagation=Propagation.MANDATORY)
public void materialized(String customerId,String bookingNo,Long orderId) {
var ref=new AppointmentExternalSourceGateway.SourceRef(customerId,bookingNo);
bookings.lockForAppointment(List.of(ref));
var matched=bookings.resolve(List.of(ref));
if(matched.size()!=1 || !Objects.equals(matched.get(0).customerId(),customerId) || !Objects.equals(matched.get(0).bookingNo(),bookingNo) || !Objects.equals(matched.get(0).orderId(),orderId)) throw conflict("来货与原登记的归属不一致,请核对");
var order=orderId==null?null:orders.selectById(orderId);
if(order==null || !Objects.equals(order.getCustomerId(),customerId)) throw conflict("来货尚未形成对应客户的真实货物");
var rows=items.selectList(new LambdaQueryWrapper<InboundFbaAppointmentItem>().eq(InboundFbaAppointmentItem::getSourceKey,"BOOKING:"+bookingNo)
.eq(InboundFbaAppointmentItem::getCustomerId,customerId).eq(InboundFbaAppointmentItem::getActive,true).last("FOR UPDATE"));
for(var row:rows) {
if(row.getOrderId()!=null && !row.getOrderId().equals(orderId)) throw conflict("原预约已关联其他来货,不能自动改绑");
if(Objects.equals(row.getOrderId(),orderId) && Objects.equals(row.getInboundCode(),order.getInboundCode())) continue;
try {items.update(null,new LambdaUpdateWrapper<InboundFbaAppointmentItem>().eq(InboundFbaAppointmentItem::getId,row.getId()).eq(InboundFbaAppointmentItem::getActive,true)
.set(InboundFbaAppointmentItem::getOrderId,orderId).set(InboundFbaAppointmentItem::getInboundCode,order.getInboundCode()));}
catch(org.springframework.dao.DuplicateKeyException duplicate){throw conflict("该来货已有其他有效预约,请先核对原安排");}
}
}
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
}
@@ -66,7 +66,10 @@ public class InboundFbaAppointmentService {
}
@Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public FbaAppointmentResponse createFromSources(String operatorId,SourceFbaAppointmentRequest request) {
return command("CREATE_FROM_SOURCES",operatorId,request.idempotencyKey(),request,()->create(operatorId,request.sources(),request.originWarehouseId(),request.fbaWarehouseCode(),request.details()));
return command("CREATE_FROM_SOURCES",operatorId,request.idempotencyKey(),request,()->create(operatorId,
request.sources()==null?null:request.sources().stream().map(source->source==null?null:
new AppointmentCoverageService.SourceRef(source.customerId(),source.sourceKey(),source.destinationKey())).toList(),
request.originWarehouseId(),request.fbaWarehouseCode(),request.details()));
}
private FbaAppointmentResponse create(String operator,List<AppointmentCoverageService.SourceRef> refs,String expectedOrigin,String expectedDestination,FbaAppointmentDetails details) {
var resolved=resolveForWrite(refs,null);
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.selfoperated.analytics.client;
/** Fixed diagnostic only: never retain provider bodies, token or arbitrary messages. */
public final class AnalyticsClientException extends RuntimeException {
private final String code;
private final boolean retryable;
public AnalyticsClientException(String code, boolean retryable) {
super(code);
this.code = code;
this.retryable = retryable;
}
public String code() { return code; }
public boolean retryable() { return retryable; }
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.selfoperated.analytics.client;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopPayloads.Order;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopPayloads.Stock;
import java.time.LocalDateTime;
import java.util.List;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
/** One upstream page or detail per call. Durable orchestration belongs to sync. */
public interface LingxingAnalyticsClient {
Page<Warehouse> warehouses(SelfopRequestContext context, int current, int size);
Page<Summary> discover(SelfopRequestContext context, LocalDateTime startTime, LocalDateTime endTime, int current, int size);
Page<Summary> locate(SelfopRequestContext context, String deliveryNo, int current, int size);
Order detail(SelfopRequestContext context, String warehouseCode, String customerCode, String deliveryNo);
Page<Stock> inventory(SelfopRequestContext context, String warehouseCode, int current, int size);
record Page<T>(List<T> records, Integer current, Integer size, Long total, Integer pages) {
public Page { records = List.copyOf(records); }
}
record Warehouse(String warehouseCode, String warehouseName, Integer warehouseType,
Integer status, String timeZone) { }
record Summary(String sourceAccount, String customerCode, String warehouseCode,
String deliveryNo, String sourceNo, Integer sourceStatus,
String sourceCreatedTime, String sourceOutboundTime) { }
}
@@ -0,0 +1,248 @@
package com.baiyue.lastleg.selfoperated.analytics.client;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopRequestContext;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopAnalyticsProperties;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopPayloads;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport;
import com.baiyue.lastleg.selfoperated.analytics.request.*;
import com.baiyue.lastleg.selfoperated.analytics.request.SelfopEgressDtos.Operation;
import org.springframework.beans.factory.annotation.Autowired;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.core.JsonProcessingException;
import org.springframework.http.MediaType;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestClientResponseException;
import java.math.BigDecimal;
import java.net.http.HttpClient;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Map;
import java.util.List;
import java.util.Set;
import java.util.function.Function;
@Component
public class RestLingxingAnalyticsClient implements LingxingAnalyticsClient {
private static final String PREFIX = "/internal/integrations/lingxing/wms";
private static final Set<String> PROVIDER_ERROR_CODES = Set.of(
"LINGXING_WMS_AUTHORIZATION_REJECTED", "LINGXING_WMS_SIGNATURE_REJECTED",
"LINGXING_WMS_SOURCE_REJECTED", "LINGXING_WMS_INVALID_RESPONSE", "LINGXING_WMS_RATE_LIMITED",
"LINGXING_WMS_HTTP_RATE_LIMITED", "LINGXING_WMS_SOURCE_RATE_LIMITED",
"LINGXING_WMS_ACCOUNT_PAUSED", "LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED");
private final RestClient client;
private final SelfopAnalyticsProperties properties;
private final SelfopEgressService egress;
private final ObjectMapper responseMapper = new ObjectMapper()
.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS);
@Autowired public RestLingxingAnalyticsClient(RestClient.Builder builder, SelfopAnalyticsProperties properties, SelfopEgressService egress) {
this.egress = egress;
this.properties = properties;
var factory = new JdkClientHttpRequestFactory(HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5)).followRedirects(HttpClient.Redirect.NEVER).build());
factory.setReadTimeout(Duration.ofSeconds(30));
this.client = builder.requestFactory(factory).baseUrl(properties.getIntegrationBaseUrl()).build();
}
// Allows an entirely in-memory transport in contract tests.
RestLingxingAnalyticsClient(RestClient client, SelfopAnalyticsProperties properties, SelfopEgressService egress) {
this.egress = egress;
this.client = client;
this.properties = properties;
}
@Override public Page<Warehouse> warehouses(SelfopRequestContext context, int current, int size) {
gate(context); String account = context.sourceAccount(); paging(current, size, 1000);
return page(post(context, "/warehouses/query", Map.of("current", current, "size", size)), current, size,
n -> new Warehouse(required(n, "whCode", 64), optional(n, "whNameCn"),
integer(n, "warehouseType"), integer(n, "status"), optional(n, "timeZone")));
}
@Override public Page<Summary> discover(SelfopRequestContext context, LocalDateTime start, LocalDateTime end, int current, int size) {
gate(context); String account = context.sourceAccount(); paging(current, size, 100);
if (start == null || end == null || !start.isBefore(end) || end.isAfter(start.plusMonths(1)))
throw failure("INVALID_DISCOVERY_WINDOW");
return page(post(context, "/outbound-orders/query", Map.of("startTime", DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(start),
"endTime", DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(end),
"current", current, "size", size)), current, size, n -> summary(account, n));
}
@Override public Page<Summary> locate(SelfopRequestContext context, String deliveryNo, int current, int size) {
gate(context); String account = context.sourceAccount(); paging(current, size, 1000); argument(deliveryNo, 128);
return page(post(context, "/outbound-orders/locate", Map.of("deliveryNo", deliveryNo,
"current", current, "size", size)), current, size, n -> summary(account, n));
}
@Override public SelfopPayloads.Order detail(SelfopRequestContext context, String warehouse, String customer, String deliveryNo) {
gate(context); String account = context.sourceAccount(); argument(warehouse, 64); argument(customer, 64); argument(deliveryNo, 128);
JsonNode n = post(context, "/outbound-orders/detail", Map.of("warehouseCode", warehouse,
"customerCode", customer, "deliveryNo", deliveryNo));
if (!warehouse.equals(required(n, "whCode", 64)) || !customer.equals(required(n, "customerCode", 64))
|| !deliveryNo.equals(required(n, "deliveryNo", 128))) throw failure("DETAIL_IDENTITY_MISMATCH");
if (!n.path("skuVOList").isArray()) throw failure("DETAIL_ITEMS_MISSING");
var items = new ArrayList<SelfopPayloads.Item>();
for (JsonNode item : n.path("skuVOList")) {
items.add(new SelfopPayloads.Item(required(item, "productSku", 128), optional(item, "productName"),
quantity(item, "actualDeliveryQty"), properties.getQuantityUnit()));
}
return new SelfopPayloads.Order(account, customer, warehouse, deliveryNo, optional(n, "sourceNo"),
integer(n, "status"), optional(n, "orderCreateTime"), optional(n, "outboundTime"), List.copyOf(items));
}
@Override public Page<SelfopPayloads.Stock> inventory(SelfopRequestContext context, String warehouse, int current, int size) {
gate(context); String account = context.sourceAccount(); argument(warehouse, 64); paging(current, size, 1000);
return page(post(context, "/product-inventory/query", Map.of("warehouseCode", warehouse,
"current", current, "size", size)), current, size, n -> {
if (!warehouse.equals(required(n, "whCode", 64))) throw failure("INVENTORY_WAREHOUSE_MISMATCH");
return new SelfopPayloads.Stock(account, required(n, "customerCode", 64), warehouse,
required(n, "productSku", 128), integer(n, "productQuality"), optional(n, "productName"),
quantity(n, "stkQty"), quantity(n, "useStkQty"), quantity(n, "freezeQty"),
quantity(n, "transitQty"), optional(n, "uniqueKey4tenant"));
});
}
private Summary summary(String account, JsonNode n) {
return new Summary(account, required(n, "customerCode", 64), required(n, "whCode", 64),
required(n, "deliveryNo", 128), optional(n, "sourceNo"), integer(n, "status"),
optional(n, "createTime"), optional(n, "outboundTime"));
}
private void gate(SelfopRequestContext context) {
if (context == null) throw failure("EGRESS_CONTEXT_MISSING");
String account = context.sourceAccount();
if ("OFF".equals(properties.getCollectionMode())) throw failure("EXECUTION_DISABLED");
if (!properties.getCollectionMode().equals(context.executionPurpose())) throw failure("EGRESS_PURPOSE_MISMATCH");
argument(account, 64);
if (!account.equals(properties.getSourceAccount())) throw failure("SOURCE_ACCOUNT_MISMATCH");
if (properties.getInternalServiceToken() == null || properties.getInternalServiceToken().isBlank())
throw failure("INTERNAL_TOKEN_UNCONFIGURED");
}
private JsonNode post(SelfopRequestContext context, String suffix, Object request) {
Map<?,?> fields = (Map<?,?>)request;
Operation op = switch(suffix) {
case "/warehouses/query" -> Operation.WAREHOUSES;
case "/outbound-orders/query" -> Operation.OUTBOUND_QUERY;
case "/outbound-orders/locate" -> Operation.OUTBOUND_LOCATE;
case "/outbound-orders/detail" -> Operation.OUTBOUND_DETAIL;
case "/product-inventory/query" -> Operation.PRODUCT_INVENTORY;
default -> throw failure("EGRESS_OPERATION_INVALID");
};
int size = fields.containsKey("size") ? ((Number)fields.get("size")).intValue() : 0;
String warehouse=(String)fields.get("warehouseCode");
String key=op==Operation.OUTBOUND_DETAIL ? SelfopSupport.json(List.of(fields.get("customerCode"),fields.get("deliveryNo"))) : null;
var permission = egress.reserve(context,op,size,warehouse,key,SelfopSupport.hash(fields));
JsonNode body;
try {
String response = client.post().uri(PREFIX + suffix).contentType(MediaType.APPLICATION_JSON)
.header("X-Internal-Service-Token", properties.getInternalServiceToken())
.header("X-Lingxing-Egress-Context", SelfopSupport.json(permission))
.body(request).retrieve().body(String.class);
body = response == null ? null : responseMapper.readTree(response);
} catch (RestClientResponseException e) {
int status = e.getStatusCode().value();
if(status>=500||status==429)egress.pauseUncertain(permission,op,"INTEGRATION_HTTP_FAILURE");
throw new AnalyticsClientException("INTEGRATION_HTTP_FAILURE", status == 429 || status >= 500);
} catch (ResourceAccessException e) {
egress.pauseUncertain(permission,op,"INTEGRATION_TRANSPORT_FAILURE");
throw new AnalyticsClientException("INTEGRATION_TRANSPORT_FAILURE", true);
} catch (RestClientException e) {
egress.pauseUncertain(permission,op,"INTEGRATION_RESPONSE_INVALID");
throw failure("INTEGRATION_RESPONSE_INVALID");
} catch (JsonProcessingException e) {
egress.pauseUncertain(permission,op,"INTEGRATION_RESPONSE_INVALID");
throw failure("INTEGRATION_RESPONSE_INVALID");
}
if (body == null || !body.isObject()) {
egress.pauseUncertain(permission,op,"INTEGRATION_RESPONSE_INVALID");throw failure("INTEGRATION_RESPONSE_INVALID");
}
if (!"200".equals(body.path("code").asText())) {
JsonNode metadata=body.path("data").path("egress");
if(metadata.path("accountPaused").isBoolean()&&metadata.path("accountPaused").booleanValue()){
if(!metadata.path("attemptId").isTextual()||!permission.attemptId().equals(metadata.path("attemptId").textValue()))
throw failure("INTEGRATION_PAUSE_BINDING_INVALID");
JsonNode reportedCode=body.path("data").path("errorCode");
egress.pauseUncertain(permission,op,reportedCode.isTextual()?reportedCode.textValue():"INTEGRATION_RESPONSE_INVALID");
}
if("LINGXING_WMS_BUDGET_AUTHORIZATION_DENIED".equals(body.path("data").path("errorCode").asText())) {
JsonNode denial=body.path("data").path("denial");
if(!denial.isObject()||!denial.path("errorCode").isTextual()||!denial.path("errorCode").textValue().matches("[A-Z][A-Z0-9_]{0,63}")||!denial.path("sampleDone").isBoolean()||!denial.has("waitUntil"))throw failure("INTEGRATION_RESPONSE_INVALID");
LocalDateTime wait=null;
if(!denial.path("waitUntil").isNull()) {
if(!denial.path("waitUntil").isTextual())throw failure("INTEGRATION_RESPONSE_INVALID");
try {wait=LocalDateTime.ofInstant(java.time.Instant.parse(denial.path("waitUntil").textValue()),java.time.ZoneOffset.UTC);}catch(Exception invalid){throw failure("INTEGRATION_RESPONSE_INVALID");}
}
throw new EgressRejectedException(denial.path("errorCode").textValue(),denial.path("sampleDone").booleanValue(),wait);
}
// Retryability is a normalized Integration field, never infer it from provider text.
boolean retryable = body.path("data").path("retryable").isBoolean()
&& body.path("data").path("retryable").booleanValue();
JsonNode sourceError = body.path("data").path("errorCode");
String code = sourceError.isTextual() && PROVIDER_ERROR_CODES.contains(sourceError.textValue())
? sourceError.textValue() : "INTEGRATION_PROVIDER_FAILURE";
if ("LINGXING_WMS_AUTHORIZATION_REJECTED".equals(code) || "LINGXING_WMS_SIGNATURE_REJECTED".equals(code))
retryable = false;
throw new AnalyticsClientException(code, retryable);
}
if (!body.path("data").isObject()) throw failure("INTEGRATION_DATA_MISSING");
return body.path("data");
}
private <T> Page<T> page(JsonNode n, int current, int size, Function<JsonNode,T> mapper) {
int actualCurrent = integer(n, "current"), actualSize = integer(n, "size"), pages = integer(n, "pages");
JsonNode totalNode = n.path("total");
long total;
try {
if (totalNode.isIntegralNumber() && totalNode.canConvertToLong()) total = totalNode.longValue();
else if (totalNode.isTextual() && totalNode.textValue().matches("[0-9]+")) total = Long.parseLong(totalNode.textValue());
else throw failure("PAGE_METADATA_INVALID");
} catch (NumberFormatException e) { throw failure("PAGE_METADATA_INVALID"); }
if (!n.path("records").isArray() || actualCurrent != current || actualSize != size || total < 0 || pages < 0
|| pages != (total == 0 ? 0 : ((total - 1) / size) + 1) || (pages > 0 && current > pages)
|| (total == 0 && current != 1)) throw failure("PAGE_METADATA_INVALID");
long expected = total == 0 ? 0 : Math.min(size, total - ((long) current - 1) * size);
if (n.path("records").size() != expected) throw failure("PAGE_ROW_COUNT_INVALID");
var records = new ArrayList<T>();
for (JsonNode row : n.path("records")) {
if (!row.isObject()) throw failure("PAGE_ROW_INVALID");
records.add(mapper.apply(row));
}
return new Page<>(records, current, size, total, pages);
}
private static void paging(int current, int size, int max) {
if (current < 1 || size < 1 || size > max) throw failure("INVALID_PAGE_REQUEST");
}
private static void argument(String value, int max) {
if (value == null || value.isBlank() || value.length() > max) throw failure("INVALID_BUSINESS_IDENTITY");
}
private static String required(JsonNode n, String field, int max) {
String value = optional(n, field); argument(value, max); return value;
}
private static String optional(JsonNode n, String field) {
JsonNode value = n.get(field);
if (value == null || value.isNull()) return null;
if (!value.isTextual()) throw failure("SOURCE_FIELD_TYPE_INVALID");
return value.textValue();
}
private static int integer(JsonNode n, String field) {
JsonNode value = n.path(field);
if (!value.isIntegralNumber() || !value.canConvertToInt()) throw failure("SOURCE_INTEGER_INVALID");
return value.intValue();
}
private static BigDecimal quantity(JsonNode n, String field) {
JsonNode value = n.get(field);
if (value == null || value.isNull() || value.isTextual() && value.textValue().isEmpty()) return null;
if (!value.isNumber()) throw failure("SOURCE_QUANTITY_INVALID");
return value.decimalValue();
}
private static AnalyticsClientException failure(String code) { return new AnalyticsClientException(code, false); }
}
@@ -0,0 +1,94 @@
package com.baiyue.lastleg.selfoperated.analytics.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.selfoperated.analytics.query.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/platform/v1/last-leg/self-operated-analytics")
@PreAuthorize("hasAuthority('platform:self-operated-analytics:list')")
@Tag(name="平台自营出库销量与库存只读分析")
public class PlatformAnalyticsController {
public static final String READ_PERMISSION="platform:self-operated-analytics:list";
private final AnalyticsQueryService service;
private final CollectionQueryService collection;
public PlatformAnalyticsController(AnalyticsQueryService service,CollectionQueryService collection){this.service=service;this.collection=collection;}
@GetMapping("/sku-sales-summary")
@Operation(summary="五周期或历史自定义范围SKU累计清单")
public ApiResponse<AnalyticsResponses.Page<AnalyticsResponses.SalesSummary>> salesSummary(
@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query){
requirePlatform(user);return ApiResponse.success(service.salesSummary(query));
}
@GetMapping("/sku-daily-sales")
@Operation(summary="精确货主与SKU的完整日趋势轴")
public ApiResponse<AnalyticsResponses.Trend> dailySales(
@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query){
requirePlatform(user);return ApiResponse.success(service.dailySales(query));
}
@GetMapping("/hot-skus")
@Operation(summary="近7或30个完整自然日热销Top100")
public ApiResponse<AnalyticsResponses.Page<AnalyticsResponses.Metric>> hotSkus(
@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query){
requirePlatform(user);return ApiResponse.success(service.hotSkus(query));
}
@GetMapping("/slow-skus")
@Operation(summary="固定近30天有库存低出库量Top100")
public ApiResponse<AnalyticsResponses.Page<AnalyticsResponses.Metric>> slowSkus(
@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query){
requirePlatform(user);return ApiResponse.success(service.slowSkus(query));
}
@GetMapping("/inventory-coverage")
@Operation(summary="正品库存、固定30天日均和可售天数")
public ApiResponse<AnalyticsResponses.Page<AnalyticsResponses.Metric>> inventoryCoverage(
@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query,@ModelAttribute AnalyticsInventoryOptions options){
requirePlatform(user);return ApiResponse.success(service.inventoryCoverage(query,options));
}
@GetMapping("/owners")
@Operation(summary="固定两版已采集货主编码前缀分页")
public ApiResponse<AnalyticsResponses.Page<AnalyticsResponses.OwnerOption>> owners(
@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query,@ModelAttribute AnalyticsInventoryOptions options){
requirePlatform(user);return ApiResponse.success(service.owners(query,options));
}
@GetMapping("/owner-overview")
@Operation(summary="本仓一个货主完整候选的30日经营概览")
public ApiResponse<AnalyticsResponses.OwnerOverview> ownerOverview(
@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query,@ModelAttribute AnalyticsInventoryOptions options){
requirePlatform(user);return ApiResponse.success(service.ownerOverview(query,options));
}
@GetMapping("/sync-overview")
@Operation(summary="各仓任务族独立配置、覆盖和采集运行概览")
public ApiResponse<CollectionResponses.Overview> syncOverview(
@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute CollectionQuery query){
requirePlatform(user);return ApiResponse.success(collection.overview(query));
}
@GetMapping("/sync-tasks")
@Operation(summary="本仓逻辑采集任务与轻量最新尝试分页")
public ApiResponse<CollectionResponses.Page<CollectionResponses.Task>> syncTasks(
@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute CollectionQuery query){
requirePlatform(user);return ApiResponse.success(collection.tasks(query));
}
@GetMapping("/sync-tasks/{taskNo}")
@Operation(summary="稳定业务任务号的采集详情与进度")
public ApiResponse<CollectionResponses.TaskDetail> syncTaskDetail(
@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String taskNo,@ModelAttribute CollectionQuery query){
requirePlatform(user);return ApiResponse.success(collection.taskDetail(taskNo,query));
}
@GetMapping("/sync-runs")
@Operation(summary="本仓或指定任务号的尝试历史与终态进度")
public ApiResponse<CollectionResponses.Page<CollectionResponses.Attempt>> syncRuns(
@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute CollectionQuery query){
requirePlatform(user);return ApiResponse.success(collection.runs(query));
}
private static void requirePlatform(AuthenticatedUser user){
if(user==null||user.tenantId()==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if(!"PLATFORM".equals(user.appCode())||user.tenantId()!=0L||user.impersonated()||!user.permissions().contains(READ_PERMISSION))
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"平台自营分析只读权限不足");
}
}
@@ -0,0 +1,140 @@
package com.baiyue.lastleg.selfoperated.analytics.inventory;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.*;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
/** Builds one immutable inventory observation, using only fixed published reference versions. */
public final class InventorySnapshotAssembler {
private InventorySnapshotAssembler() { }
public record Candidate(List<SelfopInventorySnapshot> snapshots, List<SelfopSkuProfile> profiles) { }
private record StockKey(String customer, String sku, Integer quality) { }
private record SkuKey(String customer, String sku) { }
public static Candidate assemble(SelfopSyncScope scope, SelfopSyncRun run,
List<SelfopSyncStage> stages, List<SelfopInventorySnapshot> priorStock,
List<SelfopSkuProfile> priorProfiles, List<SelfopSkuProfile> salesProfiles, String unit) {
require("COMPLETE".equals(run.getCoverageStatus()), "SELFOP_INVENTORY_INCOMPLETE");
require(run.getFetchStartedAtUtc() != null && run.getFetchFinishedAtUtc() != null
&& !run.getFetchFinishedAtUtc().isBefore(run.getFetchStartedAtUtc()), "SELFOP_INVENTORY_INTERVAL_INVALID");
require(run.getPageCount() != null && run.getPageCount() > 0
&& run.getSourceTotalCount() != null && run.getSourceTotalCount() >= 0
&& Objects.equals(run.getSourceTotalCount(), run.getUniqueObjectCount())
&& run.getSourceTotalCount() == stages.size(), "SELFOP_INVENTORY_COUNT_MISMATCH");
var now = utcNow();
Map<StockKey, SelfopInventorySnapshot> stocks = new LinkedHashMap<>();
Map<SkuKey, SelfopSkuProfile> profiles = new LinkedHashMap<>();
Set<String> hashes = new HashSet<>();
Set<Integer> observedPages = new HashSet<>();
for (var stage : stages) {
require(Objects.equals(stage.getRunId(), run.getId()) && Objects.equals(stage.getStageBatchId(), run.getId())
&& Objects.equals(stage.getLastFetchRunId(), run.getId()), "SELFOP_INVENTORY_ATTEMPT_MIXED");
require("PRODUCT_STOCK".equals(stage.getObjectType()) && "VALID".equals(stage.getValidationStatus())
&& Objects.equals(stage.getWarehouseCode(), scope.getWarehouseCode()), "SELFOP_INVENTORY_STAGE_INVALID");
require(stage.getFirstPageNo() != null && stage.getFirstPageNo() >= 1
&& stage.getFirstPageNo() <= run.getPageCount() && stage.getObservedAtUtc() != null,
"SELFOP_INVENTORY_PAGE_INVALID");
require(hashes.add(stage.getObjectKeyHash()), "SELFOP_INVENTORY_DUPLICATE_KEY");
observedPages.add(stage.getFirstPageNo());
var value = read(stage.getPayloadJson(), SelfopPayloads.Stock.class);
verifyIdentity(scope, value.sourceAccount(), value.warehouseCode(), value.customerCode(), value.skuCode());
require(value.productQuality() != null && value.productQuality() >= Short.MIN_VALUE
&& value.productQuality() <= Short.MAX_VALUE, "SELFOP_INVENTORY_QUALITY_INVALID");
require(value.productName() == null || value.productName().length() <= 255, "SELFOP_INVENTORY_NAME_INVALID");
require(value.sourceUniqueKey() == null || value.sourceUniqueKey().length() <= 512, "SELFOP_INVENTORY_SOURCE_KEY_INVALID");
require(Objects.equals(hash(value), stage.getContentHash()), "SELFOP_INVENTORY_CONTENT_CHANGED");
var key = new StockKey(value.customerCode(), value.skuCode(), value.productQuality());
require(!stocks.containsKey(key), "SELFOP_INVENTORY_DUPLICATE_KEY");
var row = snapshot(scope, run, key, now);
row.setProductName(value.productName()); row.setSourceUniqueKey(value.sourceUniqueKey());
row.setPhysicalQuantity(value.physicalQuantity()); row.setAvailableQuantity(value.availableQuantity());
row.setFrozenQuantity(value.frozenQuantity()); row.setTransitQuantity(value.transitQuantity());
row.setObservedAtUtc(stage.getObservedAtUtc());
var quality = quality(value);
row.setQualityStatus(quality[0]); row.setQualityReason(quality[1]);
if ("QUANTITY_PRECISION".equals(quality[1])) {
// The exact offending decimals remain in immutable stage evidence; never let DECIMAL silently round them.
row.setPhysicalQuantity(null); row.setAvailableQuantity(null); row.setFrozenQuantity(null); row.setTransitQuantity(null);
}
stocks.put(key, row);
var skuKey = new SkuKey(key.customer(), key.sku());
var profile = profiles.computeIfAbsent(skuKey, ignored -> profile(scope, run, skuKey, now));
profile.setProductName(profile.getProductName() == null ? value.productName() : profile.getProductName());
profile.setQuantityUnit(unit);
profile.setFirstSeenAtUtc(earlier(profile.getFirstSeenAtUtc(), stage.getObservedAtUtc()));
profile.setEarliestEvidenceAtUtc(profile.getFirstSeenAtUtc());
profile.setEvidenceType("INVENTORY_OBSERVATION"); profile.setEvidenceRunId(run.getId());
profile.setLastSeenRunId(run.getId());
}
require(stages.isEmpty() ? run.getPageCount() == 1 : observedPages.size() == run.getPageCount(),
"SELFOP_INVENTORY_PAGE_MISSING");
for (var old : priorStock) {
verifyIdentity(scope, old.getSourceAccount(), old.getWarehouseCode(), old.getCustomerCode(), old.getSkuCode());
require(Objects.equals(old.getSnapshotRunId(), run.getBasePublishedRunId()), "SELFOP_INVENTORY_BASE_INVALID");
var key = new StockKey(old.getCustomerCode(), old.getSkuCode(), old.getProductQuality());
stocks.computeIfAbsent(key, ignored -> missing(scope, run, key, old.getProductName(), now));
profiles.computeIfAbsent(new SkuKey(key.customer(), key.sku()), ignored -> profile(scope, run, ignored, now));
}
for (var old : priorProfiles) {
verifyIdentity(scope, old.getSourceAccount(), old.getWarehouseCode(), old.getCustomerCode(), old.getSkuCode());
require(Objects.equals(old.getProfileRunId(), run.getBasePublishedRunId()), "SELFOP_INVENTORY_PROFILE_BASE_INVALID");
var key = new SkuKey(old.getCustomerCode(), old.getSkuCode());
var current = profiles.computeIfAbsent(key, ignored -> profile(scope, run, key, now));
if (current.getProductName() == null) current.setProductName(old.getProductName());
if ("INVENTORY_OBSERVATION".equals(old.getEvidenceType()) && old.getFirstSeenAtUtc() != null) {
boolean observedNow = current.getLastSeenRunId() != null;
current.setFirstSeenAtUtc(earlier(current.getFirstSeenAtUtc(), old.getFirstSeenAtUtc()));
current.setEarliestEvidenceAtUtc(current.getFirstSeenAtUtc());
current.setEvidenceType("INVENTORY_OBSERVATION");
current.setEvidenceRunId(old.getEvidenceRunId());
if (!observedNow) { current.setLastSeenRunId(old.getLastSeenRunId()); current.setQuantityUnit(old.getQuantityUnit()); }
}
}
for (var sales : salesProfiles) {
verifyIdentity(scope, sales.getSourceAccount(), sales.getWarehouseCode(), sales.getCustomerCode(), sales.getSkuCode());
require(Objects.equals(sales.getProfileRunId(), run.getReferenceSalesRunId()), "SELFOP_INVENTORY_SALES_REFERENCE_INVALID");
var key = new StockKey(sales.getCustomerCode(), sales.getSkuCode(), 0);
stocks.computeIfAbsent(key, ignored -> missing(scope, run, key, sales.getProductName(), now));
var skuKey = new SkuKey(key.customer(), key.sku());
var current = profiles.computeIfAbsent(skuKey, ignored -> profile(scope, run, skuKey, now));
if (current.getProductName() == null) current.setProductName(sales.getProductName());
// Sales identifies a candidate; it cannot prove that inventory ever observed it.
}
return new Candidate(List.copyOf(stocks.values()), List.copyOf(profiles.values()));
}
private static String[] quality(SelfopPayloads.Stock stock) {
var quantities = new BigDecimal[]{ stock.physicalQuantity(), stock.availableQuantity(), stock.frozenQuantity(), stock.transitQuantity() };
boolean missing = false;
for (var quantity : quantities) {
if (quantity == null) { missing = true; continue; }
if (quantity.scale() > 6 && quantity.stripTrailingZeros().scale() > 6
|| quantity.precision() - quantity.scale() > 14) return new String[]{"INVALID", "QUANTITY_PRECISION"};
if (quantity.signum() < 0) return new String[]{"INVALID", "NEGATIVE_QUANTITY"};
}
return missing ? new String[]{"UNKNOWN", "FIELD_MISSING"} : new String[]{"VALID", null};
}
private static void verifyIdentity(SelfopSyncScope scope, String account, String warehouse, String customer, String sku) {
identity(customer, 64); identity(sku, 128);
require(Objects.equals(scope.getSourceAccount(), account) && Objects.equals(scope.getWarehouseCode(), warehouse),
"SELFOP_INVENTORY_SCOPE_LEAK");
}
private static SelfopInventorySnapshot snapshot(SelfopSyncScope scope, SelfopSyncRun run, StockKey key, LocalDateTime now) {
var row = new SelfopInventorySnapshot(); row.setId(IdWorker.getId()); row.setCreatedAt(now); row.setUpdatedAt(now);
row.setSnapshotRunId(run.getId()); row.setSourceAccount(scope.getSourceAccount()); row.setWarehouseCode(scope.getWarehouseCode());
row.setCustomerCode(key.customer()); row.setSkuCode(key.sku()); row.setProductQuality(key.quality()); return row;
}
private static SelfopInventorySnapshot missing(SelfopSyncScope scope, SelfopSyncRun run, StockKey key, String name, LocalDateTime now) {
var row = snapshot(scope, run, key, now); row.setProductName(name); row.setObservedAtUtc(run.getFetchFinishedAtUtc());
row.setQualityStatus("UNKNOWN"); row.setQualityReason("SOURCE_ROW_MISSING"); return row;
}
private static SelfopSkuProfile profile(SelfopSyncScope scope, SelfopSyncRun run, SkuKey key, LocalDateTime now) {
var row = new SelfopSkuProfile(); row.setId(IdWorker.getId()); row.setCreatedAt(now); row.setUpdatedAt(now);
row.setProfileRunId(run.getId()); row.setSourceAccount(scope.getSourceAccount()); row.setWarehouseCode(scope.getWarehouseCode());
row.setCustomerCode(key.customer()); row.setSkuCode(key.sku()); row.setEvidenceType("NONE"); return row;
}
private static LocalDateTime earlier(LocalDateTime a, LocalDateTime b) { return a == null || b != null && b.isBefore(a) ? b : a; }
}
@@ -0,0 +1,179 @@
package com.baiyue.lastleg.selfoperated.analytics.inventory;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
@Service
@RequiredArgsConstructor
public class InventorySnapshotService {
static final String DAILY_SAMPLE_RULE = "DAILY_SCHEDULED_FIRST_V1";
private static final ZoneId SAMPLE_ZONE = ZoneId.of("Asia/Shanghai");
private final SelfopCoordinationMapper coordination;
private final SelfopSyncScopeMapper scopes;
private final SelfopSyncRunMapper runs;
private final SelfopSyncTaskMapper tasks;
private final SelfopSyncStageMapper stages;
private final SelfopInventorySnapshotMapper snapshots;
private final SelfopSkuProfileMapper profiles;
private final SelfopAnalyticsProperties properties;
/** Whole candidate commits atomically. No source HTTP request occurs in this transaction. */
@Transactional(rollbackFor = Exception.class)
public void build(long scopeId, long runId, String owner, long fence) {
var scope = coordination.lockScope(scopeId);
var run = coordination.lockRun(runId);
validate(scope, run, owner, fence);
if ("READY".equals(run.getStatus()) || "PUBLISHED".equals(run.getStatus())) return;
require("VALIDATED".equals(run.getStatus()), "SELFOP_INVENTORY_NOT_VALIDATED");
require(Objects.equals(scope.getPublishedRunId(), run.getBasePublishedRunId()), "SELFOP_INVENTORY_BASE_CHANGED");
var priorStock = List.<SelfopInventorySnapshot>of();
var priorProfiles = List.<SelfopSkuProfile>of();
if (run.getBasePublishedRunId() != null) {
reference(scope, run.getBasePublishedRunId(), "INVENTORY");
priorStock = snapshots.selectList(new QueryWrapper<SelfopInventorySnapshot>().eq("snapshot_run_id", run.getBasePublishedRunId()));
priorProfiles = profiles.selectList(new QueryWrapper<SelfopSkuProfile>().eq("profile_run_id", run.getBasePublishedRunId()));
}
var salesProfiles = List.<SelfopSkuProfile>of();
if (run.getReferenceSalesRunId() != null) {
reference(scope, run.getReferenceSalesRunId(), "OUTBOUND");
salesProfiles = profiles.selectList(new QueryWrapper<SelfopSkuProfile>().eq("profile_run_id", run.getReferenceSalesRunId()));
}
var candidate = InventorySnapshotAssembler.assemble(scope, run,
stages.selectList(new QueryWrapper<SelfopSyncStage>().eq("stage_batch_id", runId).eq("object_type", "PRODUCT_STOCK").orderByAsc("id")),
priorStock, priorProfiles, salesProfiles, fixedUnit(run));
require(snapshots.selectCount(new QueryWrapper<SelfopInventorySnapshot>().eq("snapshot_run_id", runId)) == 0
&& profiles.selectCount(new QueryWrapper<SelfopSkuProfile>().eq("profile_run_id", runId)) == 0,
"SELFOP_INVENTORY_CANDIDATE_EXISTS");
run.setBuildStartedAtUtc(utcNow());run.setLastPhase("BUILDING");
for (var row : candidate.snapshots()) row.setId(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId());
for (var row : candidate.profiles()) row.setId(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId());
if(!candidate.snapshots().isEmpty())snapshots.insert(candidate.snapshots(),Math.max(1,Math.min(500,properties.getPersistenceBatchSize())));
if(!candidate.profiles().isEmpty())profiles.insert(candidate.profiles(),Math.max(1,Math.min(500,properties.getPersistenceBatchSize())));
run.setStatus("READY"); run.setUpdatedAt(utcNow()); runs.updateById(run);
}
/** Inventory publication deliberately has no outbound apply-state/revision barrier. */
@Transactional(rollbackFor = Exception.class)
public void publish(long scopeId, long runId, String owner, long fence) {
var scope = coordination.lockScope(scopeId);
var run = coordination.lockRun(runId);
validate(scope, run, owner, fence);
if ("PUBLISHED".equals(run.getStatus())) {
verifyPublishedResult(run);
return;
}
require(fixedUnit(run) != null, "SELFOP_QUANTITY_UNIT_UNCONFIRMED");
require("READY".equals(run.getStatus()) && "COMPLETE".equals(run.getCoverageStatus()), "SELFOP_INVENTORY_NOT_READY");
require(Objects.equals(scope.getPublishedRunId(), run.getBasePublishedRunId()), "SELFOP_INVENTORY_BASE_CHANGED");
var rows = snapshots.selectList(new QueryWrapper<SelfopInventorySnapshot>().eq("snapshot_run_id", runId));
var dimensions = profiles.selectList(new QueryWrapper<SelfopSkuProfile>().eq("profile_run_id", runId));
require(run.getSourceTotalCount() != null && rows.size() >= run.getSourceTotalCount(), "SELFOP_INVENTORY_CANDIDATE_INCOMPLETE");
Set<List<String>> rowSkus = new HashSet<>(), profileSkus = new HashSet<>();
for (var row : rows) {
require(Objects.equals(scope.getSourceAccount(), row.getSourceAccount())
&& Objects.equals(scope.getWarehouseCode(), row.getWarehouseCode()), "SELFOP_INVENTORY_SCOPE_LEAK");
rowSkus.add(List.of(row.getCustomerCode(), row.getSkuCode()));
}
for (var profile : dimensions) {
require(Objects.equals(scope.getSourceAccount(), profile.getSourceAccount())
&& Objects.equals(scope.getWarehouseCode(), profile.getWarehouseCode()), "SELFOP_INVENTORY_SCOPE_LEAK");
profileSkus.add(List.of(profile.getCustomerCode(), profile.getSkuCode()));
}
require(profileSkus.containsAll(rowSkus), "SELFOP_INVENTORY_PROFILE_INCOMPLETE");
var task = coordination.lockTask(run.getTaskId());
SelfopCollectionPolicy.requireBusiness(task);
require(task != null && Objects.equals(task.getScopeId(), scopeId) && Objects.equals(task.getScopeHash(), scope.getScopeHash())
&& Objects.equals(task.getSourceAccount(), scope.getSourceAccount())
&& Objects.equals(task.getWarehouseCode(), scope.getWarehouseCode())
&& "NOT_APPLICABLE".equals(task.getApplyState()), "SELFOP_INVENTORY_TASK_INVALID");
require(run.getInventoryDailySampleDate() == null && run.getInventorySampleRuleVersion() == null,
"SELFOP_INVENTORY_UNPUBLISHED_SAMPLE");
LocalDate sampleDate = eligibleSampleDate(task, run);
if (sampleDate != null) {
var selected = runs.dailyInventorySample(scope.getSourceAccount(), scope.getWarehouseCode(), sampleDate);
require(selected == null || ("PUBLISHED".equals(selected.getStatus())
&& DAILY_SAMPLE_RULE.equals(selected.getInventorySampleRuleVersion())), "SELFOP_INVENTORY_SAMPLE_INVALID");
if (selected == null) {
run.setInventoryDailySampleDate(sampleDate);
run.setInventorySampleRuleVersion(DAILY_SAMPLE_RULE);
}
}
var now = utcNow();
if(scope.getPublishedRunId()!=null)runs.update(null,new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<SelfopSyncRun>()
.eq("id",scope.getPublishedRunId()).isNull("superseded_at_utc").set("superseded_at_utc",now).set("updated_at",now));
scope.setPublishedRunId(runId); scope.setUpdatedAt(now); scopes.updateById(scope);
run.setProgressSnapshotJson(task.getProgressJson());run.setStatus("PUBLISHED"); run.setPublishedAtUtc(now); run.setFinishedAtUtc(now); run.setUpdatedAt(now); runs.updateById(run);
task.setResultPublishedRunId(runId);task.setStatus("SUCCEEDED"); task.setUpdatedAt(now); tasks.updateById(task);
}
/** Recovery verifies the committed result, never elects an old unmarked version retroactively. */
private void verifyPublishedResult(SelfopSyncRun run) {
var task = coordination.lockTask(run.getTaskId());
SelfopCollectionPolicy.requireBusiness(task);
require(task != null && Objects.equals(task.getResultPublishedRunId(), run.getId())
&& run.getPublishedAtUtc() != null, "SELFOP_INVENTORY_PUBLISHED_RESULT_INVALID");
if (run.getInventoryDailySampleDate() == null) {
require(run.getInventorySampleRuleVersion() == null, "SELFOP_INVENTORY_SAMPLE_INVALID");
return;
}
require(DAILY_SAMPLE_RULE.equals(run.getInventorySampleRuleVersion())
&& Objects.equals(run.getInventoryDailySampleDate(), eligibleSampleDate(task, run)), "SELFOP_INVENTORY_SAMPLE_INVALID");
var selected = runs.dailyInventorySample(run.getSourceAccount(), run.getWarehouseCode(), run.getInventoryDailySampleDate());
require(selected != null && Objects.equals(selected.getId(), run.getId()), "SELFOP_INVENTORY_SAMPLE_INVALID");
}
/** The observed interval, not publication/retry time, determines the Beijing observation day. */
static LocalDate eligibleSampleDate(SelfopSyncTask task, SelfopSyncRun run) {
if (!"BUSINESS".equals(task.getExecutionPurpose()) || !"BUSINESS".equals(run.getExecutionPurpose())
|| !"SCHEDULED".equals(task.getTaskType()) || !"WAREHOUSE_SYNC".equals(task.getTaskRole())
|| !"WAREHOUSE_SYNC".equals(run.getRunRole()) || !"COMPLETE".equals(run.getCoverageStatus())
|| !("READY".equals(run.getStatus()) || "PUBLISHED".equals(run.getStatus()))
|| task.getPlannedAtUtc() == null || run.getFetchStartedAtUtc() == null || run.getFetchFinishedAtUtc() == null
|| run.getFetchFinishedAtUtc().isBefore(run.getFetchStartedAtUtc())
|| run.getPageCount() == null || run.getPageCount() < 1 || run.getSourceTotalCount() == null
|| run.getSourceTotalCount() < 0 || !Objects.equals(run.getSourceTotalCount(), run.getUniqueObjectCount())) return null;
LocalDate planned = task.getPlannedAtUtc().atOffset(ZoneOffset.UTC).atZoneSameInstant(SAMPLE_ZONE).toLocalDate();
LocalDate started = run.getFetchStartedAtUtc().atOffset(ZoneOffset.UTC).atZoneSameInstant(SAMPLE_ZONE).toLocalDate();
LocalDate finished = run.getFetchFinishedAtUtc().atOffset(ZoneOffset.UTC).atZoneSameInstant(SAMPLE_ZONE).toLocalDate();
return planned.equals(started) && planned.equals(finished) ? planned : null;
}
private void reference(SelfopSyncScope scope, long referenceId, String family) {
var ref = runs.selectById(referenceId);
SelfopCollectionPolicy.requireBusiness(ref);
require(ref != null && "PUBLISHED".equals(ref.getStatus()) && "WAREHOUSE_SYNC".equals(ref.getRunRole())
&& Objects.equals(scope.getSourceAccount(), ref.getSourceAccount())
&& Objects.equals(scope.getWarehouseCode(), ref.getWarehouseCode()), "SELFOP_INVENTORY_REFERENCE_INVALID");
var refScope = scopes.selectById(ref.getScopeId());
require(refScope != null && family.equals(refScope.getDataFamily())
&& Objects.equals(scope.getSourceAccount(), refScope.getSourceAccount())
&& Objects.equals(scope.getWarehouseCode(), refScope.getWarehouseCode()), "SELFOP_INVENTORY_REFERENCE_SCOPE_INVALID");
}
private static String fixedUnit(SelfopSyncRun run) {
require(run.getScopeSnapshotJson() != null, "SELFOP_INVENTORY_CONFIG_MISSING");
Object unit = read(run.getScopeSnapshotJson(), Map.class).get("quantityUnit");
require(unit == null || unit instanceof String, "SELFOP_QUANTITY_UNIT_INVALID");
if (unit == null || ((String) unit).isBlank()) return null;
return identity((String) unit, 32);
}
private static void validate(SelfopSyncScope scope, SelfopSyncRun run, String owner, long fence) {
SelfopCollectionPolicy.requireBusiness(run);
require(scope != null && run != null && "INVENTORY".equals(scope.getDataFamily())
&& "WAREHOUSE_SYNC".equals(run.getRunRole()) && Objects.equals(scope.getId(), run.getScopeId())
&& Objects.equals(scope.getSourceAccount(), run.getSourceAccount())
&& Objects.equals(scope.getWarehouseCode(), run.getWarehouseCode())
&& Objects.equals(scope.getScopeHash(), run.getScopeHash()), "SELFOP_INVENTORY_SCOPE_INVALID");
require(owner != null && owner.equals(scope.getLeaseOwner()) && Objects.equals(scope.getFenceToken(), fence)
&& Objects.equals(run.getFenceToken(), fence) && scope.getLeaseUntilUtc() != null
&& scope.getLeaseUntilUtc().isAfter(utcNow()), "SELFOP_LEASE_LOST");
}
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.selfoperated.analytics.mapper;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface SelfopCoordinationMapper {
@Select("SELECT * FROM ll_selfop_sync_scope WHERE id=#{id} FOR UPDATE")
SelfopSyncScope lockScope(long id);
@Select("SELECT * FROM ll_selfop_outbound_discovery_scope WHERE id=#{id} FOR UPDATE")
SelfopOutboundDiscoveryScope lockDiscovery(long id);
@Select("SELECT * FROM ll_selfop_sync_task WHERE id=#{id} FOR UPDATE")
SelfopSyncTask lockTask(long id);
@Select("SELECT * FROM ll_selfop_sync_run WHERE id=#{id} FOR UPDATE")
SelfopSyncRun lockRun(long id);
@Select("SELECT * FROM ll_selfop_sync_task WHERE execution_purpose=#{purpose} AND status IN ('READY','RETRY_WAIT','RUNNING') AND (execution_purpose='BUSINESS' OR (status='READY' AND validation_root_task_id=id)) AND (next_retry_at_utc IS NULL OR next_retry_at_utc<=#{now}) ORDER BY CASE WHEN task_type IN ('RECHECK','REBUILD') THEN 1 WHEN task_role='WAREHOUSE_SYNC' THEN 0 WHEN task_type='BACKFILL' THEN 3 ELSE 2 END,id LIMIT #{limit}")
List<SelfopSyncTask> due(@Param("now") LocalDateTime now, @Param("limit") int limit, @Param("purpose") String purpose);
@Select("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE source_account=#{account} AND apply_state IN ('APPLYING','BLOCKED') AND JSON_CONTAINS(affected_warehouse_codes_json,JSON_QUOTE(#{warehouse}))")
long barriers(@Param("account") String account, @Param("warehouse") String warehouse);
@Update("UPDATE ll_selfop_sync_task SET status='SUCCEEDED',result_published_run_id=#{published},next_retry_at_utc=NULL,updated_at=#{now} WHERE scope_id=#{scope} AND scope_hash=#{hash} AND execution_purpose='BUSINESS' AND apply_state='APPLIED' AND (query_from_utc IS NULL OR (query_from_utc>=#{from} AND query_until_utc<=#{until})) AND status IN ('READY','RUNNING','RETRY_WAIT','DEAD')")
int completeCoveredTasks(@Param("scope") long scope,@Param("hash") String hash,@Param("published") long published,@Param("from") LocalDateTime from,@Param("until") LocalDateTime until,@Param("now") LocalDateTime now);
@Update("UPDATE ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id SET r.status='REUSED',r.finished_at_utc=#{now},r.updated_at=#{now},r.progress_snapshot_json=COALESCE(r.progress_snapshot_json,CASE WHEN JSON_EXTRACT(t.progress_json,'$.attemptNo')=r.attempt_no THEN t.progress_json ELSE NULL END) WHERE t.result_published_run_id=#{published} AND r.id<>#{published} AND r.attempt_no=t.attempt_count AND r.execution_purpose='BUSINESS' AND r.status IN ('FETCHING','FACTS_APPLIED','BUILDING','READY')")
int completeReusedRuns(@Param("published") long published,@Param("now") LocalDateTime now);
@Update("UPDATE ll_selfop_sync_task SET status='SUCCEEDED',result_published_run_id=#{published},next_retry_at_utc=NULL,updated_at=#{now} WHERE scope_id=#{scope} AND scope_hash=#{hash} AND execution_purpose='BUSINESS' AND task_type='SCHEDULED' AND apply_state='NOT_APPLICABLE' AND status='WAITING_SOURCE' AND planned_at_utc<=#{until}")
int completeCoveredMarkers(@Param("scope") long scope,@Param("hash") String hash,@Param("published") long published,@Param("until") LocalDateTime until,@Param("now") LocalDateTime now);
@Update("<script>UPDATE ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id SET r.heartbeat_at_utc=#{now},r.updated_at=#{now} WHERE r.fence_token=#{fence} AND t.status='RUNNING' AND r.status IN ('FETCHING','VALIDATED','FACTS_APPLIED','BUILDING','READY') <choose><when test='account'>AND t.discovery_scope_id=#{scope}</when><otherwise>AND r.scope_id=#{scope}</otherwise></choose></script>")
int heartbeat(@Param("scope") long scope,@Param("account") boolean account,@Param("fence") long fence,@Param("now") LocalDateTime now);
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.selfoperated.analytics.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopInventorySnapshot;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SelfopInventorySnapshotMapper extends BaseMapper<SelfopInventorySnapshot> {
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.selfoperated.analytics.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopOutboundDiscoveryScope;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SelfopOutboundDiscoveryScopeMapper extends BaseMapper<SelfopOutboundDiscoveryScope> {
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.selfoperated.analytics.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopOutboundItem;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SelfopOutboundItemMapper extends BaseMapper<SelfopOutboundItem> {
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.selfoperated.analytics.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopOutboundOrder;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SelfopOutboundOrderMapper extends BaseMapper<SelfopOutboundOrder> {
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.selfoperated.analytics.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSkuDailySales;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SelfopSkuDailySalesMapper extends BaseMapper<SelfopSkuDailySales> {
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.selfoperated.analytics.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSkuProfile;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SelfopSkuProfileMapper extends BaseMapper<SelfopSkuProfile> {
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.selfoperated.analytics.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncRun;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDate;
@Mapper
public interface SelfopSyncRunMapper extends BaseMapper<SelfopSyncRun> {
/** Current read after acquiring the inventory scope lock, including after a competing publisher commits. */
@Select("SELECT * FROM ll_selfop_sync_run WHERE source_account=#{account} AND warehouse_code=#{warehouse} AND inventory_daily_sample_date=#{date} FOR UPDATE")
SelfopSyncRun dailyInventorySample(@Param("account") String account, @Param("warehouse") String warehouse,
@Param("date") LocalDate date);
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.selfoperated.analytics.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncScope;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SelfopSyncScopeMapper extends BaseMapper<SelfopSyncScope> {
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.selfoperated.analytics.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncStage;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SelfopSyncStageMapper extends BaseMapper<SelfopSyncStage> {
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.selfoperated.analytics.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncTask;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SelfopSyncTaskMapper extends BaseMapper<SelfopSyncTask> {
}
@@ -0,0 +1,66 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@Component
@ConfigurationProperties(prefix = "baiyue.self-operated-analytics")
public class SelfopAnalyticsProperties {
private String collectionMode = "OFF";
private String environment = "test";
private String exclusiveGrantId;
private LocalDateTime exclusiveGrantExpiresAtUtc;
private boolean exclusiveHandoffVerified = false;
private boolean priorEgressClosed = false;
private boolean inflightDrained = false;
private boolean transferredBudgetVerified = false;
private long transferredDailyHttpCount = 0;
private LocalDate transferredBudgetDate;
private LocalDateTime transferredCooldownUntilUtc;
private String transferredPauseReason;
private int dailyRequestBudget = 0;
private int businessMinRequestIntervalMs = 5000;
private int maxRateLimitFailures = 5;
private int cooldownSeconds = 60;
private int egressLeaseSeconds = 120;
private boolean retentionEnabled = false;
private int salesRetentionDays = 7;
private int persistenceBatchSize = 500;
private int minimumBuildIntervalSeconds = 1800;
/** Read-only UI projection; collectionMode is the sole runtime mode input. */
public boolean isExecutionEnabled() {
return "VALIDATION".equals(collectionMode)||("BUSINESS".equals(collectionMode)&&"production".equals(environment));
}
private boolean salesPublicationEnabled = false;
private String integrationBaseUrl = "http://integration-service";
private String internalServiceToken = "";
private String sourceAccount = "";
private String sourceQueryTimezone;
private String quantityUnit;
private int requestBudget = 100;
private int timeBudgetSeconds = 120;
private int minRequestIntervalMs = 1100;
private int leaseSeconds = 180;
private int maxAttempts = 5;
private int pageSize = 100;
private int applyBatchSize = 100;
private Map<String, Warehouse> warehouses = new LinkedHashMap<>();
@Data
public static class Warehouse {
private String businessTimezone = "Asia/Shanghai";
private String sourceTimezone = "Asia/Shanghai";
private String outboundCron = "0 0 8,20 * * *";
private String inventoryCron = "0 0 8 * * *";
private int inventoryStaleAfterSeconds = 93600;
private int outboundStaleAfterSeconds = 93600;
private int reviewRequestBudget = 100;
private int reviewTimeBudgetSeconds = 120;
private int reviewTargetDays = 7;
}
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.require;
/** Purpose is persisted on every task/run. Unknown purpose is never treated as business. */
public final class SelfopCollectionPolicy {
private SelfopCollectionPolicy() { }
public static void requireBusiness(SelfopSyncTask task) {
require(task != null && "BUSINESS".equals(task.getExecutionPurpose()) && task.getValidationRootTaskId()==null,
"SELFOP_BUSINESS_PURPOSE_REQUIRED");
}
public static void requireBusiness(SelfopSyncRun run) {
require(run != null && "BUSINESS".equals(run.getExecutionPurpose()),"SELFOP_BUSINESS_PURPOSE_REQUIRED");
}
public static void requireExecution(SelfopAnalyticsProperties config,String purpose) {
require("VALIDATION".equals(purpose)||"BUSINESS".equals(purpose),"SELFOP_PURPOSE_INVALID");
require(purpose.equals(config.getCollectionMode()),"SELFOP_COLLECTION_MODE_REJECTED");
if("BUSINESS".equals(purpose))require("production".equals(config.getEnvironment()),"SELFOP_BUSINESS_ENVIRONMENT_REJECTED");
}
public static String configuredPurpose(SelfopAnalyticsProperties config) {
String purpose=config.getCollectionMode();requireExecution(config,purpose);return purpose;
}
}
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("ll_selfop_inventory_snapshot")
public class SelfopInventorySnapshot {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Long snapshotRunId;
private String sourceAccount;
private String customerCode;
private String warehouseCode;
private String skuCode;
private Integer productQuality;
private String productName;
private BigDecimal physicalQuantity;
private BigDecimal availableQuantity;
private BigDecimal frozenQuantity;
private BigDecimal transitQuantity;
private String sourceUniqueKey;
private LocalDateTime observedAtUtc;
private String qualityStatus;
private String qualityReason;
}
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("ll_selfop_outbound_discovery_scope")
public class SelfopOutboundDiscoveryScope {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String sourceAccount;
private LocalDateTime catalogFromUtc;
private LocalDateTime catalogUntilUtc;
private Long nextSequence;
private Long nextObservationSequence;
private Long activeTaskId;
private String leaseOwner;
private LocalDateTime leaseUntilUtc;
private Long fenceToken;
private Integer requestBudget;
private Integer timeBudgetSeconds;
private Integer minRequestIntervalMs;
private String apiBudgetJson;
private LocalDateTime apiCooldownUntilUtc;
private String apiPauseReason;
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("ll_selfop_outbound_item")
public class SelfopOutboundItem {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Long orderId;
private String skuCode;
private String productName;
private BigDecimal actualQuantity;
private String quantityUnit;
}
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("ll_selfop_outbound_order")
public class SelfopOutboundOrder {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String sourceAccount;
private String customerCode;
private String warehouseCode;
private String deliveryNo;
private String sourceNo;
private Integer sourceStatus;
private String sourceCreatedTime;
private String sourceOutboundTime;
private String sourceTimezone;
private LocalDateTime outboundAtUtc;
private LocalDate businessDate;
private String businessTimezone;
private Integer salesEligible;
private Long revisionNo;
private String contentHash;
private Long lastRunId;
private Long lastObservationSequence;
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import java.math.BigDecimal;
import java.util.List;
public final class SelfopPayloads {
private SelfopPayloads() { }
public record Order(String sourceAccount, String customerCode, String warehouseCode,
String deliveryNo, String sourceNo, Integer sourceStatus, String sourceCreatedTime,
String sourceOutboundTime, List<Item> items) { }
public record Item(String skuCode, String productName, BigDecimal actualQuantity, String quantityUnit) { }
public record Stock(String sourceAccount, String customerCode, String warehouseCode,
String skuCode, Integer productQuality, String productName, BigDecimal physicalQuantity,
BigDecimal availableQuantity, BigDecimal frozenQuantity, BigDecimal transitQuantity,
String sourceUniqueKey) { }
}
@@ -0,0 +1,68 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import java.nio.charset.StandardCharsets;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.*;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
public final class SelfopProgressProjection {
private SelfopProgressProjection() { }
private static final Set<String> PHASES = Set.of("WAITING_SOURCE", "DIRECTORY", "DETAIL", "INVENTORY",
"VALIDATION", "MERGE", "BUILD", "PUBLISH", "WAITING", "SAMPLE_DONE", "FAILED");
/** Pure projection for callers already holding the scope/task/run locks and fence. */
public static void applyCheckpoint(SelfopSyncTask task,SelfopSyncRun run,String phase,String unit,long completed,Long total,int scan) {
require(PHASES.contains(phase),"SELFOP_PROGRESS_PHASE_INVALID");
require(Set.of("PAGE","ORDER","INVENTORY_ROW","BUSINESS_KEY","BATCH","NONE").contains(unit),"SELFOP_PROGRESS_UNIT_INVALID");
require(completed>=0 && scan>=0 && (total==null || total>=completed),"SELFOP_PROGRESS_COUNTS_INVALID");
Map<String,Object> p=progress(task);
if(!Objects.equals(number(p.get("attemptNo")),run.getAttemptNo().longValue()))p=new LinkedHashMap<>();
String countScope=scope(task); Object started=p.get("stageStartedAt");
if(!phase.equals(p.get("phase")) || started==null)started=instant(utcNow());
p.put("attemptNo",run.getAttemptNo()); p.put("phase",phase); p.put("progressScope",countScope);
p.put("stageStartedAt",started); p.put("unit",unit); p.put("completed",completed);
p.put("total",total); p.put("totalKnown",total!=null); p.put("scanRound",scan); p.put("countBasis",Set.of("INVENTORY","BUILD").contains(phase)?"ATTEMPT_ONLY":"TASK_CUMULATIVE"); p.put("sliceFrom",instant(task.getQueryFromUtc())); p.put("sliceUntil",instant(task.getQueryUntilUtc())); p.put("stageFinishedAt",null);
// A zero denominator is meaningful evidence, but cannot produce a percentage.
p.put("phasePercent",total==null||total==0?null:BigDecimal.valueOf(completed).multiply(BigDecimal.valueOf(100))
.divide(BigDecimal.valueOf(total),1,RoundingMode.DOWN));
List<Map<String,Object>> stages=new ArrayList<>();
if(p.get("stages") instanceof List<?> prior)for(Object value:prior) {
var entry=map(value);
if(PHASES.contains(Objects.toString(entry.get("phase"),""))&&!phase.equals(entry.get("phase")))stages.add(entry);
}
var current=new LinkedHashMap<String,Object>();
for(String key:List.of("phase","unit","completed","total","totalKnown","scanRound","progressScope","countBasis",
"attemptNo","sliceFrom","sliceUntil","stageStartedAt","stageFinishedAt","phasePercent"))current.put(key,p.get(key));
stages.add(current);p.remove("phases");p.put("stages",stages);
task.setProgressJson(bounded(p));task.setProgressUpdatedAtUtc(utcNow());task.setUpdatedAt(utcNow());
run.setLastPhase(phase); run.setHeartbeatAtUtc(utcNow()); run.setUpdatedAt(utcNow());
}
/** Pure terminal snapshot; historical attempt evidence is immutable. */
public static void freezeRun(SelfopSyncTask task,SelfopSyncRun run) {
if(run.getProgressSnapshotJson()!=null)return;
if(task.getProgressJson()==null) {
var fallback=new LinkedHashMap<String,Object>();fallback.put("attemptNo",run.getAttemptNo());
fallback.put("phase",run.getLastPhase()!=null&&PHASES.contains(run.getLastPhase())?run.getLastPhase():"WAITING");
fallback.put("progressScope",scope(task));fallback.put("countBasis","TASK_CUMULATIVE");fallback.put("scanRound",0);
fallback.put("unit","NONE");fallback.put("completed",0);fallback.put("total",null);fallback.put("totalKnown",false);
fallback.put("sliceFrom",instant(task.getQueryFromUtc()));fallback.put("sliceUntil",instant(task.getQueryUntilUtc()));
fallback.put("stageStartedAt",instant(run.getFetchStartedAtUtc()));fallback.put("stageFinishedAt",null);fallback.put("stages",List.of());
run.setProgressSnapshotJson(bounded(fallback));
return;
}
var p=progress(task);
// A later attempt's projection must never be copied into an earlier historical run.
require(Objects.equals(number(p.get("attemptNo")),run.getAttemptNo().longValue()),"SELFOP_PROGRESS_ATTEMPT_MISMATCH");
run.setProgressSnapshotJson(bounded(p));run.setUpdatedAt(utcNow());
}
private static String bounded(Map<String,Object> p) {String value=json(p);require(value.getBytes(StandardCharsets.UTF_8).length<=8192,"SELFOP_PROGRESS_TOO_LARGE");return value;}
private static String scope(SelfopSyncTask task){return "ACCOUNT_DISCOVERY".equals(task.getTaskRole())?"ACCOUNT_SHARED":"WAREHOUSE";}
@SuppressWarnings("unchecked") private static Map<String,Object> progress(SelfopSyncTask task){return task.getProgressJson()==null?new LinkedHashMap<>():read(task.getProgressJson(),LinkedHashMap.class);}
@SuppressWarnings("unchecked") private static Map<String,Object> map(Object value){return value instanceof Map<?,?>?new LinkedHashMap<>((Map<String,Object>)value):new LinkedHashMap<>();}
private static Long number(Object value){return value instanceof Number n?n.longValue():null;}
private static String instant(LocalDateTime value){return value==null?null:value.toInstant(ZoneOffset.UTC).toString();}
}
@@ -0,0 +1,11 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
/** Collector-owned identity; no credentials or provider protocol live in this neutral model. */
public record SelfopRequestContext(String sourceAccount,String executionPurpose,Long rootTaskId,
String sessionId,String exclusiveGrantId,Long taskId,Long runId) {
public static SelfopRequestContext forTask(SelfopSyncTask task,SelfopSyncRun run,SelfopAnalyticsProperties properties) {
Long root="VALIDATION".equals(task.getExecutionPurpose())?task.getValidationRootTaskId():task.getId();
return new SelfopRequestContext(task.getSourceAccount(),task.getExecutionPurpose(),root,
"selfop-"+root,properties.getExclusiveGrantId(),task.getId(),run.getId());
}
}
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import lombok.Data;
import java.util.LinkedHashMap;
import java.util.Map;
/** Durable directory cursor and effective immutable stage references. */
@Data
public class SelfopRoutingManifest {
private int scan = 1;
private int nextPage = 1;
private Integer pages;
private Long total;
private boolean directoryComplete;
private Map<String, Entry> objects = new LinkedHashMap<>();
private Map<String, String> firstScan = new LinkedHashMap<>();
@Data
public static class Entry {
private String customerCode;
private String deliveryNo;
private String warehouseCode;
private Long directoryStageId;
private Long effectiveStageId;
private Long refreshTaskId;
private Long newestSeenSequence;
private Long appliedStageId;
}
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("ll_selfop_sku_daily_sales")
public class SelfopSkuDailySales {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Long salesRunId;
private String sourceAccount;
private String customerCode;
private String warehouseCode;
private String skuCode;
private LocalDate businessDate;
private BigDecimal outboundQuantity;
private Long outboundOrderCount;
private String quantityUnit;
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("ll_selfop_sku_profile")
public class SelfopSkuProfile {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Long profileRunId;
private String sourceAccount;
private String customerCode;
private String warehouseCode;
private String skuCode;
private String productName;
private String quantityUnit;
private LocalDateTime firstSeenAtUtc;
private LocalDateTime earliestEvidenceAtUtc;
private String evidenceType;
private Long evidenceRunId;
private Long lastSeenRunId;
}
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.HexFormat;
public final class SelfopSupport {
private static final ObjectMapper JSON = new ObjectMapper().findAndRegisterModules()
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS);
private SelfopSupport() { }
public static LocalDateTime utcNow() { return LocalDateTime.now(ZoneOffset.UTC); }
public static String json(Object value) {
try { return JSON.writeValueAsString(value); }
catch (Exception e) { throw new IllegalArgumentException("SELFOP_PAYLOAD_INVALID"); }
}
public static <T> T read(String value, Class<T> type) {
try { return JSON.readValue(value, type); }
catch (Exception e) { throw new IllegalArgumentException("SELFOP_PAYLOAD_INVALID"); }
}
public static String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(json(value).getBytes(StandardCharsets.UTF_8))); }
catch (Exception e) { throw new IllegalStateException("SELFOP_HASH_FAILED"); }
}
public static void require(boolean valid, String code) {
if (!valid) throw new IllegalStateException(code);
}
public static String identity(String value, int max) {
require(value != null && !value.isBlank() && value.length() <= max, "SELFOP_IDENTITY_INVALID");
return value;
}
}
@@ -0,0 +1,56 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("ll_selfop_sync_run")
public class SelfopSyncRun {
private String executionPurpose;
private LocalDateTime buildStartedAtUtc;
private LocalDateTime supersededAtUtc;
private String progressSnapshotJson;
private LocalDateTime heartbeatAtUtc;
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Long taskId;
private Long scopeId;
private String sourceAccount;
private String warehouseCode;
private String runRole;
private Long upstreamTaskId;
private Integer attemptNo;
private String scopeHash;
private String scopeSnapshotJson;
private String observedDimensionsJson;
private Long fenceToken;
private Long basePublishedRunId;
private Long referenceSalesRunId;
private String status;
private String lastPhase;
private LocalDateTime fetchStartedAtUtc;
private LocalDateTime fetchFinishedAtUtc;
private LocalDate inventoryDailySampleDate;
private String inventorySampleRuleVersion;
private LocalDateTime finishedAtUtc;
private LocalDateTime publishedAtUtc;
private Integer pageCount;
private Long sourceTotalCount;
private Long uniqueObjectCount;
private String coverageStatus;
private LocalDateTime coveredFromUtc;
private LocalDateTime coveredUntilUtc;
private LocalDate completeDayFrom;
private LocalDate completeDayUntil;
private Long appliedFactRevision;
private Long calculatedFactRevision;
private String errorCode;
private String errorMessage;
}
@@ -0,0 +1,51 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("ll_selfop_sync_scope")
public class SelfopSyncScope {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String sourceAccount;
private String warehouseCode;
private String dataFamily;
private String scopeJson;
private String scopeHash;
private String businessTimezone;
private String sourceTimezone;
private String scheduleCron;
private Integer staleAfterSeconds;
private String warehousePolicyRef;
private LocalDateTime initialFromUtc;
private LocalDateTime initialUntilUtc;
private LocalDateTime discoveryFromUtc;
private LocalDateTime discoveryUntilUtc;
private Long factRevision;
private Long publishedFactRevision;
private Integer rebuildRequired;
private LocalDateTime invalidatedAtUtc;
private Long publishedRunId;
private String leaseOwner;
private LocalDateTime leaseUntilUtc;
private Long fenceToken;
private Long knownOrderCursorId;
private Long knownOrderRoundMaxId;
private LocalDateTime knownOrderRoundStartedAtUtc;
private LocalDateTime knownOrderRoundFinishedAtUtc;
private LocalDateTime historyRoundFromUtc;
private LocalDateTime historyRoundUntilUtc;
private LocalDateTime historySliceCursorUtc;
private LocalDateTime historyRoundFinishedAtUtc;
private Integer reviewRequestBudget;
private Integer reviewTimeBudgetSeconds;
private Integer reviewTargetDays;
}
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("ll_selfop_sync_stage")
public class SelfopSyncStage {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Long runId;
private Long stageBatchId;
private Long lastFetchRunId;
private Long observationSequence;
private String warehouseCode;
private String previousWarehouseCode;
private String objectType;
private String objectKeyHash;
private Integer firstPageNo;
private LocalDateTime observedAtUtc;
private String contentHash;
private String payloadJson;
private String beforePayloadJson;
private String validationStatus;
private String validationCode;
}
@@ -0,0 +1,50 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("ll_selfop_sync_task")
public class SelfopSyncTask {
private String taskNo;
private String executionPurpose;
private Long validationRootTaskId;
private String validationBudgetJson;
private Long resultPublishedRunId;
private String progressJson;
private LocalDateTime progressUpdatedAtUtc;
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String sourceAccount;
private String taskRole;
private Long scopeId;
private String warehouseCode;
private Long discoveryScopeId;
private Long upstreamTaskId;
private Long discoverySequence;
private Long observationBaseline;
private String requestKey;
private String requestHash;
private String taskType;
private LocalDateTime plannedAtUtc;
private LocalDateTime queryFromUtc;
private LocalDateTime queryUntilUtc;
private String queryScopeJson;
private String routingManifestJson;
private String scopeHash;
private String status;
private String applyState;
private String affectedWarehouseCodesJson;
private LocalDateTime applyStartedAtUtc;
private LocalDateTime applyCompletedAtUtc;
private Integer attemptCount;
private LocalDateTime nextRetryAtUtc;
private String requestedBy;
}
@@ -0,0 +1,163 @@
package com.baiyue.lastleg.selfoperated.analytics.outbound;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import org.apache.ibatis.annotations.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** Database aggregation, with bounded result and insert batches. No raw historical rows enter Java. */
@Mapper
public interface OutboundAggregateMapper {
@Select("""
SELECT COUNT(*) FROM ll_selfop_outbound_order o
LEFT JOIN ll_selfop_outbound_item i ON i.order_id=o.id
LEFT JOIN ll_selfop_sync_stage s ON s.observation_sequence=o.last_observation_sequence
AND s.warehouse_code=o.warehouse_code AND s.object_type='OUTBOUND_ORDER'
AND s.validation_status='VALID'
AND EXISTS (SELECT 1 FROM ll_selfop_sync_run origin WHERE origin.id=s.run_id AND origin.source_account=o.source_account)
LEFT JOIN ll_selfop_sync_run r ON r.id=s.run_id AND r.source_account=o.source_account
LEFT JOIN ll_selfop_sync_task t ON t.id=r.task_id
WHERE o.source_account=#{account} AND o.warehouse_code=#{warehouse} AND o.sales_eligible=1
AND (o.outbound_at_utc IS NULL OR o.business_date IS NULL
OR o.source_timezone IS NULL OR o.source_timezone != #{sourceTimezone}
OR o.business_timezone IS NULL OR o.business_timezone != #{businessTimezone}
OR i.id IS NULL OR i.actual_quantity IS NULL OR i.actual_quantity < 0
OR i.quantity_unit IS NULL OR i.quantity_unit != #{unit}
OR r.id IS NULL OR r.execution_purpose IS NULL OR r.execution_purpose != 'BUSINESS'
OR t.execution_purpose IS NULL OR t.execution_purpose != 'BUSINESS' OR t.validation_root_task_id IS NOT NULL)
""")
long invalidFacts(@Param("account") String account, @Param("warehouse") String warehouse,
@Param("sourceTimezone") String sourceTimezone, @Param("businessTimezone") String businessTimezone,
@Param("unit") String unit);
@Select("""
<script>
SELECT o.customer_code,i.sku_code,o.business_date,
SUM(i.actual_quantity) outbound_quantity,COUNT(DISTINCT o.id) outbound_order_count,
MAX(i.quantity_unit) quantity_unit
FROM ll_selfop_outbound_order o JOIN ll_selfop_outbound_item i ON i.order_id=o.id
WHERE o.source_account=#{account} AND o.warehouse_code=#{warehouse} AND o.sales_eligible=1
<if test='customer != null'>
AND (o.customer_code > #{customer} OR (o.customer_code=#{customer} AND i.sku_code > #{sku})
OR (o.customer_code=#{customer} AND i.sku_code=#{sku} AND o.business_date > #{date}))
</if>
GROUP BY o.customer_code,i.sku_code,o.business_date
ORDER BY o.customer_code,i.sku_code,o.business_date LIMIT 500
</script>
""")
List<SelfopSkuDailySales> dailyBatch(@Param("account") String account,@Param("warehouse") String warehouse,
@Param("customer") String customer,@Param("sku") String sku,@Param("date") LocalDate date);
@Select("""
<script>
SELECT customer_code,sku_code,MAX(product_name) product_name,MAX(quantity_unit) quantity_unit,
MIN(observed_at_utc) first_seen_at_utc,MIN(outbound_at_utc) earliest_evidence_at_utc,
MAX(CASE WHEN outbound_rank=1 THEN observation_run_id END) evidence_run_id,
MAX(CASE WHEN seen_rank=1 THEN observation_run_id END) last_seen_run_id
FROM (
SELECT o.customer_code,i.sku_code,i.product_name,i.quantity_unit,o.outbound_at_utc,
s.observed_at_utc,s.run_id observation_run_id,
ROW_NUMBER() OVER (PARTITION BY o.customer_code,i.sku_code ORDER BY o.outbound_at_utc,o.id) outbound_rank,
ROW_NUMBER() OVER (PARTITION BY o.customer_code,i.sku_code ORDER BY s.observed_at_utc DESC,s.id DESC) seen_rank
FROM ll_selfop_outbound_order o JOIN ll_selfop_outbound_item i ON i.order_id=o.id
JOIN ll_selfop_sync_stage s ON s.observation_sequence=o.last_observation_sequence
AND s.warehouse_code=o.warehouse_code AND s.object_type='OUTBOUND_ORDER' AND s.validation_status='VALID'
JOIN ll_selfop_sync_run r ON r.id=s.run_id AND r.source_account=o.source_account AND r.execution_purpose='BUSINESS'
JOIN ll_selfop_sync_task t ON t.id=r.task_id AND t.execution_purpose='BUSINESS' AND t.validation_root_task_id IS NULL
WHERE o.source_account=#{account} AND o.warehouse_code=#{warehouse} AND o.sales_eligible=1
<if test='customer != null'>
AND (o.customer_code > #{customer} OR (o.customer_code=#{customer} AND i.sku_code > #{sku}))
</if>
) accepted
GROUP BY customer_code,sku_code ORDER BY customer_code,sku_code LIMIT 500
</script>
""")
List<SelfopSkuProfile> profileBatch(@Param("account") String account,@Param("warehouse") String warehouse,
@Param("customer") String customer,@Param("sku") String sku);
@Select("""
<script>
SELECT * FROM ll_selfop_sku_profile WHERE profile_run_id=#{runId}
<if test='customer != null'>AND (customer_code > #{customer} OR (customer_code=#{customer} AND sku_code > #{sku}))</if>
ORDER BY customer_code,sku_code LIMIT 500
</script>
""")
List<SelfopSkuProfile> publishedProfileBatch(@Param("runId") Long runId,@Param("customer") String customer,@Param("sku") String sku);
@Insert("""
<script>
INSERT INTO ll_selfop_sku_daily_sales
(id,created_at,updated_at,sales_run_id,source_account,customer_code,warehouse_code,sku_code,business_date,
outbound_quantity,outbound_order_count,quantity_unit) VALUES
<foreach collection='rows' item='r' separator=','>
(#{r.id},#{r.createdAt},#{r.updatedAt},#{r.salesRunId},#{r.sourceAccount},#{r.customerCode},#{r.warehouseCode},
#{r.skuCode},#{r.businessDate},#{r.outboundQuantity},#{r.outboundOrderCount},#{r.quantityUnit})
</foreach>
</script>
""")
int insertDaily(@Param("rows") List<SelfopSkuDailySales> rows);
@Insert("""
<script>
INSERT INTO ll_selfop_sku_profile
(id,created_at,updated_at,profile_run_id,source_account,customer_code,warehouse_code,sku_code,product_name,
quantity_unit,first_seen_at_utc,earliest_evidence_at_utc,evidence_type,evidence_run_id,last_seen_run_id) VALUES
<foreach collection='rows' item='r' separator=','>
(#{r.id},#{r.createdAt},#{r.updatedAt},#{r.profileRunId},#{r.sourceAccount},#{r.customerCode},#{r.warehouseCode},
#{r.skuCode},#{r.productName},#{r.quantityUnit},#{r.firstSeenAtUtc},#{r.earliestEvidenceAtUtc},#{r.evidenceType},
#{r.evidenceRunId},#{r.lastSeenRunId})
</foreach>
</script>
""")
int insertProfiles(@Param("rows") List<SelfopSkuProfile> rows);
@Select("""
<script>
SELECT id,query_from_utc,query_until_utc FROM ll_selfop_sync_task WHERE scope_id=#{scopeId} AND scope_hash=#{scopeHash}
AND execution_purpose='BUSINESS' AND apply_state='APPLIED'
AND query_from_utc IS NOT NULL AND query_until_utc IS NOT NULL
AND JSON_UNQUOTE(JSON_EXTRACT(routing_manifest_json,'$.directoryComplete'))='true'
<if test='id != null'>AND (query_from_utc > #{from} OR (query_from_utc=#{from} AND id > #{id}))</if>
ORDER BY query_from_utc,id LIMIT 500
</script>
""")
List<SelfopSyncTask> coverageBatch(@Param("scopeId") Long scopeId,@Param("scopeHash") String scopeHash,
@Param("from") LocalDateTime from,@Param("id") Long id);
@Select("""
<script>
SELECT CAST(#{initial} AS DATETIME) AS covered_from_utc,MAX(until_at) AS covered_until_utc
FROM (
SELECT from_at,until_at,SUM(CASE WHEN previous_until IS NULL OR from_at > previous_until THEN 1 ELSE 0 END)
OVER (ORDER BY from_at,until_at ROWS UNBOUNDED PRECEDING) AS island
FROM (
SELECT from_at,until_at,MAX(until_at)
OVER (ORDER BY from_at,until_at ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) AS previous_until
FROM (
SELECT query_from_utc AS from_at,query_until_utc AS until_at FROM ll_selfop_sync_task
WHERE scope_id=#{scopeId} AND scope_hash=#{scopeHash} AND execution_purpose='BUSINESS' AND apply_state='APPLIED'
AND query_from_utc IS NOT NULL AND query_until_utc > query_from_utc AND query_until_utc >= #{initial}
AND JSON_UNQUOTE(JSON_EXTRACT(routing_manifest_json,'$.directoryComplete'))='true'
UNION ALL SELECT CAST(#{initial} AS DATETIME),CAST(#{initial} AS DATETIME)
<if test='discoveryFrom != null and discoveryUntil != null'>
UNION ALL SELECT CAST(#{discoveryFrom} AS DATETIME),CAST(#{discoveryUntil} AS DATETIME)
WHERE #{discoveryUntil} > #{discoveryFrom} AND #{discoveryUntil} >= #{initial}
</if>
) AS intervals
) AS with_previous
) AS islands
GROUP BY island HAVING MIN(from_at) &lt;= #{initial} AND MAX(until_at) > #{initial}
</script>
""")
SelfopSyncRun contiguousCoverage(@Param("scopeId") Long scopeId,@Param("scopeHash") String scopeHash,
@Param("initial") LocalDateTime initial,@Param("discoveryFrom") LocalDateTime discoveryFrom,
@Param("discoveryUntil") LocalDateTime discoveryUntil);
@Select("""
SELECT COUNT(*) FROM ll_selfop_outbound_order WHERE source_account=#{account} AND warehouse_code=#{warehouse}
AND sales_eligible=1 AND updated_at >= #{buildStarted}
""")
long observationsChangedSince(@Param("account") String account,@Param("warehouse") String warehouse,
@Param("buildStarted") LocalDateTime buildStarted);
}
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.selfoperated.analytics.outbound;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncTask;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.*;
/** Orchestration has no encompassing transaction: checkpoints and heartbeats commit after every batch. */
@Service
@RequiredArgsConstructor
public class OutboundCandidateBuilder {
private final OutboundCandidateTransaction transaction;
public void build(Long scopeId,Long runId,String owner,long fence) {
OutboundCandidateTransaction.Checkpoint checkpoint=transaction.begin(scopeId,runId,owner,fence);
while(!"FINISH".equals(checkpoint.phase()) && !"DONE".equals(checkpoint.phase()))checkpoint=transaction.batch(scopeId,runId,owner,fence);
if("FINISH".equals(checkpoint.phase()))transaction.finish(scopeId,runId,owner,fence);
}
public Long reusable(Long scopeId,Long runId,String owner,long fence) {return transaction.reusable(scopeId,runId,owner,fence);}
public static boolean regularBuildDue(SelfopSyncTask task,LocalDateTime previousBuild,LocalDateTime now) {
return "REBUILD".equals(task.getTaskType()) || "RECHECK".equals(task.getTaskType()) || task.getAttemptCount()!=null && task.getAttemptCount()>1
|| previousBuild==null || !now.isBefore(previousBuild.plusMinutes(30));
}
public static Coverage contiguous(LocalDateTime initialFrom,List<Coverage> intervals) {
if(initialFrom==null)return new Coverage(null,null);LocalDateTime until=initialFrom;
for(Coverage interval:intervals.stream().filter(i->i.from()!=null && i.until()!=null && i.from().isBefore(i.until()))
.sorted(Comparator.comparing(Coverage::from)).toList()) {
if(interval.from().isAfter(until))break;if(interval.until().isAfter(until))until=interval.until();
}
return until.equals(initialFrom)?new Coverage(null,null):new Coverage(initialFrom,until);
}
public record Coverage(LocalDateTime from,LocalDateTime until) { }
}
@@ -0,0 +1,159 @@
package com.baiyue.lastleg.selfoperated.analytics.outbound;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.time.*;
import java.util.*;
/** Each public method is one committed bounded transaction, invoked through Spring's proxy by the facade. */
@Service
@RequiredArgsConstructor
public class OutboundCandidateTransaction {
private final OutboundLockMapper locks;
private final OutboundFactService facts;
private final SelfopSyncRunMapper runs;
private final SelfopSyncTaskMapper tasks;
private final SelfopSyncScopeMapper scopes;
private final SelfopSkuDailySalesMapper daily;
private final SelfopSkuProfileMapper profiles;
private final OutboundAggregateMapper aggregates;
private final SelfopAnalyticsProperties config;
public record Checkpoint(Long runId,String phase,String customer,String sku,LocalDate date,long dailyRows,long profileRows,long committedBatches) { }
private record Held(SelfopSyncScope scope,SelfopSyncRun run,SelfopSyncTask task) { }
@Transactional(isolation=Isolation.REPEATABLE_READ,rollbackFor=Exception.class)
public Checkpoint begin(Long scopeId,Long runId,String owner,long fence) {
Held held=held(scopeId,runId,owner,fence,false);
SelfopSyncRun run=held.run(); SelfopSyncScope scope=held.scope();
SelfopSupport.require(!Set.of("PUBLISHED","REUSED","FAILED","DEAD").contains(run.getStatus()),"CANDIDATE_RUN_INVALID");
SelfopSupport.require("APPLIED".equals(held.task().getApplyState()) || "REBUILD".equals(held.task().getTaskType()),"TASK_NOT_APPLIED");
facts.assertNoBarrier(scope); validateFacts(scope);
if("READY".equals(run.getStatus())) return new Checkpoint(runId,"DONE",null,null,null,0,0,0);
Checkpoint existing=checkpoint(held.task());
if(run.getBuildStartedAtUtc()!=null && existing!=null && Objects.equals(existing.runId(),runId)) {
SelfopSupport.require("BUILDING".equals(run.getStatus()) && Objects.equals(scope.getFactRevision(),run.getCalculatedFactRevision()),"CANDIDATE_FACT_REVISION_CHANGED");
SelfopSupport.require(aggregates.observationsChangedSince(scope.getSourceAccount(),scope.getWarehouseCode(),run.getBuildStartedAtUtc())==0,"CANDIDATE_OBSERVATION_CHANGED");
return existing;
}
SelfopSupport.require(run.getBuildStartedAtUtc()==null,"CANDIDATE_CHECKPOINT_MISSING");
// A new attempt has a new run ID; failed attempts are never overwritten or emptied.
daily.delete(new QueryWrapper<SelfopSkuDailySales>().eq("sales_run_id",runId));
profiles.delete(new QueryWrapper<SelfopSkuProfile>().eq("profile_run_id",runId));
OutboundCandidateBuilder.Coverage coverage=coverage(scope);
run.setBasePublishedRunId(scope.getPublishedRunId());
run.setCoveredFromUtc(coverage.from());run.setCoveredUntilUtc(coverage.until());
OutboundPolicy.DayCoverage days=OutboundPolicy.completeDays(coverage.from(),coverage.until(),scope.getBusinessTimezone());
run.setCompleteDayFrom(days.from());run.setCompleteDayUntil(days.until());run.setCoverageStatus(coverage.until()==null?"UNKNOWN":"COMPLETE");
run.setCalculatedFactRevision(scope.getFactRevision());run.setBuildStartedAtUtc(SelfopSupport.utcNow());run.setStatus("BUILDING");
Checkpoint result=new Checkpoint(runId,"DAILY",null,null,null,0,0,0);save(held,result);return result;
}
@Transactional(isolation=Isolation.REPEATABLE_READ,rollbackFor=Exception.class)
public Checkpoint batch(Long scopeId,Long runId,String owner,long fence) {
Held held=held(scopeId,runId,owner,fence,true); Checkpoint cursor=checkpoint(held.task());
SelfopSupport.require(cursor!=null && Objects.equals(runId,cursor.runId()),"CANDIDATE_CHECKPOINT_MISSING");
Checkpoint next;
if("DAILY".equals(cursor.phase())) {
List<SelfopSkuDailySales> rows=aggregates.dailyBatch(held.scope().getSourceAccount(),held.scope().getWarehouseCode(),cursor.customer(),cursor.sku(),cursor.date());
SelfopSupport.require(rows.size()<=500,"CANDIDATE_BATCH_LIMIT");
if(rows.isEmpty()) next=new Checkpoint(runId,"PROFILE",null,null,null,cursor.dailyRows(),0,cursor.committedBatches());
else {
for(var row:rows){row.setId(IdWorker.getId());row.setSalesRunId(runId);row.setSourceAccount(held.scope().getSourceAccount());row.setWarehouseCode(held.scope().getWarehouseCode());row.setCreatedAt(SelfopSupport.utcNow());row.setUpdatedAt(row.getCreatedAt());}
aggregates.insertDaily(rows);var last=rows.get(rows.size()-1);
SelfopSupport.require(!Objects.equals(cursor.customer(),last.getCustomerCode()) || !Objects.equals(cursor.sku(),last.getSkuCode()) || !Objects.equals(cursor.date(),last.getBusinessDate()),"CANDIDATE_CURSOR_STALLED");
next=new Checkpoint(runId,"DAILY",last.getCustomerCode(),last.getSkuCode(),last.getBusinessDate(),cursor.dailyRows()+rows.size(),0,cursor.committedBatches()+1);
}
} else if("PROFILE".equals(cursor.phase())) {
List<SelfopSkuProfile> rows=aggregates.profileBatch(held.scope().getSourceAccount(),held.scope().getWarehouseCode(),cursor.customer(),cursor.sku());
SelfopSupport.require(rows.size()<=500,"CANDIDATE_BATCH_LIMIT");
if(rows.isEmpty()) next=new Checkpoint(runId,"FINISH",null,null,null,cursor.dailyRows(),cursor.profileRows(),cursor.committedBatches());
else {
for(var row:rows){row.setId(IdWorker.getId());row.setProfileRunId(runId);row.setSourceAccount(held.scope().getSourceAccount());row.setWarehouseCode(held.scope().getWarehouseCode());row.setEvidenceType("VALID_OUTBOUND");row.setCreatedAt(SelfopSupport.utcNow());row.setUpdatedAt(row.getCreatedAt());}
aggregates.insertProfiles(rows);var last=rows.get(rows.size()-1);
SelfopSupport.require(!Objects.equals(cursor.customer(),last.getCustomerCode()) || !Objects.equals(cursor.sku(),last.getSkuCode()),"CANDIDATE_CURSOR_STALLED");
next=new Checkpoint(runId,"PROFILE",last.getCustomerCode(),last.getSkuCode(),null,cursor.dailyRows(),cursor.profileRows()+rows.size(),cursor.committedBatches()+1);
}
} else {SelfopSupport.require("FINISH".equals(cursor.phase()),"CANDIDATE_PHASE_INVALID");return cursor;}
save(held,next);return next;
}
@Transactional(isolation=Isolation.REPEATABLE_READ,rollbackFor=Exception.class)
public void finish(Long scopeId,Long runId,String owner,long fence) {
Held held=held(scopeId,runId,owner,fence,true);Checkpoint cursor=checkpoint(held.task());
SelfopSupport.require(cursor!=null && Objects.equals(cursor.runId(),runId) && "FINISH".equals(cursor.phase()),"CANDIDATE_NOT_COMPLETE");
SelfopSupport.require(daily.selectCount(new QueryWrapper<SelfopSkuDailySales>().eq("sales_run_id",runId))==cursor.dailyRows()
&& profiles.selectCount(new QueryWrapper<SelfopSkuProfile>().eq("profile_run_id",runId))==cursor.profileRows(),"CANDIDATE_ROWS_INCOMPLETE");
facts.assertNoBarrier(held.scope());held.run().setStatus("READY");save(held,new Checkpoint(runId,"DONE",null,null,null,cursor.dailyRows(),cursor.profileRows(),cursor.committedBatches()));
}
@Transactional(isolation=Isolation.REPEATABLE_READ,rollbackFor=Exception.class)
public Long reusable(Long scopeId,Long runId,String owner,long fence) {
Held held=held(scopeId,runId,owner,fence,false);SelfopSyncScope scope=held.scope();
SelfopSupport.require(!Set.of("PUBLISHED","REUSED","FAILED","DEAD").contains(held.run().getStatus()),"CANDIDATE_RUN_INVALID");
SelfopSupport.require("APPLIED".equals(held.task().getApplyState()) || "REBUILD".equals(held.task().getTaskType()),"TASK_NOT_APPLIED");
facts.assertNoBarrier(scope);validateFacts(scope);
if(scope.getPublishedRunId()==null)return null;
SelfopSyncRun published=runs.selectById(scope.getPublishedRunId());
if(published==null || !"PUBLISHED".equals(published.getStatus()) || !"BUSINESS".equals(published.getExecutionPurpose())
|| published.getBuildStartedAtUtc()==null || !Objects.equals(scopeId,published.getScopeId()) || !Objects.equals(scope.getScopeHash(),published.getScopeHash())
|| !Objects.equals(scope.getFactRevision(),published.getCalculatedFactRevision()) || !Objects.equals(scope.getFactRevision(),scope.getPublishedFactRevision())
|| !Objects.equals(SelfopSupport.read(held.run().getScopeSnapshotJson(),Map.class),SelfopSupport.read(published.getScopeSnapshotJson(),Map.class)))return null;
OutboundCandidateBuilder.Coverage next=coverage(scope);
if(!Objects.equals(next.from(),published.getCoveredFromUtc()) || !Objects.equals(next.until(),published.getCoveredUntilUtc()))return null;
// Content-identical accepted details still change receipt/run evidence; conservatively rebuild them.
return aggregates.observationsChangedSince(scope.getSourceAccount(),scope.getWarehouseCode(),published.getBuildStartedAtUtc())==0?published.getId():null;
}
private Held held(Long scopeId,Long runId,String owner,long fence,boolean building) {
SelfopSyncScope scope=locks.scope(scopeId);LocalDateTime now=SelfopSupport.utcNow();
SelfopSupport.require(scope!=null && "OUTBOUND".equals(scope.getDataFamily()) && Objects.equals(scope.getLeaseOwner(),owner) && Objects.equals(scope.getFenceToken(),fence)
&& scope.getLeaseUntilUtc()!=null && scope.getLeaseUntilUtc().isAfter(now),"LEASE_FENCE_REJECTED");
SelfopSyncRun run=locks.run(runId);SelfopCollectionPolicy.requireBusiness(run);
SelfopSyncTask task=locks.task(run.getTaskId());SelfopCollectionPolicy.requireBusiness(task);
SelfopSupport.require(Objects.equals(scopeId,run.getScopeId()) && Objects.equals(run.getFenceToken(),fence) && Objects.equals(scope.getScopeHash(),run.getScopeHash())
&& "WAREHOUSE_SYNC".equals(run.getRunRole()) && Objects.equals(scopeId,task.getScopeId()) && Objects.equals(scope.getSourceAccount(),task.getSourceAccount())
&& Objects.equals(scope.getWarehouseCode(),task.getWarehouseCode()) && Objects.equals(scope.getScopeHash(),task.getScopeHash()),"CANDIDATE_RUN_INVALID");
SelfopSupport.require(run.getStatus()!=null,"CANDIDATE_RUN_INVALID");
if(building) {
SelfopSupport.require("BUILDING".equals(run.getStatus()) && Objects.equals(scope.getFactRevision(),run.getCalculatedFactRevision()),"CANDIDATE_FACT_REVISION_CHANGED");
SelfopSupport.require(run.getBuildStartedAtUtc()!=null && aggregates.observationsChangedSince(scope.getSourceAccount(),scope.getWarehouseCode(),run.getBuildStartedAtUtc())==0,
"CANDIDATE_OBSERVATION_CHANGED");
}
scope.setLeaseUntilUtc(now.plusSeconds(Math.max(10,config.getLeaseSeconds())));scope.setUpdatedAt(now);scopes.updateById(scope);
return new Held(scope,run,task);
}
private void validateFacts(SelfopSyncScope scope) {
Map<?,?> settings=SelfopSupport.read(scope.getScopeJson(),Map.class);String unit=settings.get("quantityUnit") instanceof String s?s:null;
SelfopSupport.require(scope.getSourceTimezone()!=null && !scope.getSourceTimezone().isBlank(),"SOURCE_TIMEZONE_UNCONFIRMED");
SelfopSupport.require(unit!=null && !unit.isBlank(),"QUANTITY_UNIT_UNCONFIRMED");
SelfopSupport.require(aggregates.invalidFacts(scope.getSourceAccount(),scope.getWarehouseCode(),scope.getSourceTimezone(),scope.getBusinessTimezone(),unit)==0,"FACT_TIME_UNIT_OR_EVIDENCE_INVALID");
}
private OutboundCandidateBuilder.Coverage coverage(SelfopSyncScope scope) {
if(scope.getInitialFromUtc()==null)return new OutboundCandidateBuilder.Coverage(null,null);
SelfopSyncRun result=aggregates.contiguousCoverage(scope.getId(),scope.getScopeHash(),scope.getInitialFromUtc(),scope.getDiscoveryFromUtc(),scope.getDiscoveryUntilUtc());
return result==null?new OutboundCandidateBuilder.Coverage(null,null):new OutboundCandidateBuilder.Coverage(result.getCoveredFromUtc(),result.getCoveredUntilUtc());
}
private Checkpoint checkpoint(SelfopSyncTask task) {
if(task.getProgressJson()==null)return null;Map<?,?> progress=SelfopSupport.read(task.getProgressJson(),Map.class);Object value=progress.get("candidateBuild");
return value==null?null:SelfopSupport.read(SelfopSupport.json(value),Checkpoint.class);
}
private void save(Held held,Checkpoint checkpoint) {
LocalDateTime now=SelfopSupport.utcNow();
SelfopSupport.require(held.scope().getLeaseUntilUtc().isAfter(now),"LEASE_FENCE_REJECTED");
held.scope().setLeaseUntilUtc(now.plusSeconds(Math.max(10,config.getLeaseSeconds())));held.scope().setUpdatedAt(now);scopes.updateById(held.scope());
Map<String,Object> progress=new LinkedHashMap<>();
if(held.task().getProgressJson()!=null)SelfopSupport.read(held.task().getProgressJson(),Map.class).forEach((k,v)->progress.put(String.valueOf(k),v));
if(!(progress.get("attemptNo") instanceof Number attempt) || !Objects.equals(attempt.intValue(),held.run().getAttemptNo()))progress.clear();
progress.put("attemptNo",held.run().getAttemptNo());progress.put("candidateBuild",checkpoint);progress.remove("processedCount");
held.task().setProgressJson(SelfopSupport.json(progress));
SelfopProgressProjection.applyCheckpoint(held.task(),held.run(),"BUILD","BATCH",checkpoint.committedBatches(),null,0);
tasks.updateById(held.task());runs.updateById(held.run());
}
}
@@ -0,0 +1,245 @@
package com.baiyue.lastleg.selfoperated.analytics.outbound;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.*;
import java.util.*;
/** Short bounded transactions; all source requests must finish before entering this service. */
@Service
@RequiredArgsConstructor
public class OutboundFactService {
private final OutboundLockMapper locks;
private final SelfopSyncTaskMapper tasks;
private final SelfopSyncRunMapper runs;
private final SelfopSyncScopeMapper scopes;
private final SelfopSyncStageMapper stages;
private final SelfopOutboundOrderMapper orders;
private final SelfopOutboundItemMapper items;
public record Lease(String owner, long fence) {}
@Transactional(rollbackFor = Exception.class)
public void merge(Long taskId, Long runId, List<Long> stageIds, Map<String, Lease> leases) {
SelfopSupport.require(stageIds != null && stageIds.size() <= 100, "MERGE_BATCH_LIMIT");
SelfopSyncTask preview = tasks.selectById(taskId);
SelfopSupport.require(preview != null, "TASK_MISSING");
SelfopCollectionPolicy.requireBusiness(preview);
// Discover the entire old/new lock set before touching facts. Recheck after order locks.
SortedSet<String> warehouses = affected(preview);
warehouses.add(preview.getWarehouseCode());
// Coordinator acquired this full old/new set from the entire stable routing manifest,
// not only this bounded segment. Register it before the first fact mutation.
warehouses.addAll(leases.keySet());
for (Long id : stageIds) {
SelfopSyncStage s = stages.selectById(id);
SelfopSupport.require(s != null, "STAGE_MISSING");
SelfopPayloads.Order p = SelfopSupport.read(s.getPayloadJson(),SelfopPayloads.Order.class);
warehouses.add(p.warehouseCode());
SelfopOutboundOrder old = findOrder(p);
if (old != null) warehouses.add(old.getWarehouseCode());
}
Map<String,SelfopSyncScope> held = lockScopes(preview.getSourceAccount(),warehouses,leases);
SelfopSyncTask task = locks.task(taskId);
SelfopSyncRun run = locks.run(runId);
validateTaskRun(task,run,held);
SelfopRoutingManifest manifest=SelfopSupport.read(task.getRoutingManifestJson(),SelfopRoutingManifest.class);
SelfopSupport.require(!"APPLIED".equals(task.getApplyState()), "TASK_ALREADY_APPLIED");
task.setAffectedWarehouseCodesJson(SelfopSupport.json(warehouses));
task.setApplyState("APPLYING");
if (task.getApplyStartedAtUtc() == null) task.setApplyStartedAtUtc(SelfopSupport.utcNow());
task.setUpdatedAt(SelfopSupport.utcNow()); tasks.updateById(task);
for (Long id : stageIds) {
SelfopSyncStage stage=locks.stage(id); applyOne(task,run,stage,held,manifest);
manifest.getObjects().get(stage.getObjectKeyHash()).setAppliedStageId(stage.getId());
}
task.setRoutingManifestJson(SelfopSupport.json(manifest));
mergeProgress(task,run,manifest);tasks.updateById(task);
run.setAppliedFactRevision(held.get(task.getWarehouseCode()).getFactRevision());
run.setUpdatedAt(SelfopSupport.utcNow()); runs.updateById(run);
}
/** The caller supplies the stable complete routing set, including refreshed effective references. */
@Transactional(rollbackFor = Exception.class)
public void finish(Long taskId, Long runId, List<Long> allEffectiveStageIds, Map<String,Lease> leases) {
SelfopSyncTask preview = tasks.selectById(taskId);
SelfopSupport.require(preview != null,"TASK_MISSING");
SelfopCollectionPolicy.requireBusiness(preview);
SortedSet<String> warehouseSet = affected(preview); warehouseSet.add(preview.getWarehouseCode());
Map<String,SelfopSyncScope> held = lockScopes(preview.getSourceAccount(),warehouseSet,leases);
SelfopSyncTask task = locks.task(taskId); SelfopSyncRun run = locks.run(runId);
validateTaskRun(task,run,held);
SelfopSupport.require(allEffectiveStageIds != null && new HashSet<>(allEffectiveStageIds).size() == allEffectiveStageIds.size(),"ROUTING_SET_INVALID");
SelfopRoutingManifest manifest = SelfopSupport.read(task.getRoutingManifestJson(),SelfopRoutingManifest.class);
SelfopSupport.require(manifest.isDirectoryComplete(),"CATALOG_NOT_COMPLETE");
Set<Long> required = new HashSet<>();
for(SelfopRoutingManifest.Entry entry : manifest.getObjects().values()) {
SelfopSupport.require(entry.getEffectiveStageId() != null,"DETAIL_RECHECK_REQUIRED");
required.add(entry.getEffectiveStageId());
}
SelfopSupport.require(required.equals(new HashSet<>(allEffectiveStageIds)),"ROUTING_SET_INCOMPLETE");
for (Long id : allEffectiveStageIds) {
SelfopSyncStage stage = locks.stage(id);
SelfopPayloads.Order p = validateStage(task, stage,manifest);
SelfopOutboundOrder order = locks.order(p.sourceAccount(),p.customerCode(),p.deliveryNo());
SelfopSupport.require(order != null && Objects.equals(order.getLastObservationSequence(),stage.getObservationSequence())
&& Objects.equals(order.getContentHash(),factHash(p,held.get(p.warehouseCode()))),"OBJECT_NOT_APPLIED");
manifest.getObjects().get(stage.getObjectKeyHash()).setAppliedStageId(stage.getId());
}
task.setApplyState("APPLIED"); task.setApplyCompletedAtUtc(SelfopSupport.utcNow());
task.setRoutingManifestJson(SelfopSupport.json(manifest));mergeProgress(task,run,manifest);
task.setUpdatedAt(SelfopSupport.utcNow()); tasks.updateById(task);
run.setStatus("FACTS_APPLIED");
run.setAppliedFactRevision(held.get(task.getWarehouseCode()).getFactRevision());
run.setUpdatedAt(SelfopSupport.utcNow()); runs.updateById(run);
}
public void assertNoBarrier(SelfopSyncScope scope) {
SelfopSupport.require(locks.barriers(scope.getSourceAccount(),scope.getWarehouseCode()).isEmpty(),"HALF_APPLIED_FACTS");
}
private void applyOne(SelfopSyncTask task, SelfopSyncRun run, SelfopSyncStage stage, Map<String,SelfopSyncScope> held,SelfopRoutingManifest manifest) {
SelfopPayloads.Order p = validateStage(task,stage,manifest);
SelfopSyncScope target = held.get(p.warehouseCode());
SelfopSupport.require(target != null,"LOCK_SET_CHANGED");
LocalDateTime utc = OutboundPolicy.outboundUtc(p,target);
String unit = unit(target);
for(SelfopPayloads.Item item : p.items()) SelfopSupport.require(Objects.equals(unit,item.quantityUnit()),"QUANTITY_UNIT_UNCONFIRMED");
SelfopOutboundOrder order = locks.order(p.sourceAccount(),p.customerCode(),p.deliveryNo());
SelfopSupport.require(order == null || held.containsKey(order.getWarehouseCode()),"LOCK_SET_CHANGED");
SelfopSupport.require(order == null || order.getLastObservationSequence() <= stage.getObservationSequence(),"DETAIL_RECHECK_REQUIRED");
String hash = factHash(p,target);
if(order != null && Objects.equals(order.getContentHash(),hash)) {
order.setLastRunId(run.getId()); order.setLastObservationSequence(stage.getObservationSequence());
order.setUpdatedAt(SelfopSupport.utcNow()); orders.updateById(order); return;
}
SelfopSupport.require(order == null || order.getLastObservationSequence() < stage.getObservationSequence(),"OBSERVATION_CONTENT_CONFLICT");
String oldWarehouse = order == null ? null : order.getWarehouseCode();
if(order != null && stage.getBeforePayloadJson() == null) {
Map<String,Object> before = new LinkedHashMap<>(); before.put("order",order);
before.put("items",items.selectList(new QueryWrapper<SelfopOutboundItem>().eq("order_id",order.getId()).orderByAsc("sku_code")));
stages.update(null,new UpdateWrapper<SelfopSyncStage>().eq("id",stage.getId())
.isNull("before_payload_json").set("before_payload_json",SelfopSupport.json(before)).set("updated_at",SelfopSupport.utcNow()));
}
boolean fresh = order == null;
if(fresh) { order = new SelfopOutboundOrder(); order.setCreatedAt(SelfopSupport.utcNow()); order.setRevisionNo(1L); }
else order.setRevisionNo(order.getRevisionNo()+1);
order.setSourceAccount(p.sourceAccount()); order.setCustomerCode(p.customerCode()); order.setWarehouseCode(p.warehouseCode());
order.setDeliveryNo(p.deliveryNo()); order.setSourceNo(p.sourceNo()); order.setSourceStatus(p.sourceStatus());
order.setSourceCreatedTime(p.sourceCreatedTime()); order.setSourceOutboundTime(p.sourceOutboundTime());
order.setSourceTimezone(target.getSourceTimezone()); order.setBusinessTimezone(target.getBusinessTimezone());
order.setOutboundAtUtc(utc); order.setBusinessDate(utc == null ? null : utc.atZone(ZoneOffset.UTC).withZoneSameInstant(ZoneId.of(target.getBusinessTimezone())).toLocalDate());
order.setSalesEligible(Integer.valueOf(100).equals(p.sourceStatus()) ? 1 : 0);
order.setContentHash(hash); order.setLastRunId(run.getId()); order.setLastObservationSequence(stage.getObservationSequence());
order.setUpdatedAt(SelfopSupport.utcNow());
if(fresh) orders.insert(order);
else {
orders.updateById(order);
// Explicit nulls are meaningful for cancellation and corrections; MyBatis default ignores null entity fields.
orders.update(null,new UpdateWrapper<SelfopOutboundOrder>().eq("id",order.getId())
.set("outbound_at_utc",order.getOutboundAtUtc()).set("business_date",order.getBusinessDate())
.set("source_no",order.getSourceNo()).set("source_created_time",order.getSourceCreatedTime())
.set("source_outbound_time",order.getSourceOutboundTime()));
items.delete(new QueryWrapper<SelfopOutboundItem>().eq("order_id",order.getId()));
}
for (SelfopPayloads.Item value : p.items()) {
SelfopOutboundItem item = new SelfopOutboundItem(); item.setOrderId(order.getId()); item.setSkuCode(value.skuCode());
item.setProductName(value.productName()); item.setActualQuantity(value.actualQuantity()); item.setQuantityUnit(value.quantityUnit());
item.setCreatedAt(SelfopSupport.utcNow()); item.setUpdatedAt(item.getCreatedAt()); items.insert(item);
}
Set<String> changed = new HashSet<>(); changed.add(p.warehouseCode()); if(oldWarehouse != null) changed.add(oldWarehouse);
for(String warehouse : changed) {
SelfopSyncScope scope = held.get(warehouse); scope.setFactRevision(scope.getFactRevision()+1);
scope.setRebuildRequired(1); scope.setInvalidatedAtUtc(SelfopSupport.utcNow()); scope.setUpdatedAt(SelfopSupport.utcNow()); scopes.updateById(scope);
if(oldWarehouse != null && !oldWarehouse.equals(p.warehouseCode())) rebuild(task,scope,order);
}
}
private void rebuild(SelfopSyncTask upstream,SelfopSyncScope scope,SelfopOutboundOrder order) {
String key = "rebuild:"+upstream.getId()+":"+order.getId()+":"+order.getRevisionNo()+":"+scope.getWarehouseCode();
if(tasks.selectCount(new QueryWrapper<SelfopSyncTask>().eq("source_account",scope.getSourceAccount()).eq("request_key",key)) > 0) return;
SelfopSyncTask task = new SelfopSyncTask(); task.setSourceAccount(scope.getSourceAccount());
task.setExecutionPurpose("BUSINESS"); task.setTaskNo("SO-"+com.baomidou.mybatisplus.core.toolkit.IdWorker.getId());
task.setTaskRole("WAREHOUSE_SYNC"); task.setScopeId(scope.getId()); task.setWarehouseCode(scope.getWarehouseCode());
task.setRequestKey(key); task.setRequestHash(SelfopSupport.hash(List.of(key,scope.getScopeHash())));
task.setTaskType("REBUILD"); task.setQueryScopeJson(scope.getScopeJson()); task.setScopeHash(scope.getScopeHash());
task.setStatus("READY"); task.setApplyState("NOT_APPLICABLE"); task.setAttemptCount(0);
task.setCreatedAt(SelfopSupport.utcNow()); task.setUpdatedAt(task.getCreatedAt()); tasks.insert(task);
}
private SelfopPayloads.Order validateStage(SelfopSyncTask task,SelfopSyncStage stage,SelfopRoutingManifest manifest) {
SelfopSupport.require(stage != null && "VALID".equals(stage.getValidationStatus())
&& "OUTBOUND_ORDER".equals(stage.getObjectType()) && stage.getObservationSequence() != null,"DETAIL_NOT_VALID");
SelfopSyncRun sourceRun=runs.selectById(stage.getRunId());
SelfopCollectionPolicy.requireBusiness(sourceRun);
SelfopSyncTask sourceTask=tasks.selectById(sourceRun.getTaskId());
SelfopCollectionPolicy.requireBusiness(sourceTask);
SelfopSupport.require(Objects.equals(task.getSourceAccount(),sourceRun.getSourceAccount())
&& Objects.equals(task.getSourceAccount(),sourceTask.getSourceAccount()),"STAGE_IDENTITY_CONFLICT");
SelfopPayloads.Order raw = SelfopSupport.read(stage.getPayloadJson(),SelfopPayloads.Order.class);
SelfopSupport.require(Objects.equals(stage.getContentHash(),SelfopSupport.hash(raw)),"STAGE_CONTENT_CONFLICT");
SelfopPayloads.Order p = OutboundPolicy.normalize(raw);
SelfopRoutingManifest.Entry reference = manifest.getObjects().get(stage.getObjectKeyHash());
SelfopSupport.require(reference != null && Objects.equals(reference.getEffectiveStageId(),stage.getId()),"STAGE_NOT_ROUTED");
if(reference.getRefreshTaskId() != null) {
SelfopSyncTask refresh = tasks.selectById(reference.getRefreshTaskId());
SelfopSupport.require(refresh != null && "SUCCEEDED".equals(refresh.getStatus())
&& Objects.equals(stage.getStageBatchId(),reference.getRefreshTaskId()),"DETAIL_RECHECK_REQUIRED");
} else SelfopSupport.require(Objects.equals(stage.getStageBatchId(),task.getUpstreamTaskId()),"STAGE_BATCH_CONFLICT");
SelfopSupport.require(Objects.equals(task.getSourceAccount(),p.sourceAccount()) && Objects.equals(stage.getWarehouseCode(),p.warehouseCode()),"STAGE_IDENTITY_CONFLICT");
SelfopSupport.require(Objects.equals(stage.getObjectKeyHash(),SelfopSupport.hash(List.of(p.sourceAccount(),p.customerCode(),p.deliveryNo()))),"STAGE_KEY_CONFLICT");
SelfopSupport.require(stage.getObservationSequence() >= locks.newestObservation(p.sourceAccount(),stage.getObjectKeyHash()),"DETAIL_RECHECK_REQUIRED");
return p;
}
private void mergeProgress(SelfopSyncTask task,SelfopSyncRun run,SelfopRoutingManifest manifest) {
Set<Long> required=new HashSet<>(),applied=new HashSet<>();
boolean totalKnown=manifest.isDirectoryComplete();
for(SelfopRoutingManifest.Entry entry:manifest.getObjects().values()) {
if(entry.getEffectiveStageId()==null)totalKnown=false;
if(entry.getEffectiveStageId()!=null)required.add(entry.getEffectiveStageId());
if(entry.getEffectiveStageId()!=null && Objects.equals(entry.getEffectiveStageId(),entry.getAppliedStageId()))applied.add(entry.getAppliedStageId());
}
SelfopProgressProjection.applyCheckpoint(task,run,"MERGE","ORDER",applied.size(),totalKnown?(long)required.size():null,0);
}
private String factHash(SelfopPayloads.Order p,SelfopSyncScope scope) {
SelfopSupport.require(scope != null,"LOCK_SET_CHANGED");
return SelfopSupport.hash(List.of(p,scope.getBusinessTimezone(),scope.getSourceTimezone(),unit(scope)));
}
private String unit(SelfopSyncScope scope) {
Map<?,?> json = SelfopSupport.read(scope.getScopeJson(),Map.class); Object value = json.get("quantityUnit");
SelfopSupport.require(value instanceof String && !((String)value).isBlank(),"QUANTITY_UNIT_UNCONFIRMED");
return (String)value;
}
private SelfopOutboundOrder findOrder(SelfopPayloads.Order p) {
return orders.selectOne(new QueryWrapper<SelfopOutboundOrder>().eq("source_account",p.sourceAccount())
.eq("customer_code",p.customerCode()).eq("delivery_no",p.deliveryNo()));
}
private SortedSet<String> affected(SelfopSyncTask task) {
SortedSet<String> values = new TreeSet<>();
if(task.getAffectedWarehouseCodesJson() != null) values.addAll(Arrays.asList(SelfopSupport.read(task.getAffectedWarehouseCodesJson(),String[].class)));
return values;
}
private Map<String,SelfopSyncScope> lockScopes(String account,SortedSet<String> warehouses,Map<String,Lease> leases) {
Map<String,SelfopSyncScope> held = new LinkedHashMap<>();
for(String warehouse : warehouses) {
SelfopSyncScope scope = locks.warehouse(account,warehouse); Lease lease = leases.get(warehouse);
SelfopSupport.require(scope != null && lease != null && Objects.equals(scope.getLeaseOwner(),lease.owner())
&& Objects.equals(scope.getFenceToken(),lease.fence()) && scope.getLeaseUntilUtc() != null
&& scope.getLeaseUntilUtc().isAfter(SelfopSupport.utcNow()),"LEASE_FENCE_REJECTED");
held.put(warehouse,scope);
}
return held;
}
private void validateTaskRun(SelfopSyncTask task,SelfopSyncRun run,Map<String,SelfopSyncScope> held) {
SelfopCollectionPolicy.requireBusiness(task); SelfopCollectionPolicy.requireBusiness(run);
SelfopSyncScope own = held.get(task.getWarehouseCode());
SelfopSupport.require(run != null && Objects.equals(run.getTaskId(),task.getId()) && Objects.equals(run.getScopeId(),task.getScopeId())
&& Objects.equals(run.getScopeHash(),own.getScopeHash()) && Objects.equals(task.getScopeHash(),own.getScopeHash())
&& Objects.equals(run.getFenceToken(),own.getFenceToken()) && !"PUBLISHED".equals(run.getStatus()),"RUN_SCOPE_FENCE_CONFLICT");
}
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.selfoperated.analytics.outbound;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface OutboundLockMapper {
@Select("SELECT * FROM ll_selfop_sync_scope WHERE id=#{id} FOR UPDATE")
SelfopSyncScope scope(@Param("id") Long id);
@Select("SELECT * FROM ll_selfop_sync_scope WHERE source_account=#{account} AND warehouse_code=#{warehouse} AND data_family='OUTBOUND' FOR UPDATE")
SelfopSyncScope warehouse(@Param("account") String account,@Param("warehouse") String warehouse);
@Select("SELECT * FROM ll_selfop_sync_task WHERE id=#{id} FOR UPDATE")
SelfopSyncTask task(@Param("id") Long id);
@Select("SELECT * FROM ll_selfop_sync_run WHERE id=#{id} FOR UPDATE")
SelfopSyncRun run(@Param("id") Long id);
@Select("SELECT * FROM ll_selfop_sync_stage WHERE id=#{id} FOR UPDATE")
SelfopSyncStage stage(@Param("id") Long id);
@Select("SELECT * FROM ll_selfop_outbound_order WHERE source_account=#{account} AND customer_code=#{customer} AND delivery_no=#{delivery} FOR UPDATE")
SelfopOutboundOrder order(@Param("account") String account,@Param("customer") String customer,@Param("delivery") String delivery);
@Select("SELECT COALESCE(MAX(observation_sequence),0) FROM ll_selfop_sync_stage s JOIN ll_selfop_sync_run r ON r.id=s.run_id JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE r.source_account=#{account} AND r.execution_purpose='BUSINESS' AND t.execution_purpose='BUSINESS' AND s.object_type='OUTBOUND_ORDER' AND s.object_key_hash=#{key} AND s.validation_status='VALID'")
Long newestObservation(@Param("account") String account,@Param("key") String key);
@Select("SELECT * FROM ll_selfop_sync_task WHERE source_account=#{account} AND apply_state IN ('APPLYING','BLOCKED') AND JSON_CONTAINS(affected_warehouse_codes_json,JSON_QUOTE(#{warehouse}))")
List<SelfopSyncTask> barriers(@Param("account") String account,@Param("warehouse") String warehouse);
}
@@ -0,0 +1,87 @@
package com.baiyue.lastleg.selfoperated.analytics.outbound;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncScope;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopPayloads;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport;
import java.math.BigDecimal;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.*;
/** Source strings are interpreted only after explicit source timezone and unit confirmation. */
public final class OutboundPolicy {
private OutboundPolicy() {}
private static final DateTimeFormatter SOURCE = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss")
.withResolverStyle(java.time.format.ResolverStyle.STRICT);
public static SelfopPayloads.Order normalize(SelfopPayloads.Order p) {
text(p.sourceAccount(), 64); text(p.customerCode(), 64); text(p.warehouseCode(), 64);
text(p.deliveryNo(), 128);
optional(p.sourceNo(),128); optional(p.sourceCreatedTime(),64); optional(p.sourceOutboundTime(),64);
SelfopSupport.require(p.sourceStatus() != null, "SOURCE_STATUS_MISSING");
SelfopSupport.require(p.items() != null, "DETAIL_ITEMS_MISSING");
SelfopSupport.require(!Integer.valueOf(100).equals(p.sourceStatus()) || !p.items().isEmpty(), "OUTBOUND_ITEMS_EMPTY");
String quantityUnit = p.items().isEmpty() ? null : p.items().get(0).quantityUnit();
TreeMap<String, SelfopPayloads.Item> result = new TreeMap<>();
for (SelfopPayloads.Item item : p.items()) {
text(item.skuCode(),128); optional(item.productName(),255);
SelfopSupport.require(Objects.equals(quantityUnit, item.quantityUnit()) && item.quantityUnit() != null && !item.quantityUnit().isBlank(), "QUANTITY_UNIT_CONFLICT");
BigDecimal value = item.actualQuantity();
SelfopSupport.require(value != null && value.signum() >= 0 && value.scale() <= 6
&& value.precision() - value.scale() <= 14, "INVALID_ACTUAL_QUANTITY");
SelfopPayloads.Item old = result.get(item.skuCode());
BigDecimal sum = old == null ? value : old.actualQuantity().add(value);
SelfopSupport.require(sum.precision() - sum.scale() <= 14, "QUANTITY_OVERFLOW");
result.put(item.skuCode(), new SelfopPayloads.Item(item.skuCode(),
old != null && old.productName() != null ? old.productName() : item.productName(),
sum.stripTrailingZeros(), quantityUnit));
}
return new SelfopPayloads.Order(p.sourceAccount(),p.customerCode(),p.warehouseCode(),p.deliveryNo(),
p.sourceNo(),p.sourceStatus(),p.sourceCreatedTime(),p.sourceOutboundTime(), List.copyOf(result.values()));
}
public static LocalDateTime outboundUtc(SelfopPayloads.Order p, SelfopSyncScope scope) {
SelfopSupport.require(scope.getSourceTimezone() != null && !scope.getSourceTimezone().isBlank(),
"SOURCE_TIMEZONE_UNCONFIRMED");
if (!Integer.valueOf(100).equals(p.sourceStatus())) return null;
text(p.sourceOutboundTime(),64);
LocalDateTime local = LocalDateTime.parse(p.sourceOutboundTime(),SOURCE);
ZoneId zone = ZoneId.of(scope.getSourceTimezone());
List<ZoneOffset> offsets = zone.getRules().getValidOffsets(local);
SelfopSupport.require(offsets.size() == 1, "SOURCE_TIME_DST_AMBIGUOUS_OR_GAP");
return LocalDateTime.ofInstant(local.toInstant(offsets.get(0)),ZoneOffset.UTC);
}
/** Source API includes both endpoint seconds; new facts belong to exactly one internal slice.
* Unknown source timezone retains evidence only; caller must keep formal readiness closed.
* Known facts and explicit rechecks retain revisions even when corrected outside the old slice.
*/
public static boolean belongsToSlice(SelfopPayloads.Order p, SelfopSyncScope scope,
LocalDateTime from, LocalDateTime until,
boolean knownOrder, String taskType) {
if(knownOrder || "RECHECK".equals(taskType) || !Integer.valueOf(100).equals(p.sourceStatus())) return true;
if(scope.getSourceTimezone() == null || scope.getSourceTimezone().isBlank()) return true;
if(from == null || until == null) return true;
SelfopSupport.require(from.isBefore(until),"OUTBOUND_SLICE_INVALID");
LocalDateTime observed = outboundUtc(p,scope);
return !observed.isBefore(from) && observed.isBefore(until);
}
public static DayCoverage completeDays(LocalDateTime from, LocalDateTime until, String timezone) {
if (from == null || until == null || !from.isBefore(until)) return new DayCoverage(null,null);
ZoneId zone = ZoneId.of(timezone);
ZonedDateTime start = from.atZone(ZoneOffset.UTC).withZoneSameInstant(zone);
ZonedDateTime end = until.atZone(ZoneOffset.UTC).withZoneSameInstant(zone);
LocalDate first = start.toLocalDate();
if (!start.toInstant().equals(first.atStartOfDay(zone).toInstant())) first = first.plusDays(1);
LocalDate last = end.toLocalDate();
return first.isAfter(last) ? new DayCoverage(null,null) : new DayCoverage(first,last);
}
public record DayCoverage(LocalDate from, LocalDate until) {}
private static void optional(String value,int max) {
SelfopSupport.require(value == null || value.length() <= max,"IDENTITY_TOO_LONG");
}
private static void text(String value,int max) {
SelfopSupport.require(value != null && !value.isBlank() && value.length() <= max,"IDENTITY_OR_UNIT_MISSING");
}
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import com.baiyue.common.core.exception.BizException;
import java.util.Set;
/** Endpoint-specific inventory presentation, separate from immutable version/date identity. */
public record AnalyticsInventoryOptions(String keyword,String attention,String sortBy,Integer coverageDaysThreshold) {
public String attentionValue(){return attention==null?"ALL":attention;}
public String sortValue(){return sortBy==null?"AVAILABLE_DESC":sortBy;}
public String keywordPattern(){return keyword==null?null:keyword.replace("!","!!").replace("%","!%").replace("_","!_")+"%";}
public void validate(String endpoint,AnalyticsQuery q){
if(coverageDaysThreshold!=null&&coverageDaysThreshold<=0)throw new BizException("可售天数阈值必须为正整数");
if(!Set.of("ALL","NO_OUTBOUND_30D","COVERAGE_GT_THRESHOLD","FROZEN_STOCK","DEFECTIVE_STOCK","UNASSESSABLE").contains(attentionValue()))throw new BizException("库存关注条件无效");
if(!Set.of("AVAILABLE_DESC","COVERAGE_DAYS_DESC","PHYSICAL_DESC","OUTBOUND_30D_ASC").contains(sortValue()))throw new BizException("库存排序无效");
if("COVERAGE_GT_THRESHOLD".equals(attentionValue())&&coverageDaysThreshold==null)throw new BizException("超过可售天数筛选必须指定阈值");
if("OWNERS".equals(endpoint)){
if(keyword!=null&&(keyword.isBlank()||keyword.length()>64))throw new BizException("货主编码前缀不能为空或超长");
if(q.customerCode()!=null||q.sku()!=null||attention!=null||sortBy!=null||coverageDaysThreshold!=null)throw new BizException("货主选项仅接受编码前缀与分页");
}else{
if(keyword!=null)throw new BizException("此接口不接受货主前缀");
if("OVERVIEW".equals(endpoint)){
AnalyticsQuery.text(q.customerCode(),64,"货主");
if(q.sku()!=null||q.current()!=null||q.size()!=null||attention!=null||sortBy!=null)throw new BizException("货主概览不接受SKU、关注、排序或分页条件");
}
}
}
}
@@ -0,0 +1,75 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import java.util.Map;
/** Immutable, one-scope SQL. Qualities and days aggregate independently before joining SKU keys. */
public final class AnalyticsInventorySql {
private AnalyticsInventorySql(){}
private static String scope(AnalyticsSqlRequest p){
return " source_account=#{p.account} AND warehouse_code=#{p.warehouse}"
+(p.customer()==null?"":" AND customer_code=#{p.customer}")+(p.sku()==null?"":" AND sku_code=#{p.sku}");
}
private static String keys(AnalyticsSqlRequest p){return "SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.salesRun} AND"+scope(p)
+" UNION SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.inventoryRun} AND"+scope(p);}
private static String valid(String field){return field+" IS NOT NULL AND "+field+">=0 AND (quality_status='VALID' OR quality_reason IN ('FIELD_MISSING','NEGATIVE_QUANTITY'))";}
private static String quantity(String field){return "CASE WHEN MIN(CASE WHEN "+valid(field)+" THEN 1 ELSE 0 END)=1 THEN SUM("+field+") END";}
private static String cte(AnalyticsSqlRequest p){
return "WITH candidates AS ("+keys(p)+"), profiles AS (SELECT c.customer_code,c.sku_code,COALESCE(i.product_name,s.product_name) product_name,"
+"CASE WHEN i.quantity_unit='PCS' THEN 1 ELSE 0 END stock_unit_valid,"
+"CASE WHEN s.sku_code IS NULL OR s.quantity_unit='PCS' THEN 1 ELSE 0 END sales_unit_valid,"
+"CASE WHEN (s.sku_code IS NULL OR s.quantity_unit='PCS') AND (i.sku_code IS NULL OR i.quantity_unit='PCS') THEN 1 ELSE 0 END unit_valid,"
+"CASE WHEN s.evidence_type='VALID_OUTBOUND' AND s.evidence_run_id IS NOT NULL THEN s.earliest_evidence_at_utc END sales_evidence,"
+"CASE WHEN i.evidence_type='INVENTORY_OBSERVATION' AND i.evidence_run_id IS NOT NULL THEN i.earliest_evidence_at_utc END stock_evidence "
+"FROM candidates c LEFT JOIN ll_selfop_sku_profile s ON s.profile_run_id=#{p.salesRun} AND s.source_account=#{p.account} AND s.warehouse_code=#{p.warehouse} AND s.customer_code=c.customer_code AND s.sku_code=c.sku_code "
+"LEFT JOIN ll_selfop_sku_profile i ON i.profile_run_id=#{p.inventoryRun} AND i.source_account=#{p.account} AND i.warehouse_code=#{p.warehouse} AND i.customer_code=c.customer_code AND i.sku_code=c.sku_code),"
+" stock AS (SELECT customer_code,sku_code,"+quantity("physical_quantity")+" physical_quantity,"+quantity("frozen_quantity")+" frozen_quantity,"
+quantity("transit_quantity")+" transit_quantity,MAX(CASE WHEN product_quality=0 AND "+valid("available_quantity")+" THEN available_quantity END) available_quantity,"
+"CASE WHEN MIN(CASE WHEN product_quality NOT IN (0,1) THEN 0 WHEN product_quality=1 THEN CASE WHEN "+valid("physical_quantity")+" THEN 1 ELSE 0 END ELSE 1 END)=1 "
+"THEN SUM(CASE WHEN product_quality=1 THEN physical_quantity ELSE 0 END) END defective_physical_quantity,MAX(observed_at_utc) observed_at_utc "
+"FROM ll_selfop_inventory_snapshot WHERE snapshot_run_id=#{p.inventoryRun} AND"+scope(p)+" GROUP BY customer_code,sku_code),"
+" sales AS (SELECT customer_code,sku_code,SUM(outbound_quantity) observed_quantity,SUM(outbound_order_count) order_count,"
+"MIN(CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END) unit_valid FROM ll_selfop_sku_daily_sales "
+"WHERE sales_run_id=#{p.salesRun} AND"+scope(p)+" AND business_date>=#{p.from} AND business_date<#{p.until} GROUP BY customer_code,sku_code),"
+" last_sales AS (SELECT customer_code,sku_code,MAX(business_date) last_outbound FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{p.salesRun} AND"+scope(p)
+" AND business_date<=#{p.anchor} AND outbound_quantity>0 AND quantity_unit='PCS' GROUP BY customer_code,sku_code),"
+" values_by_sku AS (SELECT c.customer_code,c.sku_code,c.product_name,c.unit_valid,"
+"CASE WHEN c.sales_evidence IS NULL THEN c.stock_evidence WHEN c.stock_evidence IS NULL THEN c.sales_evidence ELSE LEAST(c.sales_evidence,c.stock_evidence) END evidence_at_utc,"
+stockValue("physical_quantity")+","+stockValue("available_quantity")+","+stockValue("frozen_quantity")+","+stockValue("transit_quantity")+","+stockValue("defective_physical_quantity")+",st.observed_at_utc,"
+"CASE WHEN c.sales_unit_valid=1 AND COALESCE(s.unit_valid,1)=1 AND #{p.complete30}=1 THEN COALESCE(s.observed_quantity,0) END owner_outbound,"
+"CASE WHEN c.sales_unit_valid=1 AND COALESCE(s.unit_valid,1)=1 AND #{p.salesKnown}=1 THEN s.observed_quantity END observed_outbound,s.order_count,l.last_outbound "
+"FROM profiles c LEFT JOIN stock st ON st.customer_code=c.customer_code AND st.sku_code=c.sku_code LEFT JOIN sales s ON s.customer_code=c.customer_code AND s.sku_code=c.sku_code "
+"LEFT JOIN last_sales l ON l.customer_code=c.customer_code AND l.sku_code=c.sku_code),"
+" qualified AS (SELECT v.*,CASE WHEN evidence_at_utc<=CONVERT_TZ(CAST(#{p.from} AS DATETIME),'+08:00','+00:00') THEN 1 ELSE 0 END observation_eligible,"
+"CASE WHEN unit_valid=1 AND evidence_at_utc<=CONVERT_TZ(CAST(#{p.from} AS DATETIME),'+08:00','+00:00') THEN owner_outbound END outbound_quantity30d FROM values_by_sku v),"
+" calculated AS (SELECT q.*,CASE WHEN available_quantity=0 THEN 0 WHEN available_quantity>0 AND outbound_quantity30d>0 THEN available_quantity*30/outbound_quantity30d END coverage_days,"
+"CASE WHEN physical_quantity=0 THEN 0 WHEN physical_quantity>0 AND outbound_quantity30d IS NOT NULL THEN CASE WHEN outbound_quantity30d=0 THEN 1 ELSE 0 END END no_outbound,"
+"CASE WHEN #{o.coverageDaysThreshold} IS NULL THEN NULL WHEN physical_quantity=0 THEN 0 WHEN physical_quantity>0 AND available_quantity=0 THEN 0 "
+"WHEN physical_quantity>0 AND available_quantity>0 AND outbound_quantity30d IS NOT NULL THEN CASE WHEN outbound_quantity30d>0 AND available_quantity*30>outbound_quantity30d*#{o.coverageDaysThreshold} THEN 1 ELSE 0 END END above_threshold "
+"FROM qualified q), result AS (SELECT c.*,CASE WHEN physical_quantity IS NULL OR available_quantity IS NULL OR outbound_quantity30d IS NULL THEN 1 ELSE 0 END unassessable FROM calculated c) ";
}
private static String stockValue(String field){return "CASE WHEN c.stock_unit_valid=1 AND #{p.stockFresh}=1 THEN st."+field+" END "+field;}
private static String filter(AnalyticsInventoryOptions o){return switch(o.attentionValue()){
case "NO_OUTBOUND_30D" -> " WHERE no_outbound=1";
case "COVERAGE_GT_THRESHOLD" -> " WHERE above_threshold=1";
case "FROZEN_STOCK" -> " WHERE frozen_quantity>0";
case "DEFECTIVE_STOCK" -> " WHERE defective_physical_quantity>0";
case "UNASSESSABLE" -> " WHERE unassessable=1";
default -> "";};}
private static String order(AnalyticsInventoryOptions o){String field=switch(o.sortValue()){
case "PHYSICAL_DESC" -> "physical_quantity";case "COVERAGE_DAYS_DESC" -> "coverage_days";case "OUTBOUND_30D_ASC" -> "outbound_quantity30d";default -> "available_quantity";};
return field+" IS NULL,"+field+("OUTBOUND_30D_ASC".equals(o.sortValue())?" ASC":" DESC")+",sku_code ASC,customer_code ASC";}
public static String inventoryPage(Map<String,Object> args){var p=(AnalyticsSqlRequest)args.get("p");var o=(AnalyticsInventoryOptions)args.get("o");
return typed(cte(p)+"SELECT customer_code,sku_code,product_name,unit_valid,evidence_at_utc,physical_quantity,available_quantity,frozen_quantity,transit_quantity,defective_physical_quantity,observed_at_utc,owner_outbound,observed_outbound,order_count,last_outbound,observation_eligible,outbound_quantity30d,coverage_days,no_outbound,above_threshold,unassessable FROM result"+filter(o)+" ORDER BY "+order(o)+" LIMIT #{p.size} OFFSET #{p.offset}");}
public static String inventoryCount(Map<String,Object> args){var p=(AnalyticsSqlRequest)args.get("p");var o=(AnalyticsInventoryOptions)args.get("o");
return typed("ALL".equals(o.attentionValue())?"SELECT COUNT(*) FROM ("+keys(p)+") k":cte(p)+"SELECT COUNT(*) FROM result"+filter(o));}
public static String ownerTotals(Map<String,Object> args){var p=(AnalyticsSqlRequest)args.get("p");StringBuilder sql=new StringBuilder(cte(p)).append("SELECT COUNT(*) sku_count");
for(String field:java.util.List.of("physical_quantity","available_quantity","frozen_quantity","transit_quantity","defective_physical_quantity","owner_outbound"))
sql.append(",SUM(").append(field).append(") ").append(field).append("_known,COUNT(*)-COUNT(").append(field).append(") ").append(field).append("_unknown");
sql.append(",SUM(CASE WHEN physical_quantity>0 THEN 1 WHEN physical_quantity=0 THEN 0 END) physical_sku_known,SUM(observed_outbound) observed_outbound");
for(String risk:java.util.List.of("no_outbound","above_threshold"))sql.append(",SUM(CASE WHEN ").append(risk).append("=1 THEN 1 ELSE 0 END) ").append(risk).append("_count,SUM(CASE WHEN ").append(risk).append("=1 THEN physical_quantity ELSE 0 END) ").append(risk).append("_physical,COUNT(*)-COUNT(").append(risk).append(") ").append(risk).append("_unknown,SUM(CASE WHEN ").append(risk).append(" IS NULL THEN physical_quantity END) ").append(risk).append("_unknown_physical");
return typed(sql+" FROM result");}
private static String ownersBase(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){return "SELECT DISTINCT customer_code FROM ("+keys(p)+") k"+(o.keyword()==null?"":" WHERE customer_code LIKE #{o.keywordPattern} ESCAPE '!'");}
public static String owners(Map<String,Object> args){return typed(ownersBase((AnalyticsSqlRequest)args.get("p"),(AnalyticsInventoryOptions)args.get("o"))+" ORDER BY customer_code ASC LIMIT #{p.size} OFFSET #{p.offset}");}
public static String ownerCount(Map<String,Object> args){return typed("SELECT COUNT(*) FROM ("+ownersBase((AnalyticsSqlRequest)args.get("p"),(AnalyticsInventoryOptions)args.get("o"))+") owners");}
private static String typed(String sql){for(String key:java.util.List.of("from","until","anchor"))sql=sql.replace("#{p."+key+"}","CAST(#{p."+key+"} AS DATE)");return sql.replace("#{o.coverageDaysThreshold}","CAST(#{o.coverageDaysThreshold} AS DECIMAL(20,0))");}
}
@@ -0,0 +1,40 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import com.baiyue.common.core.exception.BizException;
import java.time.Instant;
import java.time.LocalDate;
import org.springframework.format.annotation.DateTimeFormat;
/** Canonical constructor only: this record is also Spring's HTTP query binding contract. */
public record AnalyticsQuery(String sourceAccount, String warehouseCode, String customerCode, String sku,
String rangePreset, @DateTimeFormat(iso=DateTimeFormat.ISO.DATE) LocalDate startDate,
@DateTimeFormat(iso=DateTimeFormat.ISO.DATE) LocalDate endDate, String bucket, Integer windowDays,
Integer current, Integer size, Long salesRunId, Long inventoryRunId, String salesScopeHash,
String inventoryScopeHash, @DateTimeFormat(iso=DateTimeFormat.ISO.DATE_TIME) Instant queryAsOf) {
public int pageNumber(){return current==null?1:current;}
public int pageSize(){return size==null?20:size;}
public int rankingDays(){return windowDays==null?30:windowDays;}
public boolean pinned(){return salesRunId!=null;}
public long offset(){return (long)(pageNumber()-1)*pageSize();}
public void validate(boolean chart, boolean exactSku, boolean hot) {
text(sourceAccount,64,"来源账号"); text(warehouseCode,64,"仓库");
if(customerCode!=null)text(customerCode,64,"货主"); if(sku!=null)text(sku,128,"SKU");
if(exactSku){text(customerCode,64,"货主");text(sku,128,"SKU");}
if(pageNumber()<1||pageSize()<1||pageSize()>100)throw new BizException("分页范围为current≥1,size为1至100");
if((salesRunId==null)!=(inventoryRunId==null))throw new BizException("必须携带完整版本对");
if(pinned()){
if(salesRunId<0||inventoryRunId<0||queryAsOf==null)throw new BizException("固定版本必须携带queryAsOf");
if(salesRunId>0&&!hash(salesScopeHash)||inventoryRunId>0&&!hash(inventoryScopeHash))throw new BizException("固定版本缺少范围摘要");
}else if(queryAsOf!=null||salesScopeHash!=null||inventoryScopeHash!=null||pageNumber()!=1)
throw new BizException("后续查询必须携带首屏完整上下文");
if(chart){
if(windowDays!=null)throw new BizException("SKU周期查询不接受windowDays");
if(bucket!=null&&!"DAY".equals(bucket))throw new BizException("仅支持DAY日粒度");
}else{
if(rangePreset!=null||startDate!=null||endDate!=null||bucket!=null)throw new BizException("指标不接受图表周期参数");
if(hot?(rankingDays()!=7&&rankingDays()!=30):(windowDays!=null))throw new BizException("热销仅支持7/30天;其他指标固定30天");
}
}
private static boolean hash(String v){return v!=null&&v.matches("[0-9a-f]{64}");}
static void text(String v,int max,String label){if(v==null||v.isBlank()||v.length()>max)throw new BizException(label+"必填且不能超长");}
}
@@ -0,0 +1,267 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.selfoperated.analytics.statistics.*;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.selfoperated.analytics.query.AnalyticsReadRows.*;
@Service
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public class AnalyticsQueryService {
private static final ZoneId BUSINESS_ZONE=ZoneId.of("Asia/Shanghai");
private final AnalyticsReadMapper mapper;
private final ObjectMapper json;
private final Clock clock;
@Value("${baiyue.self-operated-analytics.max-query-days:366}") private int maxQueryDays=366;
@Value("${baiyue.self-operated-analytics.sales-retention-days:7}") private int retentionDays=7;
@Autowired public AnalyticsQueryService(AnalyticsReadMapper mapper,ObjectMapper json){this(mapper,json,Clock.systemUTC());}
AnalyticsQueryService(AnalyticsReadMapper mapper,ObjectMapper json,Clock clock){this.mapper=mapper;this.json=json;this.clock=clock;}
private record Snapshot(AnalyticsQuery q,Run sales,Run inventory,boolean sourceKnown,boolean trusted,
LocalDate fullFrom,LocalDate fullUntil,SalesRangePolicy.Range range,AnalyticsResponses.Context context){}
public AnalyticsResponses.Page<AnalyticsResponses.SalesSummary> salesSummary(AnalyticsQuery q){
Snapshot s=context(q,true,false,false); AnalyticsSqlRequest p=request(s,"SUMMARY");
long count=mapper.count(p);
var records=mapper.page(p).stream().map(r->summary(s,r)).toList();
return page(s,records,count,Map.of());
}
public AnalyticsResponses.Trend dailySales(AnalyticsQuery q){
Snapshot s=context(q,true,true,false);
Profile sp=profile(s,s.sales),ip=profile(s,s.inventory);
if(sp==null&&ip==null)throw new BizException("所选固定版本中无该货主和SKU记录");
String unit=sp!=null?sp.quantityUnit():ip==null?null:ip.quantityUnit();
boolean valid="PCS".equals(unit);
List<Daily> rows=s.sales==null?List.of():mapper.daily(s.sales.id(),q.sourceAccount(),q.warehouseCode(),q.customerCode(),q.sku(),
s.range.startDate(),s.range.endDateExclusive());
Map<LocalDate,Daily> daily=new HashMap<>();
for(Daily row:rows){
if(daily.put(row.businessDate(),row)!=null)throw new BizException("已发布版本存在重复日行");
if(!Objects.equals(unit,row.quantityUnit()))valid=false;
}
Instant evidence=earliest(sp,ip);
List<SalesBucketPolicy.Point> evaluated=new ArrayList<>(); List<AnalyticsResponses.Point> points=new ArrayList<>();
for(LocalDate day=s.range.startDate();day.isBefore(s.range.endDateExclusive());day=day.plusDays(1)){
Daily row=daily.get(day);
var point=SalesBucketPolicy.evaluate(new SalesBucketPolicy.Input(day,s.range.anchorDate(),BUSINESS_ZONE,s.fullFrom,s.fullUntil,
s.context.salesCoveredFrom(),s.context.salesCoveredUntil(),evidence,row==null?null:row.outboundQuantity(),
row==null?null:row.outboundOrderCount(),valid,s.sourceKnown,s.context.rebuildRequired(),"STALE".equals(s.context.salesStatus())));
evaluated.add(point);
points.add(new AnalyticsResponses.Point(day,point.outboundQuantity(),point.observedQuantity(),point.dataStatus(),
point.reasonCodes(),unit,point.provedFrom(),point.provedUntil()));
}
var totals=SalesBucketPolicy.aggregate(evaluated);
var summary=new AnalyticsResponses.SalesSummary(q.sourceAccount(),q.warehouseCode(),q.customerCode(),q.sku(),name(sp,ip),unit,
totals.outboundQuantity(),totals.observedQuantity(),totals.dataStatus(),totals.reasonCodes(),
totals.completeDays(),totals.partialDays(),totals.unknownDays());
return new AnalyticsResponses.Trend(s.context,summary,List.copyOf(points));
}
public AnalyticsResponses.Page<AnalyticsResponses.Metric> hotSkus(AnalyticsQuery q){return metrics(q,"HOT",true);}
public AnalyticsResponses.Page<AnalyticsResponses.Metric> slowSkus(AnalyticsQuery q){return metrics(q,"SLOW",false);}
public AnalyticsResponses.Page<AnalyticsResponses.Metric> inventoryCoverage(AnalyticsQuery q,AnalyticsInventoryOptions o){
o.validate("INVENTORY",q);Snapshot s=context(q,false,false,false);var p=request(s,"INVENTORY");
long total=mapper.inventoryCount(p,o);
return page(s,q.offset()>=total?List.of():mapper.inventoryPage(p,o).stream().map(r->inventoryMetric(s,r)).toList(),total,Map.of());
}
public AnalyticsResponses.Page<AnalyticsResponses.OwnerOption> owners(AnalyticsQuery q,AnalyticsInventoryOptions o){
o.validate("OWNERS",q);Snapshot s=context(q,false,false,false);var p=request(s,"OWNERS");long total=mapper.ownerCount(p,o);
return page(s,q.offset()>=total?List.of():mapper.owners(p,o).stream().map(code->new AnalyticsResponses.OwnerOption(code,null)).toList(),total,Map.of());
}
public AnalyticsResponses.OwnerOverview ownerOverview(AnalyticsQuery q,AnalyticsInventoryOptions o){
o.validate("OVERVIEW",q);Snapshot s=context(q,false,false,false);var raw=mapper.ownerTotals(request(s,"OVERVIEW"),o);
Map<String,Object> totals=new HashMap<>();if(raw!=null)raw.forEach((key,value)->totals.put(key.toLowerCase(Locale.ROOT),value));
long count=number(totals,"sku_count");if(count==0)return null;
var physical=quantity(totals,"physical_quantity");var outbound=quantity(totals,"owner_outbound");
if(outbound.overallTotal()==null)outbound=new AnalyticsResponses.QuantityTotal(null,decimal(totals,"observed_outbound"),outbound.unknownSkuCount(),
decimal(totals,"observed_outbound")==null?"UNKNOWN":"PARTIAL",List.of("SALES_WINDOW_UNAVAILABLE"));
var average=new AnalyticsResponses.QuantityTotal(divide30(outbound.overallTotal()),divide30(outbound.knownSubtotal()),outbound.unknownSkuCount(),outbound.dataStatus(),outbound.reasonCodes());
var physicalSkus=new AnalyticsResponses.QuantityTotal(physical.unknownSkuCount()==0?decimal(totals,"physical_sku_known"):null,
decimal(totals,"physical_sku_known"),physical.unknownSkuCount(),physical.dataStatus(),physical.reasonCodes());
return new AnalyticsResponses.OwnerOverview(s.context,q.customerCode(),null,count,physicalSkus,physical,quantity(totals,"available_quantity"),
quantity(totals,"frozen_quantity"),quantity(totals,"defective_physical_quantity"),quantity(totals,"transit_quantity"),outbound,average,
risk(totals,"no_outbound",physical,false),risk(totals,"above_threshold",physical,o.coverageDaysThreshold()==null));
}
private AnalyticsResponses.Metric inventoryMetric(Snapshot s,Inventory r){
boolean observed=Boolean.TRUE.equals(r.observationEligible());String quality=!"VALID".equals(s.context.inventoryStatus())?s.context.inventoryStatus():r.availableQuantity()==null?"UNKNOWN":"VALID";
String coverage=!Boolean.TRUE.equals(r.unitValid())?"UNIT_UNKNOWN_OR_CONFLICT":r.ownerOutbound()!=null?"COMPLETE":"COVERAGE_INSUFFICIENT";
var calculated=InventoryCoveragePolicy.calculate(quality,r.availableQuantity(),r.outboundQuantity30d(),s.trusted,
complete(s,s.range.startDate(),s.range.endDateExclusive()),observed,Boolean.TRUE.equals(r.unitValid()));
List<String> attention=new ArrayList<>();if(Integer.valueOf(1).equals(r.noOutbound()))attention.add("NO_OUTBOUND_30D");
if(Integer.valueOf(1).equals(r.aboveThreshold()))attention.add("COVERAGE_GT_THRESHOLD");
if(positive(r.frozenQuantity()))attention.add("FROZEN_STOCK");if(positive(r.defectivePhysicalQuantity()))attention.add("DEFECTIVE_STOCK");
if(Boolean.TRUE.equals(r.unassessable()))attention.add("UNASSESSABLE");
List<String> reasons=new ArrayList<>(s.context.reasonCodes());if(!observed)reasons.add("OBSERVATION_INSUFFICIENT");
if(!Boolean.TRUE.equals(r.unitValid()))reasons.add("UNIT_UNKNOWN_OR_CONFLICT");
if(r.lastOutbound()==null)reasons.add("NO_OUTBOUND_IN_COLLECTED_HISTORY");
if(!"VALID".equals(calculated.status()))reasons.add(calculated.status());
Map<String,String> statuses=new LinkedHashMap<>();String unknown="STALE".equals(s.context.inventoryStatus())?"STALE":"UNKNOWN";
statuses.put("physicalQuantity",r.physicalQuantity()==null?unknown:"VALID");statuses.put("availableQuantity",r.availableQuantity()==null?unknown:"VALID");
statuses.put("frozenQuantity",r.frozenQuantity()==null?unknown:"VALID");statuses.put("transitQuantity",r.transitQuantity()==null?unknown:"VALID");
statuses.put("defectivePhysicalQuantity",r.defectivePhysicalQuantity()==null?unknown:"VALID");statuses.put("outboundQuantity30d",r.outboundQuantity30d()==null?"UNKNOWN":"VALID");
return new AnalyticsResponses.Metric(s.q.sourceAccount(),r.customerCode(),s.q.warehouseCode(),r.skuCode(),r.productName(),Boolean.TRUE.equals(r.unitValid())?"PCS":null,
r.outboundQuantity30d(),r.orderCount(),null,r.physicalQuantity(),r.availableQuantity(),r.frozenQuantity(),r.transitQuantity(),utc(r.observedAtUtc()),
divide30(r.outboundQuantity30d()),calculated.days(),quality,coverage,observed,utc(r.evidenceAtUtc()),calculated.status(),List.copyOf(new LinkedHashSet<>(reasons)),
null,r.defectivePhysicalQuantity(),r.outboundQuantity30d(),r.lastOutbound(),historyStart(s),List.copyOf(attention),Map.copyOf(statuses));
}
private static boolean positive(BigDecimal n){return n!=null&&n.signum()>0;}
private static LocalDate historyStart(Snapshot s){return s.context.salesCoveredFrom()==null?null:s.context.salesCoveredFrom().atZone(BUSINESS_ZONE).toLocalDate();}
private static BigDecimal divide30(BigDecimal n){return n==null?null:n.divide(BigDecimal.valueOf(30),12,java.math.RoundingMode.HALF_UP);}
private static BigDecimal decimal(Map<String,Object> row,String key){Object n=row.get(key);return n==null?null:n instanceof BigDecimal d?d:new BigDecimal(n.toString());}
private static long number(Map<String,Object> row,String key){BigDecimal n=decimal(row,key);return n==null?0:n.longValueExact();}
private static AnalyticsResponses.QuantityTotal quantity(Map<String,Object> row,String field){
BigDecimal known=decimal(row,field+"_known");long unknown=number(row,field+"_unknown");
return new AnalyticsResponses.QuantityTotal(unknown==0?known:null,known,unknown,unknown==0?"COMPLETE":known==null?"UNKNOWN":"PARTIAL",
unknown==0?List.of():List.of("METRIC_DEPENDENCY_UNKNOWN"));
}
private static AnalyticsResponses.RiskShare risk(Map<String,Object> row,String field,AnalyticsResponses.QuantityTotal physical,boolean unset){
long unknown=number(row,field+"_unknown");long known=number(row,field+"_count");BigDecimal knownPhysical=decimal(row,field+"_physical");
boolean complete=!unset&&unknown==0&&physical.overallTotal()!=null;
BigDecimal ratio=complete&&positive(physical.overallTotal())?knownPhysical.divide(physical.overallTotal(),12,java.math.RoundingMode.HALF_UP):null;
List<String> reasons=unset?List.of("THRESHOLD_NOT_SET"):!complete?List.of("RISK_DEPENDENCY_UNKNOWN"):
!positive(physical.overallTotal())?List.of("NO_PHYSICAL_INVENTORY"):List.of();
return new AnalyticsResponses.RiskShare(complete?known:null,complete?knownPhysical:null,ratio,unset?null:known,unset?null:knownPhysical,
unknown,decimal(row,field+"_unknown_physical"),reasons);
}
private AnalyticsResponses.Page<AnalyticsResponses.Metric> metrics(AnalyticsQuery q,String kind,boolean hot){
Snapshot s=context(q,false,false,hot); AnalyticsSqlRequest p=request(s,kind);
long total=mapper.count(p);
List<AnalyticsResponses.Metric> rows=q.offset()>=total?List.of():mapper.page(p).stream().map(r->metric(s,r)).toList();
Map<String,Long> excluded=new TreeMap<>();
if(!"INVENTORY".equals(kind))for(var e:mapper.excluded(p))excluded.put(e.reason(),e.total());
return page(s,rows,total,excluded);
}
private AnalyticsResponses.SalesSummary summary(Snapshot s,Aggregate r){
int complete=r.completeDayCount(),partial=r.partialDayCount(),unknown=r.unknownDayCount();
String status=complete==s.range.dayCount()?"COMPLETE":r.observedQuantity()!=null?"PARTIAL":"UNKNOWN";
List<String> reasons=new ArrayList<>(s.context.reasonCodes());
if(!Boolean.TRUE.equals(r.unitValid()))reasons.add("UNIT_UNKNOWN_OR_CONFLICT");
if(partial>0)reasons.add("SALES_DAY_INCOMPLETE"); if(unknown>0)reasons.add("SALES_NOT_COVERED_OR_SKU_HISTORY_UNKNOWN");
return new AnalyticsResponses.SalesSummary(s.q.sourceAccount(),s.q.warehouseCode(),r.customerCode(),r.skuCode(),r.productName(),
r.quantityUnit(),"COMPLETE".equals(status)?r.observedQuantity():null,r.observedQuantity(),status,List.copyOf(reasons),complete,partial,unknown);
}
private AnalyticsResponses.Metric metric(Snapshot s,Aggregate r){
Instant evidence=utc(r.evidenceAtUtc());
boolean observed=evidence!=null&&!evidence.isAfter(s.range.anchorDate().minusDays(30).atStartOfDay(BUSINESS_ZONE).toInstant());
boolean stockValid="VALID".equals(r.inventoryQuality());
String coverage=!Boolean.TRUE.equals(r.unitValid())?"UNIT_UNKNOWN_OR_CONFLICT":!s.trusted?"SALES_UNTRUSTED":
complete(s,s.range.startDate(),s.range.endDateExclusive())?"COMPLETE":"COVERAGE_INSUFFICIENT";
var result=InventoryCoveragePolicy.calculate(r.inventoryQuality(),stockValid?r.availableQuantity():null,r.sales30Quantity(),
s.trusted,complete(s,s.range.anchorDate().minusDays(30),s.range.anchorDate()),observed,Boolean.TRUE.equals(r.unitValid()));
List<String> reasons=new ArrayList<>(s.context.reasonCodes());
if(!"COMPLETE".equals(coverage))reasons.add(coverage);
if(!"VALID".equals(result.status()))reasons.add(result.status());
if(r.inventoryReason()!=null)reasons.add(r.inventoryReason());
BigDecimal today=r.todayQuantity();
if(today==null&&s.sourceKnown&&Boolean.TRUE.equals(r.unitValid())&&evidence!=null&&s.context.salesCoveredUntil()!=null){
Instant start=s.range.anchorDate().atStartOfDay(BUSINESS_ZONE).toInstant();
Instant lower=evidence.isAfter(start)?evidence:start;
if(s.context.salesCoveredFrom()!=null&&s.context.salesCoveredFrom().isAfter(lower))lower=s.context.salesCoveredFrom();
if(lower.isBefore(s.context.salesCoveredUntil()))today=BigDecimal.ZERO;
}
return new AnalyticsResponses.Metric(s.q.sourceAccount(),r.customerCode(),s.q.warehouseCode(),r.skuCode(),r.productName(),r.quantityUnit(),
r.windowQuantity(),r.windowSkuOrderCount(),today,stockValid?r.physicalQuantity():null,stockValid?r.availableQuantity():null,
stockValid?r.frozenQuantity():null,stockValid?r.transitQuantity():null,utc(r.inventoryObservedAtUtc()),result.average(),result.days(),
r.inventoryQuality(),coverage,observed,evidence,result.status(),List.copyOf(new LinkedHashSet<>(reasons)),null,null,r.sales30Quantity(),null,historyStart(s),List.of(),Map.of());
}
private Snapshot context(AnalyticsQuery q,boolean chart,boolean exact,boolean hot){
q.validate(chart,exact,hot); Instant now=clock.instant();
List<Scope> scopes=mapper.scopes(q.sourceAccount(),q.warehouseCode());
Scope salesScope=scope(scopes,"OUTBOUND"),stockScope=scope(scopes,"INVENTORY");
if(salesScope==null&&stockScope==null)throw new BizException("来源账号或仓库未配置");
Run sales=resolve(salesScope,q.pinned()?q.salesRunId():null,q.salesScopeHash());
Run inventory=resolve(stockScope,q.pinned()?q.inventoryRunId():null,q.inventoryScopeHash());
JsonNode sc=config(sales),ic=config(inventory);
String zone=sales!=null?sc.path("businessTimezone").asText():inventory!=null?ic.path("businessTimezone").asText():
salesScope!=null?salesScope.businessTimezone():stockScope.businessTimezone();
if(!BUSINESS_ZONE.getId().equals(zone)||inventory!=null&&!BUSINESS_ZONE.getId().equals(ic.path("businessTimezone").asText()))
throw new BizException("发布版本不符合已确认的北京时间口径");
Instant asOf=q.pinned()?q.queryAsOf():now;
if(asOf.isAfter(now))throw new BizException("queryAsOf不能晚于服务端时间");
if(sales!=null&&(sales.publishedAtUtc()==null||asOf.isBefore(utc(sales.publishedAtUtc())))
||inventory!=null&&(inventory.publishedAtUtc()==null||asOf.isBefore(utc(inventory.publishedAtUtc()))))
throw new BizException("queryAsOf不能早于所选版本发布时间");
Instant expires=asOf.plusSeconds(1800);
if(sales!=null&&sales.supersededAtUtc()!=null){
Instant deadline=utc(sales.supersededAtUtc()).plus(Duration.ofDays(Math.max(7,retentionDays)));
if(deadline.isBefore(expires))expires=deadline;
}
if(!now.isBefore(expires))throw new BizException("查询上下文或统计版本已过期,请刷新整页");
LocalDate anchor=asOf.atZone(BUSINESS_ZONE).toLocalDate();
SalesRangePolicy.Range range;
try{
range=chart?SalesRangePolicy.resolve(q.rangePreset()==null?"TODAY":q.rangePreset(),q.startDate(),q.endDate(),
q.bucket()==null?"DAY":q.bucket(),asOf,BUSINESS_ZONE,maxQueryDays):
new SalesRangePolicy.Range(anchor,anchor.minusDays(hot?q.rankingDays():30),anchor,hot?q.rankingDays():30,
anchor.minusDays(hot?q.rankingDays():30).atStartOfDay(BUSINESS_ZONE).toInstant(),anchor.atStartOfDay(BUSINESS_ZONE).toInstant());
}catch(IllegalArgumentException e){throw new BizException("查询周期或日期范围无效,最多"+maxQueryDays+"个自然日");}
boolean rebuild=salesScope!=null&&(Boolean.TRUE.equals(salesScope.rebuildRequired())
||!Objects.equals(salesScope.factRevision(),salesScope.publishedFactRevision())||mapper.applyBarriers(q.sourceAccount(),q.warehouseCode())>0);
boolean known=sales!=null&&BUSINESS_ZONE.getId().equals(sc.path("sourceTimezone").asText());
String salesState=sales==null?"UNKNOWN":!known?"SOURCE_TIMEZONE_UNKNOWN":stale(sales.coveredUntilUtc(),threshold(sc))?"STALE":"VALID";
if(rebuild)salesState="REBUILD_REQUIRED";
String inventoryState=inventory==null?"UNKNOWN":stale(inventory.fetchFinishedAtUtc(),threshold(ic))?"STALE":"VALID";
LocalDate fullFrom=sales==null?null:sales.completeDayFrom(),fullUntil=sales==null?null:sales.completeDayUntil();
if(sales!=null&&(sales.coveredFromUtc()==null||sales.coveredUntilUtc()==null||!sales.coveredFromUtc().isBefore(sales.coveredUntilUtc()))){
fullFrom=null;fullUntil=null;
}
if(sales!=null&&sales.coveredFromUtc()!=null&&fullFrom!=null){
var covered=utc(sales.coveredFromUtc()).atZone(BUSINESS_ZONE); LocalDate first=covered.toLocalDate();
if(!covered.toLocalTime().equals(LocalTime.MIDNIGHT))first=first.plusDays(1);
if(first.isAfter(fullFrom))fullFrom=first;
}
if(sales!=null&&sales.coveredUntilUtc()!=null&&fullUntil!=null){
LocalDate last=utc(sales.coveredUntilUtc()).atZone(BUSINESS_ZONE).toLocalDate();
if(last.isBefore(fullUntil))fullUntil=last; if(anchor.isBefore(fullUntil))fullUntil=anchor;
}
String review=reviewStatus(salesScope); List<String> reasons=new ArrayList<>();
if(!"VALID".equals(salesState))reasons.add("SALES_"+salesState);
if(!"VALID".equals(inventoryState))reasons.add("INVENTORY_"+inventoryState);
if(!"CURRENT".equals(review))reasons.add("REVISION_REVIEW_"+review);
var c=new AnalyticsResponses.Context(sales==null?0L:sales.id(),inventory==null?0L:inventory.id(),sales==null?null:sales.scopeHash(),
inventory==null?null:inventory.scopeHash(),asOf,expires,zone,anchor,range.startDate(),range.endDateExclusive(),chart?null:range.dayCount(),
fullFrom,fullUntil,sales==null?null:utc(sales.coveredFromUtc()),sales==null?null:utc(sales.coveredUntilUtc()),
sales==null?null:utc(sales.publishedAtUtc()),inventory==null?null:utc(inventory.fetchStartedAtUtc()),
inventory==null?null:utc(inventory.fetchFinishedAtUtc()),salesState,inventoryState,rebuild,review,List.copyOf(reasons));
return new Snapshot(q,sales,inventory,known,"VALID".equals(salesState),fullFrom,fullUntil,range,c);
}
private AnalyticsSqlRequest request(Snapshot s,String kind){
int size=s.q.pageSize(); if("HOT".equals(kind)||"SLOW".equals(kind))size=(int)Math.max(0,Math.min(size,100-s.q.offset()));
return new AnalyticsSqlRequest(s.q.sourceAccount(),s.q.warehouseCode(),s.q.customerCode(),s.q.sku(),s.context.salesRunId(),
s.context.inventoryRunId(),s.range.startDate(),s.range.endDateExclusive(),s.range.anchorDate(),s.range.anchorDate().minusDays(s.q.rankingDays()),
s.fullFrom,s.fullUntil,s.sales==null?null:s.sales.coveredFromUtc(),s.sales==null?null:s.sales.coveredUntilUtc(),s.sourceKnown,s.sourceKnown,
s.trusted&&complete(s,s.range.startDate(),s.range.endDateExclusive()),
s.trusted&&complete(s,s.range.anchorDate().minusDays(30),s.range.anchorDate()),"VALID".equals(s.context.inventoryStatus()),s.q.offset(),size,kind);
}
private Run resolve(Scope scope,Long requested,String hash){
if(scope==null){if(requested!=null&&requested!=0)throw new BizException("版本任务族未配置");return null;}
Long id=requested==null?scope.publishedRunId():requested; if(id==null||id==0)return null;
Run run=mapper.publishedRun(id);
if(run==null||!Objects.equals(run.scopeId(),scope.id())||!scope.sourceAccount().equals(run.sourceAccount())
||!scope.warehouseCode().equals(run.warehouseCode())||!scope.scopeHash().equals(run.scopeHash())
||requested!=null&&!run.scopeHash().equals(hash)||!"BUSINESS".equals(run.executionPurpose())||!"PUBLISHED".equals(run.status()))
throw new BizException("版本不可用或范围不符,请刷新整页");
if(!"COMPLETE".equals(run.coverageStatus()))throw new BizException("发布版本缺少完整覆盖证明");
return run;
}
private Profile profile(Snapshot s,Run run){return run==null?null:mapper.profile(run.id(),s.q.sourceAccount(),s.q.warehouseCode(),s.q.customerCode(),s.q.sku());}
private static boolean complete(Snapshot s,LocalDate from,LocalDate until){return s.sourceKnown&&s.sales!=null&&s.sales.coveredFromUtc()!=null&&s.sales.coveredUntilUtc()!=null
&&s.sales.coveredFromUtc().isBefore(s.sales.coveredUntilUtc())&&s.fullFrom!=null&&s.fullUntil!=null&&!from.isBefore(s.fullFrom)&&!until.isAfter(s.fullUntil);}
private JsonNode config(Run r){if(r==null)return json.createObjectNode();try{JsonNode n=json.readTree(r.scopeSnapshotJson());if(n==null||!n.isObject())throw new IllegalArgumentException();return n;}catch(Exception e){throw new BizException("版本配置快照无效");}}
private static int threshold(JsonNode c){int n=c.path("staleAfterSeconds").asInt();if(n<=0)throw new BizException("版本过期阈值无效");return n;}
private boolean stale(LocalDateTime t,int seconds){return t==null||utc(t).isAfter(clock.instant())||!clock.instant().isBefore(utc(t).plusSeconds(seconds));}
private String reviewStatus(Scope s){if(s==null||s.knownOrderRoundFinishedAtUtc()==null||s.historyRoundFinishedAtUtc()==null||s.reviewTargetDays()==null||s.reviewTargetDays()<=0)return "UNKNOWN";int age=Math.multiplyExact(s.reviewTargetDays(),86400);return stale(s.knownOrderRoundFinishedAtUtc(),age)||stale(s.historyRoundFinishedAtUtc(),age)?"LAGGING":"CURRENT";}
private static Scope scope(List<Scope> ss,String family){return ss.stream().filter(s->family.equals(s.dataFamily())).findFirst().orElse(null);}
private static String name(Profile s,Profile i){return i!=null&&i.productName()!=null?i.productName():s==null?null:s.productName();}
private static Instant earliest(Profile s,Profile i){Instant a=evidence(s,"VALID_OUTBOUND"),b=evidence(i,"INVENTORY_OBSERVATION");return a==null?b:b==null||a.isBefore(b)?a:b;}
private static Instant evidence(Profile p,String type){return p!=null&&type.equals(p.evidenceType())&&p.evidenceRunId()!=null?utc(p.earliestEvidenceAtUtc()):null;}
private static Instant utc(LocalDateTime t){return t==null?null:t.toInstant(ZoneOffset.UTC);}
private static <T> AnalyticsResponses.Page<T> page(Snapshot s,List<T> rows,long total,Map<String,Long> exclusions){int size=s.q.pageSize();return new AnalyticsResponses.Page<>(s.context,rows,s.q.pageNumber(),size,total,total/size+(total%size==0?0:1),Map.copyOf(exclusions));}
}
@@ -0,0 +1,49 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import java.util.List;
import org.apache.ibatis.annotations.*;
/** Reads immutable published versions in Lastleg only. */
@Mapper
public interface AnalyticsReadMapper {
String RUN_COLUMNS="id,task_id,scope_id,source_account,warehouse_code,run_role,scope_hash,scope_snapshot_json,status,"
+"coverage_status,covered_from_utc,covered_until_utc,complete_day_from,complete_day_until,"
+"fetch_started_at_utc,fetch_finished_at_utc,published_at_utc,calculated_fact_revision,error_code,error_message,superseded_at_utc,execution_purpose";
@Select("SELECT id,source_account,warehouse_code,data_family,scope_hash,business_timezone,source_timezone,"
+"stale_after_seconds,published_run_id,fact_revision,published_fact_revision,rebuild_required,"
+"known_order_round_finished_at_utc,history_round_finished_at_utc,review_target_days "
+"FROM ll_selfop_sync_scope WHERE source_account=#{account} AND warehouse_code=#{warehouse} ORDER BY data_family")
List<AnalyticsReadRows.Scope> scopes(@Param("account") String account,@Param("warehouse") String warehouse);
@Select("SELECT "+RUN_COLUMNS+" FROM ll_selfop_sync_run WHERE id=#{id} AND status='PUBLISHED' AND run_role='WAREHOUSE_SYNC' AND execution_purpose='BUSINESS'")
AnalyticsReadRows.Run publishedRun(@Param("id") long id);
@SelectProvider(type=AnalyticsReadSql.class,method="page")
List<AnalyticsReadRows.Aggregate> page(@Param("p") AnalyticsSqlRequest request);
@SelectProvider(type=AnalyticsReadSql.class,method="count")
long count(@Param("p") AnalyticsSqlRequest request);
record Exclusion(String reason,Long total){}
@SelectProvider(type=AnalyticsReadSql.class,method="excluded")
List<Exclusion> excluded(@Param("p") AnalyticsSqlRequest request);
@Select("SELECT customer_code,sku_code,product_name,quantity_unit,first_seen_at_utc,earliest_evidence_at_utc,"
+"evidence_type,evidence_run_id,last_seen_run_id FROM ll_selfop_sku_profile WHERE profile_run_id=#{run} "
+"AND source_account=#{account} AND warehouse_code=#{warehouse} AND customer_code=#{customer} AND sku_code=#{sku}")
AnalyticsReadRows.Profile profile(@Param("run") long run,@Param("account") String account,@Param("warehouse") String warehouse,
@Param("customer") String customer,@Param("sku") String sku);
@Select("SELECT customer_code,sku_code,business_date,outbound_quantity,outbound_order_count,quantity_unit "
+"FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{run} AND source_account=#{account} AND warehouse_code=#{warehouse} "
+"AND customer_code=#{customer} AND sku_code=#{sku} AND business_date>=#{from} AND business_date<#{until} ORDER BY business_date")
List<AnalyticsReadRows.Daily> daily(@Param("run") long run,@Param("account") String account,@Param("warehouse") String warehouse,
@Param("customer") String customer,@Param("sku") String sku,@Param("from") java.time.LocalDate from,@Param("until") java.time.LocalDate until);
@Select("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE source_account=#{account} AND apply_state IN ('APPLYING','BLOCKED') "
+"AND JSON_CONTAINS(affected_warehouse_codes_json,JSON_QUOTE(#{warehouse}))")
long applyBarriers(@Param("account") String account,@Param("warehouse") String warehouse);
@SelectProvider(type=AnalyticsInventorySql.class,method="inventoryPage")
List<AnalyticsReadRows.Inventory> inventoryPage(@Param("p") AnalyticsSqlRequest request,@Param("o") AnalyticsInventoryOptions options);
@SelectProvider(type=AnalyticsInventorySql.class,method="inventoryCount")
long inventoryCount(@Param("p") AnalyticsSqlRequest request,@Param("o") AnalyticsInventoryOptions options);
@SelectProvider(type=AnalyticsInventorySql.class,method="owners")
List<String> owners(@Param("p") AnalyticsSqlRequest request,@Param("o") AnalyticsInventoryOptions options);
@SelectProvider(type=AnalyticsInventorySql.class,method="ownerCount")
long ownerCount(@Param("p") AnalyticsSqlRequest request,@Param("o") AnalyticsInventoryOptions options);
@SelectProvider(type=AnalyticsInventorySql.class,method="ownerTotals")
java.util.Map<String,Object> ownerTotals(@Param("p") AnalyticsSqlRequest request,@Param("o") AnalyticsInventoryOptions options);
}
@@ -0,0 +1,39 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** Read projections only; DATETIME columns are UTC, never JVM-local time. */
public final class AnalyticsReadRows {
private AnalyticsReadRows() {}
public record Scope(Long id, String sourceAccount, String warehouseCode, String dataFamily,
String scopeHash, String businessTimezone, String sourceTimezone,
Integer staleAfterSeconds, Long publishedRunId, Long factRevision,
Long publishedFactRevision, Boolean rebuildRequired,
LocalDateTime knownOrderRoundFinishedAtUtc, LocalDateTime historyRoundFinishedAtUtc,
Integer reviewTargetDays) {}
public record Run(Long id, Long taskId, Long scopeId, String sourceAccount, String warehouseCode, String runRole,
String scopeHash, String scopeSnapshotJson, String status, String coverageStatus,
LocalDateTime coveredFromUtc, LocalDateTime coveredUntilUtc,
LocalDate completeDayFrom, LocalDate completeDayUntil,
LocalDateTime fetchStartedAtUtc, LocalDateTime fetchFinishedAtUtc,
LocalDateTime publishedAtUtc, Long calculatedFactRevision,
String errorCode, String errorMessage, LocalDateTime supersededAtUtc, String executionPurpose) {}
public record Profile(String customerCode, String skuCode, String productName, String quantityUnit,
LocalDateTime firstSeenAtUtc, LocalDateTime earliestEvidenceAtUtc,
String evidenceType, Long evidenceRunId, Long lastSeenRunId) {}
public record Daily(String customerCode, String skuCode, LocalDate businessDate,
BigDecimal outboundQuantity, Long outboundOrderCount, String quantityUnit) {}
public record Aggregate(String customerCode, String skuCode, String productName, String quantityUnit,
LocalDateTime evidenceAtUtc, Boolean unitValid, BigDecimal observedQuantity, Long skuOrderCount,
Integer completeDayCount, Integer partialDayCount, Integer unknownDayCount,
BigDecimal windowQuantity, Long windowSkuOrderCount, BigDecimal sales30Quantity, BigDecimal todayQuantity,
BigDecimal physicalQuantity, BigDecimal availableQuantity, BigDecimal frozenQuantity, BigDecimal transitQuantity,
LocalDateTime inventoryObservedAtUtc, String inventoryQuality, String inventoryReason) {}
public record Inventory(String customerCode,String skuCode,String productName,Boolean unitValid,
LocalDateTime evidenceAtUtc,BigDecimal physicalQuantity,BigDecimal availableQuantity,BigDecimal frozenQuantity,
BigDecimal transitQuantity,BigDecimal defectivePhysicalQuantity,LocalDateTime observedAtUtc,BigDecimal ownerOutbound,
BigDecimal observedOutbound,Long orderCount,LocalDate lastOutbound,Boolean observationEligible,
BigDecimal outboundQuantity30d,BigDecimal coverageDays,Integer noOutbound,Integer aboveThreshold,Boolean unassessable){}
}
@@ -0,0 +1,147 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
/** MySQL 8 CTEs: bounded by one version, warehouse and date range; no order/item/stage reads. */
public final class AnalyticsReadSql {
private AnalyticsReadSql(){}
private static final String KEY_FILTER=" source_account=#{p.account} AND warehouse_code=#{p.warehouse}"
+" AND (#{p.customer} IS NULL OR customer_code=#{p.customer}) AND (#{p.sku} IS NULL OR sku_code=#{p.sku}) ";
private static String candidates(boolean salesOnly){
String sql="WITH candidates AS (SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.salesRun} AND"
+KEY_FILTER+" UNION SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.inventoryRun} AND"+KEY_FILTER+"),"
+" dim AS (SELECT c.customer_code,c.sku_code,COALESCE(i.product_name,s.product_name) product_name,"
+"COALESCE(s.quantity_unit,i.quantity_unit) quantity_unit,"
+"CASE WHEN (s.sku_code IS NOT NULL AND s.quantity_unit IS NULL) OR (i.sku_code IS NOT NULL AND i.quantity_unit IS NULL) THEN 0 "
+"WHEN s.quantity_unit IS NOT NULL AND i.quantity_unit IS NOT NULL AND s.quantity_unit<>i.quantity_unit THEN 0 "
+"WHEN COALESCE(s.quantity_unit,i.quantity_unit)<>'PCS' OR COALESCE(s.quantity_unit,i.quantity_unit) IS NULL THEN 0 ELSE 1 END profile_unit_valid,"
+"CASE WHEN s.evidence_type='VALID_OUTBOUND' AND s.evidence_run_id IS NOT NULL THEN s.earliest_evidence_at_utc END sales_evidence,"
+"CASE WHEN i.evidence_type='INVENTORY_OBSERVATION' AND i.evidence_run_id IS NOT NULL THEN i.earliest_evidence_at_utc END stock_evidence "
+"FROM candidates c LEFT JOIN ll_selfop_sku_profile s ON s.profile_run_id=#{p.salesRun} AND s.source_account=#{p.account} "
+"AND s.warehouse_code=#{p.warehouse} AND s.customer_code=c.customer_code AND s.sku_code=c.sku_code "
+"LEFT JOIN ll_selfop_sku_profile i ON i.profile_run_id=#{p.inventoryRun} AND i.source_account=#{p.account} "
+"AND i.warehouse_code=#{p.warehouse} AND i.customer_code=c.customer_code AND i.sku_code=c.sku_code),"
+" evidence AS (SELECT dim.*,CASE WHEN sales_evidence IS NULL THEN stock_evidence WHEN stock_evidence IS NULL THEN sales_evidence "
+"ELSE LEAST(sales_evidence,stock_evidence) END evidence_at_utc FROM dim),";
if(salesOnly){
int start=sql.indexOf("CASE WHEN (s.sku_code IS NOT NULL"),end=sql.indexOf("profile_unit_valid,",start);
sql=sql.substring(0,start)+"CASE WHEN s.sku_code IS NOT NULL THEN CASE WHEN s.quantity_unit='PCS' THEN 1 ELSE 0 END WHEN i.quantity_unit='PCS' THEN 1 ELSE 0 END "+sql.substring(end);
sql=sql.replace("COALESCE(s.quantity_unit,i.quantity_unit) quantity_unit","CASE WHEN s.sku_code IS NOT NULL THEN s.quantity_unit ELSE i.quantity_unit END quantity_unit");
}
return sql;
}
private static String cte(String kind){
String localEvidence="CONVERT_TZ(evidence_at_utc,'+00:00','+08:00')";
String observedStart="CONVERT_TZ(GREATEST(evidence_at_utc,#{p.coveredFrom}),'+00:00','+08:00')";
String observedEnd="CONVERT_TZ(#{p.coveredUntil},'+00:00','+08:00')";
String sql=candidates("SUMMARY".equals(kind))+" bounds AS (SELECT evidence.*,"
+"CASE WHEN evidence_at_utc IS NOT NULL AND #{p.completeFrom} IS NOT NULL AND #{p.completeUntil} IS NOT NULL THEN "
+"GREATEST(#{p.from},#{p.completeFrom},TIMESTAMPADD(DAY,CASE WHEN CAST("+localEvidence+" AS TIME)='00:00:00' THEN 0 ELSE 1 END,DATE("+localEvidence+"))) END full_start,"
+"LEAST(#{p.until},#{p.completeUntil},#{p.anchor}) full_end,"
+"CASE WHEN evidence_at_utc IS NOT NULL AND #{p.coveredFrom} IS NOT NULL AND #{p.coveredUntil} IS NOT NULL AND GREATEST(evidence_at_utc,#{p.coveredFrom})<#{p.coveredUntil} THEN GREATEST(#{p.from},DATE("+observedStart+")) END observed_start,"
+"CASE WHEN evidence_at_utc IS NOT NULL AND #{p.coveredFrom} IS NOT NULL AND #{p.coveredUntil} IS NOT NULL AND GREATEST(evidence_at_utc,#{p.coveredFrom})<#{p.coveredUntil} THEN LEAST(#{p.until},TIMESTAMPADD(DAY,CASE WHEN CAST("+observedEnd+" AS TIME)='00:00:00' THEN 0 ELSE 1 END,DATE("+observedEnd+"))) END observed_end "
+"FROM evidence),"
+" ranged AS (SELECT b.customer_code,b.sku_code,SUM(d.outbound_quantity) observed_sum,COUNT(d.id) actual_days,"
+"SUM(d.outbound_order_count) order_sum,MIN(d.quantity_unit) min_unit,MAX(d.quantity_unit) max_unit,"
+"SUM(CASE WHEN d.business_date>=#{p.completeFrom} AND d.business_date<#{p.completeUntil} AND d.business_date<#{p.anchor} THEN 1 ELSE 0 END) actual_complete,"
+"SUM(CASE WHEN d.business_date>=b.full_start AND d.business_date<b.full_end THEN 1 ELSE 0 END) actual_full_span,"
+"SUM(CASE WHEN d.business_date>=b.observed_start AND d.business_date<b.observed_end THEN 1 ELSE 0 END) actual_observed_span "
+"FROM bounds b JOIN ll_selfop_sku_daily_sales d ON d.sales_run_id=#{p.salesRun} AND d.source_account=#{p.account} "
+"AND d.warehouse_code=#{p.warehouse} AND d.customer_code=b.customer_code AND d.sku_code=b.sku_code "
+"AND d.business_date>=#{p.from} AND d.business_date<#{p.until} GROUP BY b.customer_code,b.sku_code),"
+" metric_sales AS (SELECT customer_code,sku_code,"
+"SUM(CASE WHEN business_date>=#{p.windowFrom} AND business_date<#{p.anchor} THEN outbound_quantity ELSE 0 END) window_quantity,"
+"SUM(CASE WHEN business_date>=#{p.windowFrom} AND business_date<#{p.anchor} THEN outbound_order_count ELSE 0 END) window_orders,"
+"SUM(CASE WHEN business_date<#{p.anchor} THEN outbound_quantity ELSE 0 END) sales30_quantity,"
+"SUM(CASE WHEN business_date=#{p.anchor} THEN outbound_quantity ELSE NULL END) today_quantity,"
+"MIN(quantity_unit) min_unit,MAX(quantity_unit) max_unit "
+"FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{p.salesRun} AND"+KEY_FILTER
+" AND business_date>=TIMESTAMPADD(DAY,-30,#{p.anchor}) AND business_date<TIMESTAMPADD(DAY,1,#{p.anchor}) "
+"GROUP BY customer_code,sku_code),"
+" counted AS (SELECT b.*,r.observed_sum,r.order_sum,"
+"CASE WHEN b.profile_unit_valid=1 AND (r.min_unit IS NULL OR (r.min_unit=b.quantity_unit AND r.max_unit=b.quantity_unit)) "
+"AND (m.min_unit IS NULL OR (m.min_unit=b.quantity_unit AND m.max_unit=b.quantity_unit)) THEN 1 ELSE 0 END unit_valid,"
+"CASE WHEN #{p.formalSales}=1 THEN GREATEST(0,COALESCE(TIMESTAMPDIFF(DAY,b.full_start,b.full_end),0)) "
+"-COALESCE(r.actual_full_span,0)+COALESCE(r.actual_complete,0) ELSE 0 END complete_count,"
+"CASE WHEN #{p.salesKnown}=1 THEN GREATEST(0,COALESCE(TIMESTAMPDIFF(DAY,b.observed_start,b.observed_end),0)) "
+"-COALESCE(r.actual_observed_span,0)+COALESCE(r.actual_days,0) ELSE 0 END observed_count,"
+"m.window_quantity,m.window_orders,m.sales30_quantity,m.today_quantity "
+"FROM bounds b LEFT JOIN ranged r ON r.customer_code=b.customer_code AND r.sku_code=b.sku_code "
+"LEFT JOIN metric_sales m ON m.customer_code=b.customer_code AND m.sku_code=b.sku_code),"
+" result AS (SELECT c.customer_code,c.sku_code,c.product_name,c.quantity_unit,c.evidence_at_utc,c.unit_valid,"
+"CASE WHEN c.unit_valid=1 AND c.observed_count>0 THEN COALESCE(c.observed_sum,0) END observed_quantity,"
+"c.order_sum sku_order_count,CASE WHEN c.unit_valid=1 THEN c.complete_count ELSE 0 END complete_day_count,"
+"CASE WHEN c.unit_valid=1 THEN GREATEST(0,c.observed_count-c.complete_count) ELSE 0 END partial_day_count,"
+"TIMESTAMPDIFF(DAY,#{p.from},#{p.until})-CASE WHEN c.unit_valid=1 THEN c.observed_count ELSE 0 END unknown_day_count,"
+"CASE WHEN c.unit_valid=1 AND #{p.completeWindow}=1 THEN COALESCE(c.window_quantity,0) END window_quantity,"
+"CASE WHEN c.unit_valid=1 AND #{p.completeWindow}=1 THEN COALESCE(c.window_orders,0) END window_sku_order_count,"
+"CASE WHEN c.unit_valid=1 AND #{p.complete30}=1 THEN COALESCE(c.sales30_quantity,0) END sales30_quantity,"
+"CASE WHEN c.unit_valid=1 AND #{p.salesKnown}=1 THEN c.today_quantity END today_quantity,"
+"st.physical_quantity,st.available_quantity,st.frozen_quantity,st.transit_quantity,st.observed_at_utc inventory_observed_at_utc,"
+"CASE WHEN st.sku_code IS NULL THEN 'UNKNOWN' WHEN st.quality_status='INVALID' THEN 'INVALID' "
+"WHEN st.quality_status<>'VALID' OR st.available_quantity IS NULL OR st.physical_quantity IS NULL OR st.frozen_quantity IS NULL OR st.transit_quantity IS NULL THEN 'UNKNOWN' "
+"WHEN st.available_quantity<0 OR st.physical_quantity<0 OR st.frozen_quantity<0 OR st.transit_quantity<0 THEN 'INVALID' "
+"WHEN #{p.stockFresh}=0 THEN 'STALE' ELSE 'VALID' END inventory_quality,st.quality_reason inventory_reason "
+"FROM counted c LEFT JOIN ll_selfop_inventory_snapshot st ON st.snapshot_run_id=#{p.inventoryRun} AND st.source_account=#{p.account} "
+"AND st.warehouse_code=#{p.warehouse} AND st.customer_code=c.customer_code AND st.sku_code=c.sku_code AND st.product_quality=0) ";
if("SUMMARY".equals(kind)){
int start=sql.indexOf(" metric_sales AS (");int end=sql.indexOf(" counted AS (",start);
sql=sql.substring(0,start)+" metric_sales AS (SELECT customer_code,sku_code,CAST(NULL AS DECIMAL(20,6)) window_quantity,"
+"CAST(NULL AS DECIMAL(20,0)) window_orders,CAST(NULL AS DECIMAL(20,6)) sales30_quantity,"
+"CAST(NULL AS DECIMAL(20,6)) today_quantity,CAST(NULL AS CHAR(32)) min_unit,CAST(NULL AS CHAR(32)) max_unit FROM bounds WHERE 1=0),"+sql.substring(end);
// A sales chart does not require inventory quantities or freshness. Keep only profile evidence.
int stockJoin=sql.indexOf("LEFT JOIN ll_selfop_inventory_snapshot st");
sql=sql.substring(0,stockJoin)+"LEFT JOIN (SELECT CAST(NULL AS DECIMAL(20,0)) snapshot_run_id,CAST(NULL AS CHAR(64)) source_account,"
+"CAST(NULL AS CHAR(64)) warehouse_code,CAST(NULL AS CHAR(64)) customer_code,CAST(NULL AS CHAR(128)) sku_code,"
+"CAST(NULL AS DECIMAL(20,6)) physical_quantity,CAST(NULL AS DECIMAL(20,6)) available_quantity,"
+"CAST(NULL AS DECIMAL(20,6)) frozen_quantity,CAST(NULL AS DECIMAL(20,6)) transit_quantity,CAST(NULL AS DATETIME) observed_at_utc,"
+"CAST(NULL AS CHAR(16)) quality_status,CAST(NULL AS CHAR(64)) quality_reason,CAST(NULL AS DECIMAL(6,0)) product_quality) st ON 1=0) ";
}
return sql;
}
private static String filter(String kind){
if("HOT".equals(kind))return " WHERE window_quantity>0 ";
if("SLOW".equals(kind))return " WHERE window_quantity IS NOT NULL AND inventory_quality='VALID' AND available_quantity>0 "
+"AND evidence_at_utc<=CONVERT_TZ(CAST(TIMESTAMPADD(DAY,-30,#{p.anchor}) AS DATETIME),'+08:00','+00:00') ";
return "";
}
private static String order(String kind){
if("HOT".equals(kind))return "window_quantity DESC,window_sku_order_count DESC,sku_code ASC,customer_code ASC";
if("SLOW".equals(kind))return "window_quantity ASC,available_quantity DESC,sku_code ASC,customer_code ASC";
if("SUMMARY".equals(kind))return "observed_quantity IS NULL,observed_quantity DESC,sku_code ASC,customer_code ASC";
return "sku_code ASC,customer_code ASC";
}
public static String page(java.util.Map<String,Object> parameters){
var p=(AnalyticsSqlRequest)parameters.get("p");
return typed(cte(p.kind())+"SELECT customer_code,sku_code,product_name,quantity_unit,evidence_at_utc,unit_valid,observed_quantity,sku_order_count,"
+"complete_day_count,partial_day_count,unknown_day_count,window_quantity,window_sku_order_count,sales30_quantity,today_quantity,"
+"physical_quantity,available_quantity,frozen_quantity,transit_quantity,inventory_observed_at_utc,inventory_quality,inventory_reason "
+"FROM result"+filter(p.kind()).stripTrailing()+" ORDER BY "+order(p.kind())+" LIMIT #{p.size} OFFSET #{p.offset}",p);
}
public static String count(java.util.Map<String,Object> parameters){
var p=(AnalyticsSqlRequest)parameters.get("p");
// Candidate totals do not scan daily rows for normal lists.
if(!"HOT".equals(p.kind())&&!"SLOW".equals(p.kind()))return typed("SELECT COUNT(*) FROM (SELECT customer_code,sku_code FROM ll_selfop_sku_profile "
+"WHERE profile_run_id=#{p.salesRun} AND"+KEY_FILTER+" UNION SELECT customer_code,sku_code FROM ll_selfop_sku_profile "
+"WHERE profile_run_id=#{p.inventoryRun} AND"+KEY_FILTER+") candidate_keys",p);
return typed(cte(p.kind())+"SELECT LEAST(COUNT(*),100) FROM result"+filter(p.kind()),p);
}
public static String excluded(java.util.Map<String,Object> parameters){
var p=(AnalyticsSqlRequest)parameters.get("p");
String reason="HOT".equals(p.kind())?"CASE WHEN unit_valid=0 THEN 'UNIT_UNKNOWN_OR_CONFLICT' WHEN window_quantity IS NULL THEN 'SALES_WINDOW_UNAVAILABLE' ELSE 'ZERO_OUTBOUND' END":
"CASE WHEN inventory_quality<>'VALID' THEN CONCAT('INVENTORY_',inventory_quality) WHEN available_quantity<=0 THEN 'NO_AVAILABLE_INVENTORY' "
+"WHEN window_quantity IS NULL THEN 'SALES_WINDOW_UNAVAILABLE' ELSE 'OBSERVATION_INSUFFICIENT' END";
return typed(cte(p.kind())+"SELECT "+reason+" reason,COUNT(*) total FROM result WHERE NOT ("+filter(p.kind()).replace(" WHERE ","").strip()+") OR window_quantity IS NULL"
+("SLOW".equals(p.kind())?" OR evidence_at_utc IS NULL":"")+" GROUP BY reason",p);
}
private static String typed(String sql,AnalyticsSqlRequest p){
String filters=" source_account=#{p.account} AND warehouse_code=#{p.warehouse} ";
if(p.customer()!=null)filters+="AND customer_code=#{p.customer} ";
if(p.sku()!=null)filters+="AND sku_code=#{p.sku} ";
sql=sql.replace(KEY_FILTER,filters);
// Explicit expression types preserve NULL semantics and avoid inference from bare JDBC parameters.
for(String key:java.util.List.of("from","until","anchor","windowFrom","completeFrom","completeUntil"))
sql=sql.replace("#{p."+key+"}","CAST(#{p."+key+"} AS DATE)");
for(String key:java.util.List.of("coveredFrom","coveredUntil"))
sql=sql.replace("#{p."+key+"}","CAST(#{p."+key+"} AS DATETIME)");
return sql;
}
}
@@ -0,0 +1,41 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
public final class AnalyticsResponses {
private AnalyticsResponses(){}
public record Context(Long salesRunId,Long inventoryRunId,String salesScopeHash,String inventoryScopeHash,
Instant queryAsOf,Instant queryExpiresAt,String businessTimezone,LocalDate anchorDate,
LocalDate rangeStartDate,LocalDate rangeEndDateExclusive,Integer calculationWindowDays,
LocalDate completeDayFrom,LocalDate completeDayUntil,Instant salesCoveredFrom,Instant salesCoveredUntil,
Instant salesPublishedAt,Instant inventoryObservedFrom,Instant inventoryObservedUntil,
String salesStatus,String inventoryStatus,boolean rebuildRequired,String revisionReviewStatus,List<String> reasonCodes){}
public record Page<T>(Context context,List<T> records,int current,int size,long total,long pages,Map<String,Long> excludedReasons){}
public record SalesSummary(String sourceAccount,String warehouseCode,String customerCode,String sku,String productName,
String quantityUnit,BigDecimal outboundQuantity,BigDecimal observedQuantity,String dataStatus,List<String> reasonCodes,
int completeDayCount,int partialDayCount,int unknownDayCount){}
public record Point(LocalDate businessDate,BigDecimal outboundQuantity,BigDecimal observedQuantity,String dataStatus,
List<String> reasonCodes,String quantityUnit,Instant coveredFrom,Instant coveredUntil){}
public record Trend(Context context,SalesSummary summary,List<Point> points){}
public record Metric(String sourceAccount,String customerCode,String warehouseCode,String sku,String productName,
String quantityUnit,BigDecimal outboundQuantity,Long skuOutboundOrderCount,BigDecimal todayObservedQuantity,
BigDecimal physicalQuantity,BigDecimal availableQuantity,BigDecimal frozenQuantity,BigDecimal transitQuantity,
Instant inventoryObservedAt,BigDecimal averageDailyOutboundQuantity,BigDecimal inventoryCoverageDays,
String inventoryQuality,String salesCoverage,boolean observationEligible,Instant earliestReliableEvidenceAt,
String status,List<String> reasonCodes,String customerName,BigDecimal defectivePhysicalQuantity,
BigDecimal outboundQuantity30d,LocalDate lastObservedOutboundDate,LocalDate observedHistoryStartDate,
List<String> attentionReasons,Map<String,String> quantityStatuses){}
public record OwnerOption(String customerCode,String customerName){}
public record QuantityTotal(BigDecimal overallTotal,BigDecimal knownSubtotal,long unknownSkuCount,String dataStatus,List<String> reasonCodes){}
public record RiskShare(Long matchedSkuCount,BigDecimal matchedPhysicalQuantity,BigDecimal physicalQuantityRatio,
Long knownMatchedSkuCount,BigDecimal knownMatchedPhysicalQuantity,long unassessableSkuCount,
BigDecimal unassessableKnownPhysicalQuantity,List<String> reasonCodes){}
public record OwnerOverview(Context context,String customerCode,String customerName,Long skuCount,QuantityTotal physicalSkuCount,
QuantityTotal physicalQuantity,QuantityTotal availableQuantity,QuantityTotal frozenQuantity,QuantityTotal defectivePhysicalQuantity,
QuantityTotal transitQuantity,QuantityTotal outboundQuantity30d,QuantityTotal averageDailyOutboundQuantity,
RiskShare noOutbound30d,RiskShare coverageAboveThreshold){}
}
@@ -0,0 +1,10 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** Bound parameters only, generated by the validated service context. */
public record AnalyticsSqlRequest(String account,String warehouse,String customer,String sku,long salesRun,long inventoryRun,
LocalDate from,LocalDate until,LocalDate anchor,LocalDate windowFrom,LocalDate completeFrom,LocalDate completeUntil,
LocalDateTime coveredFrom,LocalDateTime coveredUntil,boolean salesKnown,boolean formalSales,boolean completeWindow,
boolean complete30,boolean stockFresh,long offset,int size,String kind) {}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import java.time.LocalDate;
import java.time.Instant;
import org.springframework.format.annotation.DateTimeFormat;
/** Single canonical constructor supports Spring ModelAttribute binding. */
public record CollectionQuery(String sourceAccount, String warehouseCode, String dataFamily,
String taskType, String status, String taskNo,
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateFrom,
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateUntil,
Integer current, Integer size,
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant listUpperCreatedAt,
Long listUpperId) {}
@@ -0,0 +1,350 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopAnalyticsProperties;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.time.*;
import java.util.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.support.CronExpression;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import static com.baiyue.lastleg.selfoperated.analytics.query.CollectionReadRows.*;
@Service
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public class CollectionQueryService {
private final CollectionReadMapper mapper;
private final SelfopAnalyticsProperties config;
private final ObjectMapper json;
private final Clock clock;
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
private static final Set<String> TERMINAL = Set.of("ROUTED","PUBLISHED","REUSED","YIELDED","FAILED","ABORTED","SAMPLE_DONE");
private static final Set<String> PROGRESS_FIELDS = Set.of("progressScope","countBasis","attemptNo","scanRound","phase",
"completed","total","totalKnown","unit","sliceFrom","sliceUntil","stageStartedAt","stageFinishedAt",
"validationState","invalidCount","unknownCount","pendingReviewCount","countUnit","summaryAsOf","directoryComplete");
private static final Set<String> COUNTS = Set.of("completed","total","invalidCount","unknownCount","pendingReviewCount","attemptNo","scanRound");
private static final Set<String> UNITS = Set.of("PAGE","ORDER","INVENTORY_ROW","BUSINESS_KEY","SKU","BATCH","NONE");
@Autowired
public CollectionQueryService(CollectionReadMapper mapper, SelfopAnalyticsProperties config, ObjectMapper json) {
this(mapper, config, json, Clock.systemUTC());
}
CollectionQueryService(CollectionReadMapper mapper, SelfopAnalyticsProperties config, ObjectMapper json, Clock clock) {
this.mapper=mapper; this.config=config; this.json=json; this.clock=clock;
}
private record Context(Map<Long,Task> tasks,Map<Long,Attempt> latest,Map<Long,Attempt> published) {}
private Context context(String account,List<Task> base,List<Long> extraPublished) {
Map<Long,Task> tasks=new HashMap<>();for(Task t:base)tasks.put(t.id(),t);
Set<Long> related=new HashSet<>();
for(Task t:base){if(t.validationRootTaskId()!=null)related.add(t.validationRootTaskId());if(t.upstreamTaskId()!=null)related.add(t.upstreamTaskId());}
related.removeAll(tasks.keySet());
if(!related.isEmpty())for(Task t:mapper.tasksByIds(account,new ArrayList<>(related)))tasks.put(t.id(),t);
Map<Long,Attempt> latest=new HashMap<>();
if(!tasks.isEmpty())for(Attempt a:mapper.latestAttempts(account,new ArrayList<>(tasks.keySet())))latest.put(a.taskId(),a);
Set<Long> versions=new HashSet<>(extraPublished);versions.remove(null);
for(Task t:tasks.values())if(t.resultPublishedRunId()!=null)versions.add(t.resultPublishedRunId());
Map<Long,Attempt> published=new HashMap<>();
if(!versions.isEmpty())for(Attempt a:mapper.publishedRuns(account,new ArrayList<>(versions)))published.put(a.id(),a);
return new Context(tasks,latest,published);
}
public CollectionResponses.Overview overview(CollectionQuery query) {
validate(query, false);
List<Scope> scopes=mapper.scopes(query.sourceAccount(),query.warehouseCode());
Map<String,Scope> persisted=new HashMap<>();
for(Scope scope:scopes) persisted.put(scope.warehouseCode()+":"+scope.dataFamily(),scope);
var rows=new ArrayList<CollectionResponses.Warehouse>();
var protection=protection(query.sourceAccount());
Map<String,CollectionResponses.InventoryDailySamples> inventorySamples=inventorySamples(query);
List<Long> scopeIds=scopes.stream().filter(s->config.getWarehouses().containsKey(s.warehouseCode())).map(Scope::id).toList();
List<Task> currentTasks=scopeIds.isEmpty()?List.of():mapper.currentTasks(query.sourceAccount(),scopeIds);
List<Task> successes=scopeIds.isEmpty()?List.of():mapper.lastSuccessfulTasks(query.sourceAccount(),scopeIds);
Task active=mapper.sharedActiveAccount(query.sourceAccount());
List<Task> all=new ArrayList<>(currentTasks);all.addAll(successes);if(active!=null)all.add(active);
Context context=context(query.sourceAccount(),all,scopes.stream().map(Scope::publishedRunId).filter(Objects::nonNull).toList());
Set<String> barriers=config.getWarehouses().isEmpty()?Set.of():new HashSet<>(mapper.barrierWarehouses(query.sourceAccount(),new ArrayList<>(config.getWarehouses().keySet())));
for(String warehouse:new TreeSet<>(config.getWarehouses().keySet())) {
if(query.warehouseCode()!=null&&!warehouse.equals(query.warehouseCode())) continue;
for(String family:List.of("OUTBOUND","INVENTORY")) {
if(query.dataFamily()!=null&&!family.equals(query.dataFamily()))continue;
Scope scope=persisted.get(warehouse+":"+family);
Task current=currentTasks.stream().filter(t->warehouse.equals(t.warehouseCode())&&family.equals(t.dataFamily())).findFirst().orElse(null);
Task last=successes.stream().filter(t->warehouse.equals(t.warehouseCode())&&family.equals(t.dataFamily())).findFirst().orElse(null);
Attempt published=scope==null?null:context.published().get(scope.publishedRunId());
if(published!=null&&(!warehouse.equals(published.warehouseCode())||!Objects.equals(scope.id(),published.scopeId())
||!Objects.equals(scope.scopeHash(),published.scopeHash())))published=null;
Task shared="OUTBOUND".equals(family)&&sharedWindowRelated(active,scope)?active:null;
String blocked=executionBlock(warehouse,protection);
boolean enabled=blocked==null;
boolean publication="BUSINESS".equals(config.getCollectionMode())&&"production".equals(config.getEnvironment())
&&("INVENTORY".equals(family)||config.isSalesPublicationEnabled());
String initial="INVENTORY".equals(family)?published!=null&&"COMPLETE".equals(published.coverageStatus())?"COMPLETE":"UNKNOWN"
:scope==null||scope.initialFromUtc()==null||scope.initialUntilUtc()==null?"UNKNOWN"
:published!=null&&"COMPLETE".equals(published.coverageStatus())&&published.coveredFromUtc()!=null&&published.coveredUntilUtc()!=null
&&!published.coveredFromUtc().isAfter(scope.initialFromUtc())&&!published.coveredUntilUtc().isBefore(scope.initialUntilUtc())
&&!Integer.valueOf(1).equals(scope.rebuildRequired())&&!barriers.contains(warehouse)?"COMPLETE":"PARTIAL";
Instant observation=published==null?null:utc("INVENTORY".equals(family)?published.fetchFinishedAtUtc():published.coveredUntilUtc());
String freshness=published==null?"NOT_COLLECTED":scope.staleAfterSeconds()==null||scope.staleAfterSeconds()<=0?"UNKNOWN"
:observation==null?"UNKNOWN":!clock.instant().isBefore(observation.plusSeconds(scope.staleAfterSeconds()))?"STALE":"FRESH";
rows.add(new CollectionResponses.Warehouse(warehouse,warehouse,family,enabled,publication,blocked,
enabled?nextSchedule(warehouse,family):null,clock.instant(),scope==null?null:utc(scope.initialFromUtc()),
scope==null?null:utc(scope.initialUntilUtc()),initial,published==null?null:utc(published.coveredFromUtc()),
published==null?null:utc(published.coveredUntilUtc()),last==null?null:last.taskNo(),last==null||context.latest().get(last.id())==null?null:utc(context.latest().get(last.id()).finishedAtUtc()),
published==null?null:utc(published.publishedAtUtc()),"INVENTORY".equals(family)&&published!=null?utc(published.fetchStartedAtUtc()):null,
"INVENTORY".equals(family)&&published!=null?utc(published.fetchFinishedAtUtc()):null,freshness,
"OUTBOUND".equals(family)&&scope!=null&&(Integer.valueOf(1).equals(scope.rebuildRequired())||barriers.contains(warehouse)),reviewStatus(scope),
current==null?null:taskView(current,scope,protection,context),shared==null?null:taskView(shared,null,protection,context),protection,
"INVENTORY".equals(family)?inventorySamples.get(warehouse):null));
}
}
return new CollectionResponses.Overview(query.sourceAccount(),config.getEnvironment(),config.getCollectionMode(),clock.instant(),rows);
}
private Map<String,CollectionResponses.InventoryDailySamples> inventorySamples(CollectionQuery query) {
if("OUTBOUND".equals(query.dataFamily()))return Map.of();
List<String> warehouses=config.getWarehouses().keySet().stream()
.filter(w->query.warehouseCode()==null||w.equals(query.warehouseCode())).sorted().toList();
if(warehouses.isEmpty())return Map.of();
LocalDate until=LocalDate.now(clock.withZone(BUSINESS_ZONE)),from=until.minusDays(30);
Map<String,Set<LocalDate>> dates=new HashMap<>();
for(InventorySample sample:mapper.inventoryDailySamples(query.sourceAccount(),warehouses,from,until)) {
if(sample.sampleDate()!=null&&!sample.sampleDate().isBefore(from)&&sample.sampleDate().isBefore(until))
dates.computeIfAbsent(sample.warehouseCode(),k->new HashSet<>()).add(sample.sampleDate());
}
Map<String,LocalDate> starts=new HashMap<>();
for(InventorySample sample:mapper.inventoryAccumulationStarts(query.sourceAccount(),warehouses))
if(sample.sampleDate()!=null)starts.put(sample.warehouseCode(),sample.sampleDate());
Map<String,CollectionResponses.InventoryDailySamples> result=new HashMap<>();
for(String warehouse:warehouses) {
Set<LocalDate> observed=dates.getOrDefault(warehouse,Set.of());
List<LocalDate> missing=from.datesUntil(until).filter(d->!observed.contains(d)).toList();
result.put(warehouse,new CollectionResponses.InventoryDailySamples(from,until,30,30-missing.size(),missing,starts.get(warehouse)));
}
return result;
}
public CollectionResponses.Page<CollectionResponses.Task> tasks(CollectionQuery query) {
validate(query,true);
var f=filter(query); Bound bound=bound(query,false,f);
if(bound==null)return empty(query);
var scopes=scopeMap(query); var protection=protection(query.sourceAccount());
List<Task> tasks=mapper.tasks(f,bound);Context context=context(query.sourceAccount(),tasks,List.of());
var rows=tasks.stream().map(t->taskView(t,scopes.get(t.dataFamily()),protection,context)).toList();
return new CollectionResponses.Page<>(current(query),size(query),mapper.taskCount(f,bound),utc(bound.createdAt()),bound.id(),clock.instant(),rows);
}
public CollectionResponses.TaskDetail taskDetail(String taskNo,CollectionQuery query) {
validate(query,true);
if(taskNo==null||taskNo.isBlank()||taskNo.length()>64)throw new BizException("任务号无效");
Task task=mapper.task(query.sourceAccount(),query.warehouseCode(),taskNo);
if(task==null)throw new BizException("任务不存在或不属于所选仓库");
Scope scope=scopeMap(query).get(task.dataFamily());
if(scope==null||(query.dataFamily()!=null&&!query.dataFamily().equals(task.dataFamily())))throw new BizException("任务范围与所选仓库不一致");
Context context=context(query.sourceAccount(),List.of(task),List.of());
Task shared=relatedUpstream(task,context);
var protection=protection(query.sourceAccount());
return new CollectionResponses.TaskDetail(taskView(task,scope,protection,context),shared==null?null:taskView(shared,null,protection,context),protection,clock.instant());
}
public CollectionResponses.Page<CollectionResponses.Attempt> runs(CollectionQuery query) {
validate(query,true);
var f=filter(query); Bound bound=bound(query,true,f);
if(bound==null)return empty(query);
List<Attempt> runs=mapper.runs(f,bound);
Map<Long,Task> tasks=new HashMap<>();List<Long> taskIds=runs.stream().map(Attempt::taskId).distinct().toList();
if(!taskIds.isEmpty())for(Task t:mapper.tasksByIds(query.sourceAccount(),taskIds))tasks.put(t.id(),t);
Map<String,Scope> scopes=scopeMap(query);var protection=protection(query.sourceAccount());
Context context=context(query.sourceAccount(),new ArrayList<>(tasks.values()),List.of());
var rows=runs.stream().map(r->{Task t=tasks.get(r.taskId());return attemptView(r,t,
t!=null&&retryBlock(t,scopes.get(t.dataFamily()),protection,context)==null);}).toList();
return new CollectionResponses.Page<>(current(query),size(query),mapper.runCount(f,bound),utc(bound.createdAt()),bound.id(),clock.instant(),rows);
}
private <T> CollectionResponses.Page<T> empty(CollectionQuery q) {
// A zero id freezes an empty first page; later inserts cannot enter it.
return new CollectionResponses.Page<>(current(q),size(q),0,clock.instant(),0L,clock.instant(),List.of());
}
private Map<String,Scope> scopeMap(CollectionQuery q) {
Map<String,Scope> scopes=new HashMap<>();
for(Scope scope:mapper.scopes(q.sourceAccount(),q.warehouseCode()))scopes.put(scope.dataFamily(),scope);
return scopes;
}
private boolean sharedWindowRelated(Task active,Scope scope) {
if(active==null||scope==null||active.queryFromUtc()==null||active.queryUntilUtc()==null)return false;
return overlaps(active.queryFromUtc(),active.queryUntilUtc(),scope.initialFromUtc(),scope.initialUntilUtc())
||overlaps(active.queryFromUtc(),active.queryUntilUtc(),scope.discoveryFromUtc(),scope.discoveryUntilUtc());
}
private static boolean overlaps(LocalDateTime from,LocalDateTime until,LocalDateTime otherFrom,LocalDateTime otherUntil) {
return otherFrom!=null&&otherUntil!=null&&from.isBefore(otherUntil)&&until.isAfter(otherFrom);
}
private String reviewStatus(Scope scope) {
if(scope==null||scope.reviewTargetDays()==null||scope.reviewTargetDays()<1)return "UNKNOWN";
if(scope.knownOrderRoundFinishedAtUtc()==null||scope.historyRoundFinishedAtUtc()==null)return "PENDING";
Instant deadline=clock.instant().minusSeconds(scope.reviewTargetDays()*86400L);
return utc(scope.knownOrderRoundFinishedAtUtc()).isBefore(deadline)||utc(scope.historyRoundFinishedAtUtc()).isBefore(deadline)?"OVERDUE":"REVIEWED";
}
private Task relatedUpstream(Task task,Context context) {
if(task.upstreamTaskId()==null)return null;
Task parent=context.tasks().get(task.upstreamTaskId());
return parent!=null&&"ACCOUNT_DISCOVERY".equals(parent.taskRole())&&Objects.equals(task.sourceAccount(),parent.sourceAccount())?parent:null;
}
private CollectionResponses.Task taskView(Task t,Scope scope,CollectionResponses.Protection protection,Context context) {
Attempt latest=context.latest().get(t.id());
String blocked=retryBlock(t,scope,protection,context);
Task root=t.validationRootTaskId()==null?null:context.tasks().get(t.validationRootTaskId());
Attempt result=context.published().get(t.resultPublishedRunId());
String resultState=t.resultPublishedRunId()==null?"NOT_PUBLISHED":result==null||scope==null||!Objects.equals(scope.id(),result.scopeId())
||!Objects.equals(t.warehouseCode(),result.warehouseCode())||!Objects.equals(t.scopeHash(),result.scopeHash())?"RESULT_UNAVAILABLE"
:latest!=null&&"REUSED".equals(latest.status())?"REUSED":latest!=null&&"PUBLISHED".equals(latest.status())?"PUBLISHED":"RESULT_AVAILABLE";
return new CollectionResponses.Task(t.taskNo(),t.taskType(),t.dataFamily(),t.warehouseCode(),t.taskRole(),t.executionPurpose(),
utc(t.queryFromUtc()),utc(t.queryUntilUtc()),t.status(),t.applyState(),t.attemptCount(),latest==null?null:latest.attemptNo(),
resultState,t.resultPublishedRunId(),utc(t.nextRetryAtUtc()),
blocked==null?List.of("RETRY"):List.of(),blocked,utc(t.progressUpdatedAtUtc()),progress(t.progressJson()),
validation(root==null?t:root),latest==null?null:attemptView(latest,t,blocked==null));
}
private String retryBlock(Task task,Scope scope,CollectionResponses.Protection protection,Context context) {
if(!"WAREHOUSE_SYNC".equals(task.taskRole()))return "ACCOUNT_SHARED_TASK";
if(!"BUSINESS".equals(task.executionPurpose()))return "VALIDATION_NOT_RETRYABLE";
String disabled=executionBlock(task.warehouseCode(),protection);if(disabled!=null)return disabled;
if(!"BUSINESS".equals(config.getCollectionMode()))return "COLLECTION_MODE_NOT_BUSINESS";
if(!Set.of("DEAD","RETRY_WAIT").contains(task.status()))return "TASK_NOT_RETRYABLE";
if(scope==null||!Objects.equals(scope.scopeHash(),task.scopeHash()))return "SCOPE_CHANGED";
if(scope.leaseOwner()!=null&&scope.leaseUntilUtc()!=null&&clock.instant().isBefore(utc(scope.leaseUntilUtc())))return "SCOPE_LEASE_BUSY";
Task parent=relatedUpstream(task,context);
if(task.upstreamTaskId()!=null&&(parent==null||!"SUCCEEDED".equals(parent.status())))return "UPSTREAM_NOT_READY";
return null;
}
private CollectionResponses.Attempt attemptView(Attempt run,Task task,boolean retryAllowed) {
boolean terminal=TERMINAL.contains(run.status());
boolean same=task!=null&&Objects.equals(run.attemptNo(),task.attemptCount());
Map<String,Object> progress=progress(terminal?run.progressSnapshotJson():same?task.progressJson():null);
Instant updated=terminal?progressInstant(progress,"summaryAsOf"):same?utc(task.progressUpdatedAtUtc()):null;
Instant started=utc(run.fetchStartedAtUtc()),finished=utc(run.finishedAtUtc()),heartbeat=utc(run.heartbeatAtUtc());
String health=terminal?"TERMINATED":heartbeat==null||!clock.instant().isBefore(heartbeat.plusSeconds(config.getLeaseSeconds()))?"UNCONFIRMED":"HEALTHY";
Long elapsed=started==null?null:Math.max(0,Duration.between(started,finished==null?clock.instant():finished).getSeconds());
return new CollectionResponses.Attempt(run.id(),run.taskNo(),run.attemptNo(),run.status(),run.lastPhase(),run.executionPurpose(),
started,finished,progressInstant(progress,"stageStartedAt"),updated,heartbeat,health,elapsed,run.errorCode(),safeMessage(run.errorMessage()),
retryAllowed&&same&&"BUSINESS".equals(run.executionPurpose())&&Set.of("FAILED","ABORTED","YIELDED").contains(run.status()),
run.coverageStatus(),utc(run.coveredFromUtc()),utc(run.coveredUntilUtc()),utc(run.publishedAtUtc()),progress);
}
private String safeMessage(String error) {
if(error==null)return null;
// Errors are already sanitized by the write side; avoid exposing arbitrary payloads here.
return "采集尝试失败,请根据错误码查看受控诊断记录";
}
private Instant progressInstant(Map<String,Object> progress,String field) {
Object value=progress.get(field);
try{return value instanceof String text?Instant.parse(text):null;}catch(DateTimeException error){return null;}
}
private CollectionResponses.Protection protection(String account) {
var p=mapper.protection(account);
JsonNode budget=p==null?null:boundedObject(p.apiBudgetJson());
return new CollectionResponses.Protection(p==null?null:utc(p.apiCooldownUntilUtc()),p==null?null:p.apiPauseReason(),
config.getDailyRequestBudget(),number(budget,"reservedHttp"),number(budget,"confirmedHttp"),
budget==null||!budget.path("budgetDate").isTextual()?null:budget.path("budgetDate").textValue());
}
private String executionBlock(String warehouse,CollectionResponses.Protection protection) {
if("OFF".equals(config.getCollectionMode()))return "COLLECTION_OFF";
if(!Set.of("BUSINESS","VALIDATION").contains(config.getCollectionMode()))return "COLLECTION_MODE_INVALID";
if("BUSINESS".equals(config.getCollectionMode())&&!"production".equals(config.getEnvironment()))return "BUSINESS_ENVIRONMENT_REJECTED";
if(!config.isExecutionEnabled())return "EXECUTION_DISABLED";
if(!config.getWarehouses().containsKey(warehouse))return "WAREHOUSE_NOT_CONFIGURED";
var w=config.getWarehouses().get(warehouse);
if(!"Asia/Shanghai".equals(w.getBusinessTimezone())||!"Asia/Shanghai".equals(w.getSourceTimezone())||!"PCS".equals(config.getQuantityUnit()))return "TIMEZONE_OR_UNIT_NOT_CONFIRMED";
if(protection.accountPauseReason()!=null&&!protection.accountPauseReason().isBlank())return "ACCOUNT_PAUSED";
if(protection.accountCooldownUntil()!=null&&clock.instant().isBefore(protection.accountCooldownUntil()))return "ACCOUNT_COOLDOWN";
if(!config.isExclusiveHandoffVerified()||!config.isPriorEgressClosed()||!config.isInflightDrained()||!config.isTransferredBudgetVerified()
||config.getExclusiveGrantExpiresAtUtc()==null||!clock.instant().isBefore(utc(config.getExclusiveGrantExpiresAtUtc())))return "EXCLUSIVE_GRANT_NOT_VALID";
if(config.getDailyRequestBudget()<=0)return "DAILY_BUDGET_NOT_CONFIGURED";
if(protection.reservedHttpRequests()!=null&&clock.instant().atZone(BUSINESS_ZONE).toLocalDate().toString().equals(protection.budgetDate())
&&protection.reservedHttpRequests()>=config.getDailyRequestBudget())return "DAILY_BUDGET_EXHAUSTED";
return null;
}
private Instant nextSchedule(String warehouse,String family) {
var w=config.getWarehouses().get(warehouse);
try {var next=CronExpression.parse("OUTBOUND".equals(family)?w.getOutboundCron():w.getInventoryCron()).next(clock.instant().atZone(BUSINESS_ZONE));return next==null?null:next.toInstant();}
catch(IllegalArgumentException ex){return null;}
}
private CollectionReadMapper.Filter filter(CollectionQuery q) {
long offset=(long)(current(q)-1)*size(q);if(offset>Integer.MAX_VALUE)throw new BizException("分页范围过大");
return new CollectionReadMapper.Filter(q.sourceAccount(),q.warehouseCode(),q.dataFamily(),q.taskType(),q.status(),q.taskNo(),
q.dateFrom()==null?null:LocalDateTime.ofInstant(q.dateFrom().atStartOfDay(BUSINESS_ZONE).toInstant(),ZoneOffset.UTC),
q.dateUntil()==null?null:LocalDateTime.ofInstant(q.dateUntil().plusDays(1).atStartOfDay(BUSINESS_ZONE).toInstant(),ZoneOffset.UTC),
(int)offset,size(q));
}
private Bound bound(CollectionQuery q,boolean run,CollectionReadMapper.Filter f) {
if(q.listUpperId()!=null)return new Bound(LocalDateTime.ofInstant(q.listUpperCreatedAt(),ZoneOffset.UTC),q.listUpperId());
return run?mapper.runBound(f):mapper.taskBound(f);
}
private void validate(CollectionQuery q,boolean warehouseRequired) {
if(q==null||q.sourceAccount()==null||q.sourceAccount().isBlank()||!q.sourceAccount().equals(config.getSourceAccount()))throw new BizException("来源账号未配置或无权访问");
if(warehouseRequired&&(q.warehouseCode()==null||q.warehouseCode().isBlank()))throw new BizException("必须选择仓库");
if(q.warehouseCode()!=null&&!config.getWarehouses().containsKey(q.warehouseCode()))throw new BizException("仓库不在配置范围内");
if(q.dataFamily()!=null&&!Set.of("OUTBOUND","INVENTORY").contains(q.dataFamily()))throw new BizException("任务族无效");
if(current(q)<1||size(q)<1||size(q)>100)throw new BizException("分页参数无效");
if((q.listUpperCreatedAt()==null)!=(q.listUpperId()==null)||q.listUpperId()!=null&&q.listUpperId()<0)throw new BizException("分页快照上界无效");
if(current(q)>1&&q.listUpperId()==null)throw new BizException("翻页必须携带首屏快照上界");
if(q.dateFrom()!=null&&q.dateUntil()!=null&&q.dateUntil().isBefore(q.dateFrom()))throw new BizException("创建日期范围倒置");
if(q.taskNo()!=null&&(q.taskNo().isBlank()||q.taskNo().length()>64))throw new BizException("任务号无效");
}
private static int current(CollectionQuery q){return q.current()==null?1:q.current();}
private static int size(CollectionQuery q){return q.size()==null?20:q.size();}
private static Instant utc(LocalDateTime t){return t==null?null:t.toInstant(ZoneOffset.UTC);}
/** Unknown/invalid quantities stay null; arbitrary raw payload fields never pass through. */
Map<String,Object> progress(String source) {
JsonNode root=boundedObject(source);if(root==null)return Map.of();
Map<String,Object> result=projectProgress(root);
if(root.path("stages").isArray()) {
List<Map<String,Object>> stages=new ArrayList<>();
if(root.path("stages").size()>16)return Map.of();
root.path("stages").forEach(stage->{if(stage.isObject())stages.add(projectProgress(stage));});
result.put("stages",stages);
}
return Collections.unmodifiableMap(result);
}
private Map<String,Object> projectProgress(JsonNode node) {
Map<String,Object> result=new LinkedHashMap<>();
for(String field:PROGRESS_FIELDS) {
JsonNode value=node.get(field);if(value==null)continue;
if(COUNTS.contains(field))result.put(field,value.isIntegralNumber()&&value.canConvertToLong()&&value.longValue()>=0?value.longValue():null);
else if(Set.of("totalKnown","directoryComplete").contains(field))result.put(field,value.isBoolean()?value.booleanValue():null);
else if(value.isTextual()&&value.textValue().length()<=128)result.put(field,value.textValue());
}
if(result.containsKey("unit")&&!UNITS.contains(result.get("unit")))result.put("unit",null);
if(result.containsKey("countUnit")&&!UNITS.contains(result.get("countUnit")))result.put("countUnit",null);
if(result.containsKey("countBasis")&&!Set.of("TASK_CUMULATIVE","ATTEMPT_ONLY").contains(result.get("countBasis")))result.put("countBasis",null);
if(result.containsKey("progressScope")&&!Set.of("ACCOUNT_SHARED","WAREHOUSE").contains(result.get("progressScope")))result.put("progressScope",null);
if(!Boolean.TRUE.equals(result.get("totalKnown")))result.put("total",null);
if("NONE".equals(result.get("unit"))) {
result.put("completed",null);result.put("total",null);result.put("totalKnown",false);
}
return result;
}
private JsonNode boundedObject(String value) {
if(value==null||value.getBytes(StandardCharsets.UTF_8).length>8192)return null;
try {JsonNode node=json.readTree(value);return node!=null&&node.isObject()?node:null;}catch(Exception ex){return null;}
}
private CollectionResponses.Validation validation(Task root) {
if(!"VALIDATION".equals(root.executionPurpose()))return null;
JsonNode budget=boundedObject(root.validationBudgetJson());
Map<String,Object> limits=new LinkedHashMap<>(),consumed=new LinkedHashMap<>();
for(String key:List.of("maxPages","maxPageSize","maxDistinctDetails","maxDetailHttp","maxInventoryPagesPerWarehouse","maxHttp"))limits.put(key,number(budget,key));
for(String key:List.of("pages","detailHttp","http","confirmedHttp"))consumed.put(key,number(budget,key));
consumed.put("distinctDetails",budget==null||!budget.path("detailKeys").isArray()?null:(long)budget.path("detailKeys").size());
// detailKeys themselves contain upstream identities and are never exposed.
return new CollectionResponses.Validation(root.taskNo(),Collections.unmodifiableMap(limits),Collections.unmodifiableMap(consumed),
budgetInstant(budget,"deadlineAtUtc"),budget==null?null:fixedReason(budget.path("stopReason")));
}
private String fixedReason(JsonNode value) {
if(value==null||value.isNull()||!value.isTextual())return null;
return value.textValue().matches("[A-Z0-9_]{1,64}")?value.textValue():"STOP_REASON_UNAVAILABLE";
}
private Long number(JsonNode root,String field) {
JsonNode value=root==null?null:root.get(field);
return value!=null&&value.isIntegralNumber()&&value.canConvertToLong()&&value.longValue()>=0?value.longValue():null;
}
private Instant budgetInstant(JsonNode root,String field) {
try{return root==null||!root.path(field).isTextual()?null:utc(LocalDateTime.parse(root.path(field).textValue()));}catch(DateTimeException ex){return null;}
}
}
@@ -0,0 +1,115 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import java.time.LocalDateTime;
import java.time.LocalDate;
import java.util.List;
import org.apache.ibatis.annotations.*;
import static com.baiyue.lastleg.selfoperated.analytics.query.CollectionReadRows.*;
@Mapper
public interface CollectionReadMapper {
String DAILY_SAMPLE_FROM = " FROM ll_selfop_sync_run r JOIN ll_selfop_sync_scope s ON s.id=r.scope_id"
+ " AND s.source_account=r.source_account AND s.warehouse_code=r.warehouse_code AND s.data_family='INVENTORY'"
+ " WHERE r.source_account=#{account} AND r.status='PUBLISHED' AND r.execution_purpose='BUSINESS'"
+ " AND r.run_role='WAREHOUSE_SYNC' AND r.coverage_status='COMPLETE'"
+ " AND r.inventory_sample_rule_version='DAILY_SCHEDULED_FIRST_V1'"
+ " AND r.warehouse_code IN <foreach collection='warehouses' item='w' open='(' close=')' separator=','>#{w}</foreach>";
@Select("<script>SELECT r.warehouse_code,r.inventory_daily_sample_date AS sample_date" + DAILY_SAMPLE_FROM
+ " AND r.inventory_daily_sample_date &gt;= #{from} AND r.inventory_daily_sample_date &lt; #{until}"
+ " ORDER BY r.warehouse_code,r.inventory_daily_sample_date</script>")
List<InventorySample> inventoryDailySamples(@Param("account") String account,
@Param("warehouses") List<String> warehouses, @Param("from") LocalDate from, @Param("until") LocalDate until);
@Select("<script>SELECT r.warehouse_code,MIN(r.inventory_daily_sample_date) AS sample_date" + DAILY_SAMPLE_FROM
+ " AND r.inventory_daily_sample_date IS NOT NULL GROUP BY r.warehouse_code</script>")
List<InventorySample> inventoryAccumulationStarts(@Param("account") String account,
@Param("warehouses") List<String> warehouses);
record Filter(String account, String warehouse, String family, String type, String status,
String taskNo, LocalDateTime from, LocalDateTime until, int offset, int size) {}
String SCOPE = "id,warehouse_code,data_family,scope_hash,business_timezone,schedule_cron,stale_after_seconds,"
+ "initial_from_utc,initial_until_utc,discovery_from_utc,discovery_until_utc,rebuild_required,published_run_id,"
+ "known_order_round_finished_at_utc,history_round_finished_at_utc,review_target_days,lease_owner,lease_until_utc";
String TASK = "t.id,t.task_no,t.source_account,t.warehouse_code,s.data_family,t.task_role,t.task_type,"
+ "t.execution_purpose,t.scope_hash,t.status,t.apply_state,t.attempt_count,t.query_from_utc,t.query_until_utc,"
+ "t.next_retry_at_utc,t.upstream_task_id,t.validation_root_task_id,t.result_published_run_id,"
+ "t.progress_json,t.progress_updated_at_utc,t.validation_budget_json,t.created_at,t.updated_at";
String ATTEMPT = "r.id,r.task_id,t.task_no,r.attempt_no,r.status,r.last_phase,r.execution_purpose,"
+ "r.fetch_started_at_utc,r.fetch_finished_at_utc,r.build_started_at_utc,r.finished_at_utc,"
+ "r.published_at_utc,r.heartbeat_at_utc,r.progress_snapshot_json,r.coverage_status,"
+ "r.covered_from_utc,r.covered_until_utc,r.error_code,r.error_message,r.created_at,r.warehouse_code,r.scope_id,r.scope_hash";
String TASK_FROM = " FROM ll_selfop_sync_task t JOIN ll_selfop_sync_scope s ON s.id=t.scope_id "
+ "AND s.source_account=t.source_account AND s.warehouse_code=t.warehouse_code ";
String RUN_FROM = " FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id "
+ "AND t.source_account=r.source_account AND t.warehouse_code=r.warehouse_code "
+ "JOIN ll_selfop_sync_scope s ON s.id=r.scope_id AND s.id=t.scope_id ";
String FILTER = " WHERE t.source_account=#{f.account} AND t.warehouse_code=#{f.warehouse} "
+ "AND t.task_role='WAREHOUSE_SYNC' "
+ "<if test='f.family != null'>AND s.data_family=#{f.family} </if>"
+ "<if test='f.type != null'>AND t.task_type=#{f.type} </if>"
+ "<if test='f.taskNo != null'>AND t.task_no=#{f.taskNo} </if>";
String TASK_FILTER = FILTER + "<if test='f.status != null'>AND t.status=#{f.status} </if>"
+ "<if test='f.from != null'>AND t.created_at &gt;= #{f.from} </if>"
+ "<if test='f.until != null'>AND t.created_at &lt; #{f.until} </if>";
String RUN_FILTER = FILTER + "<if test='f.status != null'>AND r.status=#{f.status} </if>"
+ "<if test='f.from != null'>AND r.created_at &gt;= #{f.from} </if>"
+ "<if test='f.until != null'>AND r.created_at &lt; #{f.until} </if>";
String TASK_BOUND = " AND (t.created_at &lt; #{b.createdAt} OR (t.created_at=#{b.createdAt} AND t.id &lt;= #{b.id})) ";
String RUN_BOUND = " AND (r.created_at &lt; #{b.createdAt} OR (r.created_at=#{b.createdAt} AND r.id &lt;= #{b.id})) ";
@Select("<script>SELECT " + SCOPE + " FROM ll_selfop_sync_scope WHERE source_account=#{account}"
+ "<if test='warehouse != null'> AND warehouse_code=#{warehouse}</if> ORDER BY warehouse_code,data_family</script>")
List<Scope> scopes(@Param("account") String account, @Param("warehouse") String warehouse);
@Select("<script>SELECT t.created_at,t.id" + TASK_FROM + TASK_FILTER + " ORDER BY t.created_at DESC,t.id DESC LIMIT 1</script>")
Bound taskBound(@Param("f") Filter filter);
@Select("<script>SELECT r.created_at,r.id" + RUN_FROM + RUN_FILTER + " ORDER BY r.created_at DESC,r.id DESC LIMIT 1</script>")
Bound runBound(@Param("f") Filter filter);
@Select("<script>SELECT COUNT(*)" + TASK_FROM + TASK_FILTER + TASK_BOUND + "</script>")
long taskCount(@Param("f") Filter filter, @Param("b") Bound bound);
@Select("<script>SELECT " + TASK + TASK_FROM + TASK_FILTER + TASK_BOUND
+ " ORDER BY t.created_at DESC,t.id DESC LIMIT #{f.size} OFFSET #{f.offset}</script>")
List<Task> tasks(@Param("f") Filter filter, @Param("b") Bound bound);
@Select("<script>SELECT COUNT(*)" + RUN_FROM + RUN_FILTER + RUN_BOUND + "</script>")
long runCount(@Param("f") Filter filter, @Param("b") Bound bound);
@Select("<script>SELECT " + ATTEMPT + RUN_FROM + RUN_FILTER + RUN_BOUND
+ " ORDER BY r.created_at DESC,r.id DESC LIMIT #{f.size} OFFSET #{f.offset}</script>")
List<Attempt> runs(@Param("f") Filter filter, @Param("b") Bound bound);
@Select("SELECT " + TASK + TASK_FROM + " WHERE t.source_account=#{account} AND t.warehouse_code=#{warehouse} AND t.task_no=#{taskNo}")
Task task(@Param("account") String account, @Param("warehouse") String warehouse, @Param("taskNo") String taskNo);
@Select("SELECT api_cooldown_until_utc,api_pause_reason,api_budget_json FROM ll_selfop_outbound_discovery_scope WHERE source_account=#{account} LIMIT 1")
Protection protection(@Param("account") String account);
@Select("<script>SELECT " + TASK + " FROM ll_selfop_sync_task t LEFT JOIN ll_selfop_sync_scope s ON s.id=t.scope_id"
+ " WHERE t.source_account=#{account} AND t.id IN <foreach collection='ids' item='id' open='(' close=')' separator=','>#{id}</foreach></script>")
List<Task> tasksByIds(@Param("account") String account,@Param("ids") List<Long> ids);
@Select("<script>SELECT " + ATTEMPT + " FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id"
+ " JOIN (SELECT id,ROW_NUMBER() OVER(PARTITION BY task_id ORDER BY attempt_no DESC,id DESC) rn FROM ll_selfop_sync_run"
+ " WHERE task_id IN <foreach collection='ids' item='id' open='(' close=')' separator=','>#{id}</foreach>) ranked"
+ " ON ranked.id=r.id AND ranked.rn=1 WHERE r.source_account=#{account}</script>")
List<Attempt> latestAttempts(@Param("account") String account,@Param("ids") List<Long> ids);
@Select("<script>SELECT " + ATTEMPT + " FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id"
+ " WHERE r.source_account=#{account} AND r.status='PUBLISHED' AND r.execution_purpose='BUSINESS' AND r.run_role='WAREHOUSE_SYNC'"
+ " AND r.id IN <foreach collection='ids' item='id' open='(' close=')' separator=','>#{id}</foreach></script>")
List<Attempt> publishedRuns(@Param("account") String account,@Param("ids") List<Long> ids);
@Select("<script>SELECT " + TASK + TASK_FROM
+ " JOIN (SELECT id,ROW_NUMBER() OVER(PARTITION BY scope_id ORDER BY created_at DESC,id DESC) rn FROM ll_selfop_sync_task"
+ " WHERE scope_id IN <foreach collection='ids' item='id' open='(' close=')' separator=','>#{id}</foreach>) ranked"
+ " ON ranked.id=t.id AND ranked.rn=1 WHERE t.source_account=#{account}</script>")
List<Task> currentTasks(@Param("account") String account,@Param("ids") List<Long> ids);
@Select("<script>SELECT " + TASK + TASK_FROM
+ " JOIN (SELECT id,ROW_NUMBER() OVER(PARTITION BY scope_id ORDER BY updated_at DESC,id DESC) rn FROM ll_selfop_sync_task"
+ " WHERE status='SUCCEEDED' AND execution_purpose='BUSINESS' AND result_published_run_id IS NOT NULL AND scope_id IN"
+ " <foreach collection='ids' item='id' open='(' close=')' separator=','>#{id}</foreach>) ranked"
+ " ON ranked.id=t.id AND ranked.rn=1 WHERE t.source_account=#{account}</script>")
List<Task> lastSuccessfulTasks(@Param("account") String account,@Param("ids") List<Long> ids);
// The one account active task is separately related to warehouses by actual persisted windows.
@Select("SELECT " + TASK + " FROM ll_selfop_outbound_discovery_scope d JOIN ll_selfop_sync_task t ON t.id=d.active_task_id"
+ " AND t.source_account=d.source_account LEFT JOIN ll_selfop_sync_scope s ON s.id=t.scope_id"
+ " WHERE d.source_account=#{account} AND t.task_role='ACCOUNT_DISCOVERY' LIMIT 1")
Task sharedActiveAccount(@Param("account") String account);
@Select("<script>SELECT DISTINCT s.warehouse_code FROM ll_selfop_sync_scope s WHERE s.source_account=#{account}"
+ " AND s.data_family='OUTBOUND' AND s.warehouse_code IN"
+ " <foreach collection='warehouses' item='w' open='(' close=')' separator=','>#{w}</foreach>"
+ " AND EXISTS (SELECT 1 FROM ll_selfop_sync_task t WHERE t.source_account=s.source_account"
+ " AND t.execution_purpose='BUSINESS' AND t.apply_state IN ('APPLYING','BLOCKED')"
+ " AND (t.warehouse_code=s.warehouse_code OR JSON_CONTAINS(t.affected_warehouse_codes_json,JSON_QUOTE(s.warehouse_code))))</script>")
List<String> barrierWarehouses(@Param("account") String account,@Param("warehouses") List<String> warehouses);
}
@@ -0,0 +1,34 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import java.time.LocalDateTime;
import java.time.LocalDate;
/** Narrow read projections: no raw manifests, stage payloads or business detail tables. */
public final class CollectionReadRows {
private CollectionReadRows() {}
public record Bound(LocalDateTime createdAt, Long id) {}
public record InventorySample(String warehouseCode, LocalDate sampleDate) {}
public record Scope(Long id, String warehouseCode, String dataFamily, String scopeHash,
String businessTimezone, String scheduleCron, Integer staleAfterSeconds,
LocalDateTime initialFromUtc, LocalDateTime initialUntilUtc,
LocalDateTime discoveryFromUtc, LocalDateTime discoveryUntilUtc,
Integer rebuildRequired, Long publishedRunId,
LocalDateTime knownOrderRoundFinishedAtUtc, LocalDateTime historyRoundFinishedAtUtc, Integer reviewTargetDays,
String leaseOwner, LocalDateTime leaseUntilUtc) {}
public record Task(Long id, String taskNo, String sourceAccount, String warehouseCode,
String dataFamily, String taskRole, String taskType, String executionPurpose,
String scopeHash, String status, String applyState, Integer attemptCount,
LocalDateTime queryFromUtc, LocalDateTime queryUntilUtc, LocalDateTime nextRetryAtUtc,
Long upstreamTaskId, Long validationRootTaskId, Long resultPublishedRunId,
String progressJson, LocalDateTime progressUpdatedAtUtc, String validationBudgetJson,
LocalDateTime createdAt, LocalDateTime updatedAt) {}
public record Attempt(Long id, Long taskId, String taskNo, Integer attemptNo,
String status, String lastPhase, String executionPurpose,
LocalDateTime fetchStartedAtUtc, LocalDateTime fetchFinishedAtUtc,
LocalDateTime buildStartedAtUtc, LocalDateTime finishedAtUtc,
LocalDateTime publishedAtUtc, LocalDateTime heartbeatAtUtc,
String progressSnapshotJson, String coverageStatus,
LocalDateTime coveredFromUtc, LocalDateTime coveredUntilUtc,
String errorCode, String errorMessage, LocalDateTime createdAt, String warehouseCode, Long scopeId, String scopeHash) {}
public record Protection(LocalDateTime apiCooldownUntilUtc, String apiPauseReason, String apiBudgetJson) {}
}
@@ -0,0 +1,41 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
public final class CollectionResponses {
private CollectionResponses() {}
public record Page<T>(int current, int size, long total, Instant listUpperCreatedAt,
Long listUpperId, Instant observedAt, List<T> records) {}
public record Overview(String sourceAccount, String environmentLabel, String collectionMode,
Instant observedAt, List<Warehouse> warehouses) {}
public record Warehouse(String warehouseCode, String warehouseName, String dataFamily,
boolean executionEnabled, boolean publicationEnabled, String disabledReason,
Instant nextScheduledAt, Instant observedAt, Instant initialFrom, Instant initialUntil,
String initialCoverageStatus, Instant salesCoveredFrom, Instant salesCoveredUntil,
String lastSuccessfulTaskNo, Instant lastSucceededAt, Instant lastPublishedAt,
Instant inventoryObservedFrom, Instant inventoryObservedUntil, String dataFreshness,
boolean rebuildRequired, String revisionReviewStatus, Task currentTask,
Task sharedUpstream, Protection protection, InventoryDailySamples inventoryDailySamples) {}
public record InventoryDailySamples(LocalDate windowFrom, LocalDate windowUntil, int expectedDays,
int sampleCount, List<LocalDate> missingDates, LocalDate accumulationStartedOn) {}
public record Task(String taskNo, String taskType, String dataFamily, String warehouseCode,
String taskRole, String executionPurpose, Instant queryFrom, Instant queryUntil,
String taskStatus, String applyState, Integer attemptCount, Integer latestAttemptNo,
String resultPublicationState, Long resultPublishedRunId, Instant nextRetryAt,
List<String> allowedActions, String actionBlockReason, Instant progressUpdatedAt,
Map<String,Object> progress, Validation validation, Attempt latestAttempt) {}
public record TaskDetail(Task task, Task sharedUpstream, Protection protection, Instant observedAt) {}
public record Attempt(Long runId, String taskNo, Integer attemptNo, String status, String phase,
String executionPurpose, Instant startedAt, Instant finishedAt, Instant phaseStartedAt,
Instant progressUpdatedAt, Instant heartbeatAt, String executionHealth, Long elapsedSeconds,
String errorCode, String failureSummary, boolean retryable, String coverageStatus,
Instant coveredFrom, Instant coveredUntil, Instant publishedAt, Map<String,Object> progress) {}
public record Protection(Instant accountCooldownUntil, String accountPauseReason,
Integer dailyRequestBudget, Long reservedHttpRequests, Long confirmedHttpRequests, String budgetDate) {}
public record Validation(String validationSessionNo, Map<String,Object> budgetLimits,
Map<String,Object> budgetConsumed, Instant deadlineAt, String stopReason) {}
}
@@ -0,0 +1,8 @@
package com.baiyue.lastleg.selfoperated.analytics.request;
import java.time.LocalDateTime;
/** Fixed reason; committed reservations are never refunded after an ambiguous outcome. */
public final class EgressRejectedException extends RuntimeException {
private final boolean sampleDone; private final LocalDateTime waitUntilUtc;
public EgressRejectedException(String code, boolean sampleDone, LocalDateTime waitUntilUtc) {super(code);this.sampleDone=sampleDone;this.waitUntilUtc=waitUntilUtc;}
public String code(){return getMessage();} public boolean sampleDone(){return sampleDone;} public LocalDateTime waitUntilUtc(){return waitUntilUtc;}
}
@@ -0,0 +1,12 @@
package com.baiyue.lastleg.selfoperated.analytics.request;
import java.time.*;
/** Bounded durable account journal: only one outstanding attempt; old IDs cannot match a new permit. */
public final class SelfopApiBudget {
public int dailyLimit,minIntervalMs; public String configHash;
public LocalDate budgetDate; public long reservedHttp,confirmedHttp; public int rateLimitFailures;
public LocalDateTime lastRequestAtUtc; public String importedGrantId; public Pending pending;
public static final class Pending {
public SelfopEgressDtos.EgressContext context; public SelfopEgressDtos.Operation operation; public String requestHash; public String internalRequestHash; public String completionHash;
public Long taskId,runId; public LocalDateTime expiresAtUtc; public boolean consumed; public boolean completed; public boolean abandoned;
}
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.selfoperated.analytics.request;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import java.time.ZoneOffset;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/internal/lastleg/self-operated-analytics/egress")
public class SelfopEgressController {
private final InternalServiceTokenGuard guard;private final SelfopEgressService service;
public SelfopEgressController(InternalServiceTokenGuard guard,SelfopEgressService service){this.guard=guard;this.service=service;}
@PostMapping("/authorize") public ApiResponse<?> authorize(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@RequestBody SelfopEgressDtos.AuthorizationRequest request){guard.verify(token);try{return ApiResponse.success(service.authorize(request));}catch(EgressRejectedException denied){return ApiResponse.of(ResultCode.BUSINESS_ERROR,"领星请求许可未授予",new SelfopEgressDtos.AuthorizationDenial(denied.code(),denied.sampleDone(),denied.waitUntilUtc()==null?null:denied.waitUntilUtc().toInstant(ZoneOffset.UTC)),null,null);}}
@PostMapping("/complete") public ApiResponse<Boolean> complete(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@RequestBody SelfopEgressDtos.CompletionRequest request){guard.verify(token);service.complete(request);return ApiResponse.success(true);}
}
@@ -0,0 +1,12 @@
package com.baiyue.lastleg.selfoperated.analytics.request;
import java.time.Instant;
public final class SelfopEgressDtos {
private SelfopEgressDtos(){}
public enum Operation {WAREHOUSES,OUTBOUND_QUERY,OUTBOUND_LOCATE,OUTBOUND_DETAIL,PRODUCT_INVENTORY}
public record EgressContext(String sourceAccount,String executionPurpose,String rootTaskId,String sessionId,String attemptId,String exclusiveGrantId){}
@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.ALWAYS)
public record AuthorizationDenial(String errorCode,boolean sampleDone,Instant waitUntil){}
public record AuthorizationRequest(EgressContext context,Operation operation,String requestHash){}
public record AttemptPermit(EgressContext context,Operation operation,String requestHash,int allowedAttempts,Instant expiresAt,boolean exclusiveConfirmed,boolean budgetConsumed){}
public record CompletionRequest(EgressContext context,Operation operation,String requestHash,int actualHttpAttempts,String errorCode,Integer upstreamStatus,Integer sourceCode,boolean accountPaused,Instant cooldownUntil){}
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.selfoperated.analytics.request;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
@Mapper
public interface SelfopEgressMapper {
@Select("SELECT * FROM ll_selfop_outbound_discovery_scope WHERE source_account=#{account} FOR UPDATE") SelfopOutboundDiscoveryScope lockAccount(String account);
@Select("SELECT * FROM ll_selfop_sync_task WHERE id=#{id} FOR UPDATE") SelfopSyncTask lockTask(Long id);
@Select("SELECT * FROM ll_selfop_sync_run WHERE id=#{id} FOR UPDATE") SelfopSyncRun lockRun(Long id);
@Select("SELECT COUNT(*) FROM ll_selfop_sync_scope WHERE id=#{scopeId} AND fence_token=#{fence} AND lease_until_utc>#{now}") int warehouseFence(@Param("scopeId") Long scopeId,@Param("fence") Long fence,@Param("taskId") Long taskId,@Param("now") LocalDateTime now);
@Update("UPDATE ll_selfop_outbound_discovery_scope SET api_budget_json=#{apiBudgetJson},api_cooldown_until_utc=#{apiCooldownUntilUtc},api_pause_reason=#{apiPauseReason},updated_at=UTC_TIMESTAMP(3) WHERE id=#{id}") int saveAccount(SelfopOutboundDiscoveryScope row);
@Update("UPDATE ll_selfop_sync_task SET validation_budget_json=#{validationBudgetJson},status=#{status},next_retry_at_utc=#{nextRetryAtUtc},updated_at=UTC_TIMESTAMP(3) WHERE id=#{id}") int saveRoot(SelfopSyncTask row);
@Update("UPDATE ll_selfop_sync_run SET status=#{status},error_code=#{reason},finished_at_utc=#{now},progress_snapshot_json=(SELECT progress_json FROM ll_selfop_sync_task WHERE id=task_id) WHERE task_id IN (SELECT id FROM ll_selfop_sync_task WHERE validation_root_task_id=#{root}) AND status IN ('FETCHING','VALIDATED','YIELDED')") int terminateValidationRuns(@Param("root") Long root,@Param("status") String status,@Param("reason") String reason,@Param("now") LocalDateTime now);
}
@@ -0,0 +1,158 @@
package com.baiyue.lastleg.selfoperated.analytics.request;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopRequestContext;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import com.baiyue.lastleg.selfoperated.analytics.request.SelfopEgressDtos.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.*;
import java.util.*;
@Service
public class SelfopEgressService {
private final SelfopEgressMapper mapper; private final SelfopAnalyticsProperties config; private final Clock clock;
@Autowired public SelfopEgressService(SelfopEgressMapper mapper,SelfopAnalyticsProperties config){this(mapper,config,Clock.systemUTC());}
SelfopEgressService(SelfopEgressMapper mapper,SelfopAnalyticsProperties config,Clock clock){this.mapper=mapper;this.config=config;this.clock=clock;}
private LocalDateTime now(){return LocalDateTime.ofInstant(clock.instant(),ZoneOffset.UTC);}
@Transactional(noRollbackFor=EgressRejectedException.class)
public EgressContext reserve(SelfopRequestContext context,Operation operation,int pageSize,String warehouseCode,String detailKey,String requestHash){
if(context==null||context.rootTaskId()==null||context.taskId()==null||context.runId()==null||!Objects.equals(context.sessionId(),"selfop-"+context.rootTaskId()))throw reject("EGRESS_CONTEXT_INVALID");
LocalDateTime now=now();gate(context.sourceAccount(),context.executionPurpose(),context.exclusiveGrantId(),now);
if(operation==null||requestHash==null||!requestHash.matches("[a-f0-9]{64}"))throw reject("EGRESS_REQUEST_INVALID");
var account=account(context.sourceAccount());var budget=budget(account,now);save(account,budget);var root=root(context.rootTaskId(),context.executionPurpose(),context.sourceAccount());
var task=mapper.lockTask(context.taskId());var run=mapper.lockRun(context.runId());verify(task,run,root,account,now);
checkAccount(account,budget,now);
SelfopValidationBudget validation=null;
if("VALIDATION".equals(context.executionPurpose())){
validation=validation(root);String limit=validation.limit(now,operation,pageSize,warehouseCode,detailKey);
if(limit!=null){stop(root,validation,limit,false,now);throw new EgressRejectedException(limit,true,null);}
if(operation==Operation.PRODUCT_INVENTORY&&(warehouseCode==null||!config.getWarehouses().containsKey(warehouseCode)))throw reject("EGRESS_WAREHOUSE_INVALID");
if(operation==Operation.OUTBOUND_DETAIL&&(detailKey==null||detailKey.isBlank()))throw reject("EGRESS_DETAIL_IDENTITY_INVALID");
}
int interval="VALIDATION".equals(context.executionPurpose())?Math.max(5000,budget.minIntervalMs):budget.minIntervalMs;
if(budget.lastRequestAtUtc!=null&&now.isBefore(budget.lastRequestAtUtc.plusNanos((long)interval*1000000)))throw new EgressRejectedException("ACCOUNT_INTERVAL_WAIT",false,budget.lastRequestAtUtc.plusNanos((long)interval*1000000));
if(budget.reservedHttp>=budget.dailyLimit)throw new EgressRejectedException("ACCOUNT_DAILY_BUDGET",false,now.plusHours(8).toLocalDate().plusDays(1).atStartOfDay().minusHours(8));
var outbound=new EgressContext(context.sourceAccount(),context.executionPurpose(),String.valueOf(context.rootTaskId()),context.sessionId(),UUID.randomUUID().toString(),context.exclusiveGrantId());
var pending=new SelfopApiBudget.Pending();pending.context=outbound;pending.operation=operation;pending.internalRequestHash=requestHash;pending.taskId=context.taskId();pending.runId=context.runId();pending.expiresAtUtc=now.plusSeconds(Math.max(1,Math.min(120,config.getEgressLeaseSeconds())));
if(pending.expiresAtUtc.isAfter(config.getExclusiveGrantExpiresAtUtc()))pending.expiresAtUtc=config.getExclusiveGrantExpiresAtUtc();
if(validation!=null&&pending.expiresAtUtc.isAfter(validation.deadlineAtUtc))pending.expiresAtUtc=validation.deadlineAtUtc;
budget.pending=pending;budget.reservedHttp++;budget.lastRequestAtUtc=now;
if(validation!=null){validation.consume(operation,warehouseCode,detailKey);root.setValidationBudgetJson(SelfopSupport.json(validation));mapper.saveRoot(root);}
save(account,budget);return outbound;
}
@Transactional(noRollbackFor=EgressRejectedException.class)
public AttemptPermit authorize(AuthorizationRequest request){
requireRequest(request);var c=request.context();var now=now();gate(c.sourceAccount(),c.executionPurpose(),c.exclusiveGrantId(),now);
var account=account(c.sourceAccount());var budget=budget(account,now);var pending=pendingForAuthorization(budget,c,request.operation());
var root=root(parseId(c.rootTaskId()),c.executionPurpose(),c.sourceAccount());
var task=mapper.lockTask(pending.taskId);var run=mapper.lockRun(pending.runId);verify(task,run,root,account,now);
if(pending.consumed||pending.completed||!now.isBefore(pending.expiresAtUtc))throw reject("EGRESS_PERMIT_REPLAY_OR_EXPIRED");
if(account.getApiPauseReason()!=null||account.getApiCooldownUntilUtc()!=null&&now.isBefore(account.getApiCooldownUntilUtc()))throw reject("ACCOUNT_PAUSED");
if("VALIDATION".equals(c.executionPurpose())){var v=validation(root);if(v.stopReason!=null||!now.isBefore(v.deadlineAtUtc)){stop(root,v,v.stopReason==null?"VALIDATION_DEADLINE":v.stopReason,false,now);throw new EgressRejectedException("VALIDATION_STOPPED",true,null);}}
if (!request.requestHash().matches("[a-f0-9]{64}"))throw reject("EGRESS_REQUEST_HASH_INVALID");
pending.requestHash=request.requestHash();pending.consumed=true;LocalDateTime expiry=now.plusSeconds(30);if(expiry.isAfter(pending.expiresAtUtc))expiry=pending.expiresAtUtc;pending.expiresAtUtc=expiry;budget.lastRequestAtUtc=now;save(account,budget);
return new AttemptPermit(c,request.operation(),request.requestHash(),1,expiry.toInstant(ZoneOffset.UTC),true,true);
}
@Transactional(noRollbackFor=EgressRejectedException.class)
public void complete(CompletionRequest request){
if(request==null||request.context()==null||request.actualHttpAttempts()<0||request.actualHttpAttempts()>1)throw reject("EGRESS_COMPLETION_INVALID");
var c=request.context();var now=now();var account=account(c.sourceAccount());var budget=budget(account,now);var pending=pending(budget,c,request.operation(),request.requestHash());
String completionHash=SelfopSupport.hash(request);
if(pending.completed&&!pending.abandoned){
if(!Objects.equals(pending.completionHash,completionHash))throw reject("EGRESS_COMPLETION_CONFLICT");
return;
}
if(!pending.consumed)throw reject("EGRESS_COMPLETION_NOT_AUTHORIZED");
pending.completed=true;pending.abandoned=false;pending.completionHash=completionHash;budget.confirmedHttp+=request.actualHttpAttempts();
var root=root(parseId(c.rootTaskId()),c.executionPurpose(),c.sourceAccount());
SelfopValidationBudget validation="VALIDATION".equals(c.executionPurpose())?validation(root):null;
if(validation!=null){validation.confirmedHttp+=request.actualHttpAttempts();root.setValidationBudgetJson(SelfopSupport.json(validation));mapper.saveRoot(root);}
boolean auth=Integer.valueOf(401).equals(request.upstreamStatus())||Integer.valueOf(403).equals(request.upstreamStatus())
||"LINGXING_WMS_AUTHORIZATION_REJECTED".equals(request.errorCode())||"LINGXING_WMS_SIGNATURE_REJECTED".equals(request.errorCode());
boolean rate=!auth&&(Integer.valueOf(429).equals(request.upstreamStatus())||Integer.valueOf(31001).equals(request.sourceCode())
||Integer.valueOf(429).equals(request.sourceCode())||"LINGXING_WMS_HTTP_RATE_LIMITED".equals(request.errorCode())
||"LINGXING_WMS_SOURCE_RATE_LIMITED".equals(request.errorCode()));
if(rate){
budget.rateLimitFailures++;var until=now.plusSeconds(Math.max(1,config.getCooldownSeconds())*(1L<<Math.min(10,budget.rateLimitFailures-1)));
if(request.cooldownUntil()!=null&&LocalDateTime.ofInstant(request.cooldownUntil(),ZoneOffset.UTC).isAfter(until))until=LocalDateTime.ofInstant(request.cooldownUntil(),ZoneOffset.UTC);
if(account.getApiCooldownUntilUtc()==null||account.getApiCooldownUntilUtc().isBefore(until))account.setApiCooldownUntilUtc(until);
if(recoverablePause(account.getApiPauseReason()))account.setApiPauseReason(budget.rateLimitFailures>=config.getMaxRateLimitFailures()?"RATE_LIMIT_REQUIRES_REVIEW":"RATE_LIMIT_COOLDOWN");
}
if(auth)account.setApiPauseReason("AUTHORIZATION_REQUIRES_REVIEW");
else if(request.accountPaused()&&!rate&&recoverablePause(account.getApiPauseReason()))account.setApiPauseReason("EGRESS_REQUIRES_REVIEW");
if(validation!=null&&(request.errorCode()!=null||rate||request.accountPaused()||request.upstreamStatus()!=null&&request.upstreamStatus()>=400))stop(root,validation,auth?"AUTHORIZATION_REJECTED":rate?"RATE_LIMITED":"VALIDATION_PROVIDER_FAILURE",true,now);
save(account,budget);
}
/** Records the caller's uncertain result without changing the original completion or its consumption. */
@Transactional
public void pauseUncertain(EgressContext context,Operation operation,String errorCode){
if(context==null||operation==null)throw reject("EGRESS_CONTEXT_INVALID");
var account=account(context.sourceAccount());
if(!Objects.equals(account.getSourceAccount(),context.sourceAccount())||account.getApiBudgetJson()==null)throw reject("EGRESS_PERMIT_BINDING_INVALID");
var budget=SelfopSupport.read(account.getApiBudgetJson(),SelfopApiBudget.class);
var pending=pendingForAuthorization(budget,context,operation);
// A durable completion already confirms the supplier rate limit. Keep its recoverable cooldown.
boolean confirmedRate=pending.completed&&!pending.abandoned&&pending.completionHash!=null
&&Set.of("LINGXING_WMS_HTTP_RATE_LIMITED","LINGXING_WMS_SOURCE_RATE_LIMITED").contains(errorCode==null?"":errorCode)
&&Set.of("RATE_LIMIT_COOLDOWN","RATE_LIMIT_REQUIRES_REVIEW").contains(account.getApiPauseReason()==null?"":account.getApiPauseReason());
if(confirmedRate)return;
if(recoverablePause(account.getApiPauseReason()))account.setApiPauseReason("EGRESS_REQUIRES_REVIEW");
if(mapper.saveAccount(account)!=1)throw reject("EGRESS_ACCOUNT_WRITE_FAILED");
}
@Transactional(noRollbackFor=EgressRejectedException.class)
public void failValidation(Long rootTaskId,String code){var initial=mapper.lockTask(rootTaskId);if(initial==null)throw reject("VALIDATION_ROOT_MISSING");/* caller is no longer holding collection locks */var v=validation(initial);stop(initial,v,safeReason(code),true,now());}
@Transactional(noRollbackFor=EgressRejectedException.class)
public void finishSample(Long rootTaskId,String reason){var root=mapper.lockTask(rootTaskId);var v=validation(root);if("DEAD".equals(root.getStatus()))return;stop(root,v,safeReason(reason),false,now());}
private void gate(String account,String purpose,String grant,LocalDateTime now){
if("OFF".equals(config.getCollectionMode())||!Objects.equals(config.getCollectionMode(),purpose)||!("VALIDATION".equals(purpose)||"BUSINESS".equals(purpose)))throw reject("EGRESS_MODE_REJECTED");
if("BUSINESS".equals(purpose)&&!"production".equals(config.getEnvironment()))throw reject("BUSINESS_ENVIRONMENT_REJECTED");
if(!Objects.equals(config.getSourceAccount(),account)||account==null||account.isBlank())throw reject("SOURCE_ACCOUNT_MISMATCH");
if(grant==null||!Objects.equals(grant,config.getExclusiveGrantId())||config.getExclusiveGrantExpiresAtUtc()==null||!now.isBefore(config.getExclusiveGrantExpiresAtUtc())||!config.isExclusiveHandoffVerified()||!config.isPriorEgressClosed()||!config.isInflightDrained()||!config.isTransferredBudgetVerified())throw reject("EXCLUSIVE_HANDOFF_UNCONFIRMED");
if(config.getDailyRequestBudget()<1||config.getBusinessMinRequestIntervalMs()<1||config.getRequestBudget()<1||config.getTimeBudgetSeconds()<1||config.getMaxRateLimitFailures()<1||config.getCooldownSeconds()<1)throw reject("ACCOUNT_BUDGET_UNCONFIGURED");
}
private SelfopOutboundDiscoveryScope account(String id){var a=mapper.lockAccount(id);if(a==null)throw reject("EGRESS_ACCOUNT_MISSING");return a;}
private SelfopApiBudget budget(SelfopOutboundDiscoveryScope account,LocalDateTime now){
var b=account.getApiBudgetJson()==null?new SelfopApiBudget():SelfopSupport.read(account.getApiBudgetJson(),SelfopApiBudget.class);
if(b.reservedHttp<0||b.confirmedHttp<0||b.confirmedHttp>b.reservedHttp)throw reject("ACCOUNT_BUDGET_INVALID");
LocalDate day=now.plusHours(8).toLocalDate();
if(!Objects.equals(day,b.budgetDate)){/* unknown in-flight is charged again on new day; cooling survives */b.budgetDate=day;b.dailyLimit=config.getDailyRequestBudget();b.reservedHttp=b.pending!=null&&!b.pending.completed?1:0;b.confirmedHttp=0;}
if(b.dailyLimit<1)b.dailyLimit=config.getDailyRequestBudget();
b.dailyLimit=Math.min(b.dailyLimit,config.getDailyRequestBudget());b.minIntervalMs=Math.max(b.minIntervalMs,config.getBusinessMinRequestIntervalMs());
b.configHash=SelfopSupport.hash(Arrays.asList(b.dailyLimit,b.minIntervalMs,config.getRequestBudget(),config.getTimeBudgetSeconds()));
if(!Objects.equals(config.getExclusiveGrantId(),b.importedGrantId)){
if(config.getTransferredBudgetDate()==null||!config.getTransferredBudgetDate().equals(day)||config.getTransferredDailyHttpCount()<0)throw reject("HANDOFF_BUDGET_DATE_UNCONFIRMED");
b.reservedHttp=Math.max(b.reservedHttp,config.getTransferredDailyHttpCount());b.importedGrantId=config.getExclusiveGrantId();
if(config.getTransferredCooldownUntilUtc()!=null&&(account.getApiCooldownUntilUtc()==null||account.getApiCooldownUntilUtc().isBefore(config.getTransferredCooldownUntilUtc())))account.setApiCooldownUntilUtc(config.getTransferredCooldownUntilUtc());
if(config.getTransferredPauseReason()!=null&&!config.getTransferredPauseReason().isBlank())account.setApiPauseReason(config.getTransferredPauseReason());
}
return b;
}
private void checkAccount(SelfopOutboundDiscoveryScope account,SelfopApiBudget b,LocalDateTime now){
if(b.pending!=null&&!b.pending.completed){
if(now.isBefore(b.pending.expiresAtUtc))throw new EgressRejectedException("ACCOUNT_REQUEST_INFLIGHT",false,b.pending.expiresAtUtc);
/* A lost response may still be in the provider. Conservatively retain charge and cool before another attempt. */
b.pending.completed=true;b.pending.abandoned=true;var until=now.plusSeconds(config.getCooldownSeconds());
if(account.getApiCooldownUntilUtc()==null||account.getApiCooldownUntilUtc().isBefore(until))account.setApiCooldownUntilUtc(until);
if(recoverablePause(account.getApiPauseReason()))account.setApiPauseReason("REQUEST_OUTCOME_UNKNOWN");save(account,b);
}
if(account.getApiCooldownUntilUtc()!=null&&now.isBefore(account.getApiCooldownUntilUtc()))throw new EgressRejectedException("ACCOUNT_COOLDOWN_WAIT",false,account.getApiCooldownUntilUtc());
if("RATE_LIMIT_COOLDOWN".equals(account.getApiPauseReason())||"REQUEST_OUTCOME_UNKNOWN".equals(account.getApiPauseReason()))account.setApiPauseReason(null);
if(account.getApiPauseReason()!=null)throw reject("ACCOUNT_REQUIRES_REVIEW");
}
private SelfopSyncTask root(Long id,String purpose,String account){var t=mapper.lockTask(id);if(t==null||!Objects.equals(t.getSourceAccount(),account)||!Objects.equals(t.getExecutionPurpose(),purpose))throw reject("EGRESS_ROOT_INVALID");if("VALIDATION".equals(purpose)&&!Objects.equals(id,t.getValidationRootTaskId()))throw reject("VALIDATION_ROOT_INVALID");return t;}
private void verify(SelfopSyncTask task,SelfopSyncRun run,SelfopSyncTask root,SelfopOutboundDiscoveryScope account,LocalDateTime now){
if(task==null||run==null||!Objects.equals(run.getTaskId(),task.getId())||!Objects.equals(run.getSourceAccount(),root.getSourceAccount())||!Objects.equals(task.getSourceAccount(),root.getSourceAccount())||!Objects.equals(task.getExecutionPurpose(),root.getExecutionPurpose())||!Objects.equals(run.getExecutionPurpose(),task.getExecutionPurpose())||!"RUNNING".equals(task.getStatus())||!Set.of("FETCHING","VALIDATED").contains(run.getStatus()))throw reject("EGRESS_TASK_NOT_ACTIVE");
if("VALIDATION".equals(root.getExecutionPurpose())&&(!Objects.equals(task.getValidationRootTaskId(),root.getId())||Set.of("DEAD","SAMPLE_DONE").contains(root.getStatus())))throw reject("VALIDATION_ROOT_STOPPED");
if("ACCOUNT_DISCOVERY".equals(task.getTaskRole())){if(!Objects.equals(task.getDiscoveryScopeId(),account.getId())||!Objects.equals(account.getFenceToken(),run.getFenceToken())||account.getLeaseUntilUtc()==null||!now.isBefore(account.getLeaseUntilUtc()))throw reject("EGRESS_FENCE_LOST");}
else if(!Objects.equals(task.getScopeId(),run.getScopeId())||!Objects.equals(task.getWarehouseCode(),run.getWarehouseCode())||mapper.warehouseFence(task.getScopeId(),run.getFenceToken(),task.getId(),now)!=1)throw reject("EGRESS_FENCE_LOST");
}
private SelfopValidationBudget validation(SelfopSyncTask root){if(root==null||!"VALIDATION".equals(root.getExecutionPurpose())||!Objects.equals(root.getId(),root.getValidationRootTaskId())||root.getValidationBudgetJson()==null)throw reject("VALIDATION_ROOT_INVALID");var b=SelfopSupport.read(root.getValidationBudgetJson(),SelfopValidationBudget.class);b.validate();return b;}
private SelfopApiBudget.Pending pendingForAuthorization(SelfopApiBudget budget,EgressContext c,Operation op){var p=budget.pending;if(p==null||!Objects.equals(p.context,c)||p.operation!=op)throw reject("EGRESS_PERMIT_BINDING_INVALID");return p;}
private SelfopApiBudget.Pending pending(SelfopApiBudget budget,EgressContext c,Operation op,String hash){var p=budget.pending;if(p==null||!Objects.equals(p.context,c)||p.operation!=op||!Objects.equals(p.requestHash,hash))throw reject("EGRESS_PERMIT_BINDING_INVALID");return p;}
private void stop(SelfopSyncTask root,SelfopValidationBudget v,String reason,boolean failed,LocalDateTime now){if("DEAD".equals(root.getStatus())||"SAMPLE_DONE".equals(root.getStatus()))return;v.stopReason=reason;root.setValidationBudgetJson(SelfopSupport.json(v));root.setStatus(failed?"DEAD":"SAMPLE_DONE");root.setNextRetryAtUtc(null);mapper.saveRoot(root);mapper.terminateValidationRuns(root.getId(),failed?"FAILED":"SAMPLE_DONE",reason,now);}
private void save(SelfopOutboundDiscoveryScope account,SelfopApiBudget b){account.setApiBudgetJson(SelfopSupport.json(b));if(mapper.saveAccount(account)!=1)throw reject("EGRESS_ACCOUNT_WRITE_FAILED");}
private static Long parseId(String id){try{return Long.valueOf(id);}catch(Exception e){throw reject("EGRESS_ROOT_ID_INVALID");}}
private static void requireRequest(AuthorizationRequest r){if(r==null||r.context()==null||r.operation()==null||r.requestHash()==null)throw reject("EGRESS_REQUEST_INVALID");}
private static String safeReason(String code){return code!=null&&code.matches("[A-Z0-9_]{1,64}")?code:"VALIDATION_FAILED";}
private static boolean recoverablePause(String reason){return reason==null||"RATE_LIMIT_COOLDOWN".equals(reason)||"REQUEST_OUTCOME_UNKNOWN".equals(reason);}
private static EgressRejectedException reject(String code){return new EgressRejectedException(code,false,null);}
}
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.selfoperated.analytics.request;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport;
import java.time.LocalDateTime;
import java.util.*;
/** Frozen, persisted once on the root task. Limits may only be lowered before creation. */
public final class SelfopValidationBudget {
public int maxPages=10,maxPageSize=10,maxDistinctDetails=20,maxDetailHttp=20,maxInventoryPagesPerWarehouse=2,maxHttp=40;
public int pages,detailHttp,http,confirmedHttp;
public LocalDateTime startedAtUtc,deadlineAtUtc;
public String stopReason;
public Set<String> detailKeys=new LinkedHashSet<>();
public Map<String,Integer> inventoryPages=new LinkedHashMap<>();
public static String freeze(LocalDateTime now){return freeze(now,10,20,40,300);}
public static String freeze(LocalDateTime now,int pages,int details,int http,int seconds){
if(pages<1||details<1||http<1||seconds<1)throw new IllegalArgumentException("VALIDATION_LIMIT_INVALID");
var b=new SelfopValidationBudget();b.maxPages=Math.min(10,pages);b.maxDetailHttp=Math.min(20,details);b.maxDistinctDetails=Math.min(20,details);b.maxHttp=Math.min(40,http);b.startedAtUtc=now;b.deadlineAtUtc=now.plusSeconds(Math.min(300,seconds));return SelfopSupport.json(b);
}
public void validate(){
if(maxPages<1||maxPages>10||maxPageSize<1||maxPageSize>10||maxDistinctDetails<1||maxDistinctDetails>20||maxDetailHttp<1||maxDetailHttp>20||maxInventoryPagesPerWarehouse<1||maxInventoryPagesPerWarehouse>2||maxHttp<1||maxHttp>40||startedAtUtc==null||deadlineAtUtc==null||deadlineAtUtc.isAfter(startedAtUtc.plusMinutes(5))||!deadlineAtUtc.isAfter(startedAtUtc)||pages<0||detailHttp<0||http<0||confirmedHttp<0||confirmedHttp>http||detailKeys==null||inventoryPages==null)throw new EgressRejectedException("VALIDATION_BUDGET_INVALID",false,null);
}
public String limit(LocalDateTime now,SelfopEgressDtos.Operation op,int size,String warehouse,String key){
validate();if(stopReason!=null)return stopReason;
if(!now.isBefore(deadlineAtUtc))return "VALIDATION_DEADLINE";
if(http>=maxHttp)return "VALIDATION_HTTP_LIMIT";
if(op!=SelfopEgressDtos.Operation.OUTBOUND_DETAIL){if(size<1||size>maxPageSize)throw new EgressRejectedException("VALIDATION_PAGE_SIZE",false,null);if(pages>=maxPages)return "VALIDATION_PAGE_LIMIT";}
if(op==SelfopEgressDtos.Operation.PRODUCT_INVENTORY&&inventoryPages.getOrDefault(warehouse,0)>=maxInventoryPagesPerWarehouse)return "VALIDATION_INVENTORY_LIMIT";
if(op==SelfopEgressDtos.Operation.OUTBOUND_DETAIL){if(detailHttp>=maxDetailHttp)return "VALIDATION_DETAIL_HTTP_LIMIT";if(!detailKeys.contains(key)&&detailKeys.size()>=maxDistinctDetails)return "VALIDATION_DETAIL_IDENTITY_LIMIT";}
return null;
}
public void consume(SelfopEgressDtos.Operation op,String warehouse,String key){http++;if(op!=SelfopEgressDtos.Operation.OUTBOUND_DETAIL)pages++;else{detailHttp++;detailKeys.add(key);}if(op==SelfopEgressDtos.Operation.PRODUCT_INVENTORY)inventoryPages.merge(warehouse,1,Integer::sum);}
}
@@ -0,0 +1,44 @@
package com.baiyue.lastleg.selfoperated.analytics.retention;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface SalesRetentionMapper {
@Select("SELECT * FROM ll_selfop_sync_scope WHERE id=#{id} FOR UPDATE")
SelfopSyncScope scope(@Param("id") Long id);
@Select("SELECT * FROM ll_selfop_sync_run WHERE id=#{id} FOR UPDATE")
SelfopSyncRun run(@Param("id") Long id);
@Select("SELECT id FROM ll_selfop_sync_run WHERE scope_id=#{scopeId} AND status='PUBLISHED' AND execution_purpose='BUSINESS' AND id != #{current} ORDER BY published_at_utc DESC,id DESC LIMIT 1")
Long previous(@Param("scopeId") Long scopeId,@Param("current") Long current);
@Select("""
SELECT r.* FROM ll_selfop_sync_run r
WHERE r.scope_id=#{scopeId} AND r.status='PUBLISHED' AND r.execution_purpose='BUSINESS' AND r.run_role='WAREHOUSE_SYNC'
AND r.superseded_at_utc IS NOT NULL AND r.superseded_at_utc <= #{cutoff}
AND r.inventory_daily_sample_date IS NULL AND r.inventory_sample_rule_version IS NULL
AND r.id != #{current} AND (#{previous} IS NULL OR r.id != #{previous})
AND (EXISTS (SELECT 1 FROM ll_selfop_sku_daily_sales d WHERE d.sales_run_id=r.id)
OR EXISTS (SELECT 1 FROM ll_selfop_sku_profile p WHERE p.profile_run_id=r.id))
AND NOT EXISTS (
SELECT 1 FROM ll_selfop_sync_run input JOIN ll_selfop_sync_task t ON t.id=input.task_id
WHERE t.source_account=r.source_account AND t.warehouse_code=r.warehouse_code
AND (t.status NOT IN ('SUCCEEDED','SAMPLE_DONE','CANCELLED') OR t.apply_state IN ('APPLYING','BLOCKED'))
AND (input.base_published_run_id=r.id OR input.reference_sales_run_id=r.id))
ORDER BY r.superseded_at_utc,r.id LIMIT 500
""")
List<SelfopSyncRun> eligible(@Param("scopeId") Long scopeId,@Param("current") Long current,
@Param("previous") Long previous,@Param("cutoff") LocalDateTime cutoff);
@Select("""
SELECT COUNT(*) FROM ll_selfop_sync_run input JOIN ll_selfop_sync_task t ON t.id=input.task_id
WHERE t.source_account=#{account} AND t.warehouse_code=#{warehouse}
AND (t.status NOT IN ('SUCCEEDED','SAMPLE_DONE','CANCELLED') OR t.apply_state IN ('APPLYING','BLOCKED'))
AND (input.base_published_run_id=#{id} OR input.reference_sales_run_id=#{id})
""")
long activeReferences(@Param("account") String account,@Param("warehouse") String warehouse,@Param("id") Long id);
@Delete("DELETE FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{id} LIMIT #{batchSize}")
int deleteDaily(@Param("id") Long id,@Param("batchSize") int batchSize);
@Delete("DELETE FROM ll_selfop_sku_profile WHERE profile_run_id=#{id} LIMIT #{batchSize}")
int deleteProfiles(@Param("id") Long id,@Param("batchSize") int batchSize);
}
@@ -0,0 +1,54 @@
package com.baiyue.lastleg.selfoperated.analytics.retention;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.*;
/** No scheduled execution. Enabled, explicitly requested calls can remove only old derived sales copies. */
@Service
@RequiredArgsConstructor
public class SalesRetentionService {
private final SalesRetentionMapper mapper;
private final SelfopAnalyticsProperties config;
public record Preview(Long scopeId,Long current,Long previous,List<Long> eligibleRunIds,LocalDateTime cutoff) { }
public record Deleted(Long runId,int dailyRows,int profileRows) { }
@Transactional(isolation=Isolation.READ_COMMITTED,rollbackFor=Exception.class)
public Preview preview(Long scopeId,LocalDateTime now) {
SelfopSyncScope scope=mapper.scope(scopeId); validateScope(scope);
Long previous=scope.getPublishedRunId()==null?null:mapper.previous(scopeId,scope.getPublishedRunId());
LocalDateTime cutoff=now.minusDays(Math.max(7,config.getSalesRetentionDays()));
if(scope.getPublishedRunId()==null) return new Preview(scopeId,null,null,List.of(),cutoff);
return new Preview(scopeId,scope.getPublishedRunId(),previous,
mapper.eligible(scopeId,scope.getPublishedRunId(),previous,cutoff).stream().map(SelfopSyncRun::getId).toList(),cutoff);
}
/** Every batch rechecks current pointers and active references while holding the shared OUTBOUND scope lock. */
@Transactional(isolation=Isolation.READ_COMMITTED,rollbackFor=Exception.class)
public Deleted deleteBatch(Long scopeId,Long runId) {
SelfopSupport.require(config.isRetentionEnabled(),"SALES_RETENTION_DISABLED");
SelfopSyncScope scope=mapper.scope(scopeId); validateScope(scope);
SelfopSyncRun run=mapper.run(runId);
Long previous=scope.getPublishedRunId()==null?null:mapper.previous(scopeId,scope.getPublishedRunId());
SelfopSupport.require(run!=null && Objects.equals(run.getScopeId(),scopeId)
&& Objects.equals(run.getSourceAccount(),scope.getSourceAccount())
&& Objects.equals(run.getWarehouseCode(),scope.getWarehouseCode()) && "WAREHOUSE_SYNC".equals(run.getRunRole())
&& "PUBLISHED".equals(run.getStatus()) && "BUSINESS".equals(run.getExecutionPurpose())
&& run.getInventoryDailySampleDate()==null && run.getInventorySampleRuleVersion()==null
&& run.getPublishedAtUtc()!=null && run.getSupersededAtUtc()!=null
&& !run.getSupersededAtUtc().isAfter(SelfopSupport.utcNow().minusDays(Math.max(7,config.getSalesRetentionDays())))
&& scope.getPublishedRunId()!=null && !Objects.equals(runId,scope.getPublishedRunId())
&& !Objects.equals(runId,previous),"SALES_VERSION_PROTECTED");
SelfopSupport.require(mapper.activeReferences(scope.getSourceAccount(),scope.getWarehouseCode(),runId)==0,"SALES_INPUT_REFERENCED");
int size=Math.max(1,Math.min(500,config.getPersistenceBatchSize()));
return new Deleted(runId,mapper.deleteDaily(runId,size),mapper.deleteProfiles(runId,size));
}
private void validateScope(SelfopSyncScope scope) {
SelfopSupport.require(scope!=null && "OUTBOUND".equals(scope.getDataFamily()),"SALES_RETENTION_SCOPE_INVALID");
}
}
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.selfoperated.analytics.statistics;
import java.math.BigDecimal;
import java.math.RoundingMode;
/** Quality precedes zero; denominator uses all 30 complete days. */
public final class InventoryCoveragePolicy {
private InventoryCoveragePolicy() {}
public record Result(BigDecimal average, BigDecimal days, String status) {}
public static Result calculate(String inventoryQuality, BigDecimal available, BigDecimal sales30,
boolean salesTrusted, boolean complete30, boolean observationEligible,
boolean unitConfirmed) {
if (!"VALID".equals(inventoryQuality) || available == null)
return new Result(null, null, "INVENTORY_" + inventoryQuality);
if (available.signum() < 0) return new Result(null, null, "INVENTORY_INVALID");
BigDecimal average = unitConfirmed && salesTrusted && complete30 && observationEligible && sales30 != null
? sales30.divide(BigDecimal.valueOf(30), 12, RoundingMode.HALF_UP) : null;
if (available.signum() == 0) return new Result(average, BigDecimal.ZERO.setScale(1), "NO_AVAILABLE_INVENTORY");
if (!unitConfirmed) return new Result(null, null, "UNIT_UNKNOWN_OR_CONFLICT");
if (!salesTrusted) return new Result(null, null, "SALES_UNTRUSTED");
if (!complete30) return new Result(null, null, "COVERAGE_INSUFFICIENT");
if (!observationEligible) return new Result(null, null, "OBSERVATION_INSUFFICIENT");
if (sales30 == null) return new Result(null, null, "SALES_UNKNOWN");
if (sales30.signum() == 0) return new Result(average, null, "NO_OUTBOUND_30_DAYS");
// Algebraically stock / (sales / 30), avoiding intermediate rounding.
return new Result(average, available.multiply(BigDecimal.valueOf(30)).divide(sales30, 1, RoundingMode.HALF_UP), "VALID");
}
}
@@ -0,0 +1,104 @@
package com.baiyue.lastleg.selfoperated.analytics.statistics;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
/** Daily quantities distinguish complete facts, observed facts and unknown history. */
public final class SalesBucketPolicy {
private SalesBucketPolicy() {}
public record Input(LocalDate date, LocalDate anchorDate, ZoneId zone,
LocalDate completeDayFrom, LocalDate completeDayUntilExclusive,
Instant coveredFrom, Instant coveredUntil, Instant evidenceAt,
BigDecimal actualQuantity, Long actualOrderCount, boolean unitConfirmed,
boolean salesKnown, boolean rebuild, boolean stale) {}
public record Point(LocalDate date, BigDecimal outboundQuantity, BigDecimal observedQuantity,
Long orderCount, String dataStatus, List<String> reasonCodes,
Instant provedFrom, Instant provedUntil) {}
public record Summary(BigDecimal outboundQuantity, BigDecimal observedQuantity, String dataStatus,
List<String> reasonCodes, int completeDays, int partialDays, int unknownDays) {}
public static Point evaluate(Input input) {
Objects.requireNonNull(input.date(), "date");
Objects.requireNonNull(input.anchorDate(), "anchorDate");
Objects.requireNonNull(input.zone(), "zone");
if (input.date().isAfter(input.anchorDate())) throw new IllegalArgumentException("Future bucket");
Instant start = input.date().atStartOfDay(input.zone()).toInstant();
Instant end = input.date().plusDays(1).atStartOfDay(input.zone()).toInstant();
Instant provedFrom = input.coveredFrom() == null ? null : later(start, input.coveredFrom());
Instant provedUntil = input.coveredUntil() == null ? null : earlier(end, input.coveredUntil());
boolean intersection = provedFrom != null && provedUntil != null && provedFrom.isBefore(provedUntil);
if (!intersection) { provedFrom = null; provedUntil = null; }
List<String> reasons = new ArrayList<>();
if (input.rebuild()) reasons.add("SALES_REBUILD_REQUIRED");
if (input.stale()) reasons.add("SALES_STALE");
if (!input.unitConfirmed()) {
reasons.add("UNIT_UNKNOWN_OR_CONFLICT");
return point(input, null, null, null, "UNKNOWN", reasons, provedFrom, provedUntil);
}
if (!input.salesKnown()) {
reasons.add("SALES_NOT_COVERED");
return point(input, null, null, null, "UNKNOWN", reasons, provedFrom, provedUntil);
}
boolean today = input.date().equals(input.anchorDate());
boolean complete = !today && input.completeDayFrom() != null && input.completeDayUntilExclusive() != null
&& !input.date().isBefore(input.completeDayFrom())
&& input.date().isBefore(input.completeDayUntilExclusive())
&& input.coveredFrom() != null && !input.coveredFrom().isAfter(start)
&& input.coveredUntil() != null && !input.coveredUntil().isBefore(end);
boolean actual = input.actualQuantity() != null && input.actualQuantity().signum() >= 0;
if (input.actualQuantity() != null && !actual) {
reasons.add("SALES_QUANTITY_INVALID");
return point(input, null, null, null, "UNKNOWN", reasons, provedFrom, provedUntil);
}
if (complete && (actual || input.evidenceAt() != null && !input.evidenceAt().isAfter(start))) {
BigDecimal quantity = actual ? input.actualQuantity() : BigDecimal.ZERO;
return point(input, quantity, quantity, actual ? input.actualOrderCount() : 0L,
"COMPLETE", reasons, provedFrom, provedUntil);
}
if (today) reasons.add("TODAY_INCOMPLETE");
else reasons.add("SALES_DAY_INCOMPLETE");
if (actual) return point(input, null, input.actualQuantity(), input.actualOrderCount(),
"PARTIAL", reasons, provedFrom, provedUntil);
if (intersection && input.evidenceAt() != null && later(provedFrom, input.evidenceAt()).isBefore(provedUntil)) {
reasons.add("SKU_PARTIAL_OBSERVATION");
return point(input, null, BigDecimal.ZERO, 0L, "PARTIAL", reasons, provedFrom, provedUntil);
}
reasons.add(input.evidenceAt() == null || !input.evidenceAt().isBefore(end)
? "SKU_HISTORY_UNKNOWN" : "SALES_NOT_COVERED");
return point(input, null, null, null, "UNKNOWN", reasons, provedFrom, provedUntil);
}
public static Summary aggregate(List<Point> points) {
Objects.requireNonNull(points, "points");
int complete = 0, partial = 0, unknown = 0;
BigDecimal outbound = BigDecimal.ZERO, observed = BigDecimal.ZERO;
boolean hasObserved = false;
LinkedHashSet<String> reasons = new LinkedHashSet<>();
for (Point point : points) {
reasons.addAll(point.reasonCodes());
switch (point.dataStatus()) {
case "COMPLETE" -> { complete++; outbound = outbound.add(point.outboundQuantity()); }
case "PARTIAL" -> partial++;
case "UNKNOWN" -> unknown++;
default -> throw new IllegalArgumentException("Unknown dataStatus");
}
if (point.observedQuantity() != null) { observed = observed.add(point.observedQuantity()); hasObserved = true; }
}
boolean allComplete = !points.isEmpty() && complete == points.size();
return new Summary(allComplete ? outbound : null, hasObserved ? observed : null,
allComplete ? "COMPLETE" : hasObserved ? "PARTIAL" : "UNKNOWN",
List.copyOf(reasons), complete, partial, unknown);
}
private static Point point(Input input, BigDecimal outbound, BigDecimal observed, Long count,
String status, List<String> reasons, Instant from, Instant until) {
return new Point(input.date(), outbound, observed, count, status, List.copyOf(reasons), from, until);
}
private static Instant later(Instant a, Instant b) { return a.isAfter(b) ? a : b; }
private static Instant earlier(Instant a, Instant b) { return a.isBefore(b) ? a : b; }
}
@@ -0,0 +1,51 @@
package com.baiyue.lastleg.selfoperated.analytics.statistics;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.time.temporal.TemporalAdjusters;
import java.util.Objects;
/** Natural-day query boundaries, resolved once for the fixed query version pair. */
public final class SalesRangePolicy {
private SalesRangePolicy() {}
public enum Preset { TODAY, LAST_3_DAYS, THIS_WEEK, LAST_15_DAYS, THIS_MONTH, CUSTOM }
public record Range(LocalDate anchorDate, LocalDate startDate, LocalDate endDateExclusive,
int dayCount, Instant startAt, Instant endAtExclusive) {}
public static Range resolve(String rangePreset, LocalDate startDate, LocalDate endDate,
String bucket, Instant queryAsOf, ZoneId zone, int maxQueryDays) {
Objects.requireNonNull(queryAsOf, "queryAsOf");
Objects.requireNonNull(zone, "zone");
if (maxQueryDays < 1 || maxQueryDays > 366) throw new IllegalArgumentException("maxQueryDays must be 1..366");
if (!"DAY".equals(bucket)) throw new IllegalArgumentException("bucket must be DAY");
Preset preset;
try { preset = Preset.valueOf(rangePreset); }
catch (RuntimeException ex) { throw new IllegalArgumentException("Unknown rangePreset", ex); }
LocalDate anchor = queryAsOf.atZone(zone).toLocalDate();
LocalDate start;
LocalDate end = anchor;
if (preset == Preset.CUSTOM) {
if (startDate == null || endDate == null) throw new IllegalArgumentException("CUSTOM requires both dates");
start = startDate;
end = endDate;
} else {
if (startDate != null || endDate != null) throw new IllegalArgumentException("Preset dates conflict with explicit dates");
start = switch (preset) {
case TODAY -> anchor;
case LAST_3_DAYS -> anchor.minusDays(2);
case THIS_WEEK -> anchor.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
case LAST_15_DAYS -> anchor.minusDays(14);
case THIS_MONTH -> anchor.withDayOfMonth(1);
case CUSTOM -> throw new IllegalStateException("CUSTOM handled above");
};
}
long days = ChronoUnit.DAYS.between(start, end) + 1;
if (end.isAfter(anchor) || days < 1 || days > maxQueryDays) throw new IllegalArgumentException("Invalid query date range");
LocalDate exclusive = end.plusDays(1);
return new Range(anchor, start, exclusive, (int) days, start.atStartOfDay(zone).toInstant(),
exclusive.atStartOfDay(zone).toInstant());
}
}
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.selfoperated.analytics.sync;
import java.time.Duration;
import java.time.Instant;
/** Per-dispatch budget: cursors survive this object's lifetime in task manifests. */
public final class SelfopBudget {
private final int limit;
private final Instant deadline;
private int requests;
public SelfopBudget(int limit,int seconds) {
if(limit<=0||seconds<=0)throw new IllegalArgumentException("SELFOP_BUDGET_INVALID");
this.limit=limit;this.deadline=Instant.now().plusSeconds(seconds);
}
public boolean take() {if(requests>=limit||!Instant.now().isBefore(deadline))return false;requests++;return true;}
public int used(){return requests;}
}
@@ -0,0 +1,65 @@
package com.baiyue.lastleg.selfoperated.analytics.sync;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
@Service
public class SelfopCommandService {
public record Command(String requestKey,String warehouseCode,String action,String taskNo,
LocalDateTime sourceFrom,LocalDateTime sourceUntil,String customerCode,String deliveryNo,String reason) { }
private final SelfopTaskService taskService;
private final SelfopSyncScopeMapper scopes;
private final SelfopOutboundDiscoveryScopeMapper accounts;
private final SelfopAnalyticsProperties config;
private final SelfopSyncTaskMapper tasks;
public SelfopCommandService(SelfopTaskService taskService,SelfopSyncScopeMapper scopes,SelfopOutboundDiscoveryScopeMapper accounts,SelfopAnalyticsProperties config,SelfopSyncTaskMapper tasks){
this.taskService=taskService;this.scopes=scopes;this.accounts=accounts;this.config=config;this.tasks=tasks;
}
@Transactional
public SelfopSyncTask request(Command c,String operator){
identity(c.requestKey(),100);identity(c.warehouseCode(),64);require(config.getWarehouses().containsKey(c.warehouseCode()),"SELFOP_WAREHOUSE_UNCONFIGURED");
taskService.initialize();
if("RETRY".equals(c.action())){identity(c.taskNo(),64);identity(c.reason(),500);
var original=tasks.selectOne(new QueryWrapper<SelfopSyncTask>().eq("task_no",c.taskNo()).eq("source_account",config.getSourceAccount()));require(original!=null,"SELFOP_TASK_SCOPE_MISMATCH");
require("WAREHOUSE_SYNC".equals(original.getTaskRole())&&c.warehouseCode().equals(original.getWarehouseCode()),"SELFOP_TASK_SCOPE_MISMATCH");
require(!"WAITING_SOURCE".equals(original.getStatus()),"SELFOP_RETRY_WAITING_SOURCE");
return taskService.retry(original.getId(),c.requestKey(),operator,c.reason());}
require(Set.of("INVENTORY","BACKFILL","RECHECK","REBUILD","VALIDATION").contains(c.action()),"SELFOP_COMMAND_INVALID");
String purpose=SelfopCollectionPolicy.configuredPurpose(config);
require(("VALIDATION".equals(purpose))==("VALIDATION".equals(c.action())),"SELFOP_VALIDATION_ENTRY_REQUIRED");
var scope=scopes.selectOne(new QueryWrapper<SelfopSyncScope>().eq("source_account",config.getSourceAccount()).eq("warehouse_code",c.warehouseCode())
.eq("data_family","INVENTORY".equals(c.action())?"INVENTORY":"OUTBOUND"));
var task=new SelfopSyncTask();task.setSourceAccount(config.getSourceAccount());task.setRequestedBy(operator);task.setRequestKey("manual:"+c.requestKey());
task.setExecutionPurpose(purpose);
task.setTaskType("INVENTORY".equals(c.action())?"BACKFILL":c.action());task.setScopeHash(scope.getScopeHash());
if(Set.of("INVENTORY","REBUILD").contains(c.action())){
task.setTaskRole("WAREHOUSE_SYNC");task.setScopeId(scope.getId());task.setWarehouseCode(c.warehouseCode());task.setQueryScopeJson(scope.getScopeJson());
}else{
var account=accounts.selectOne(new QueryWrapper<SelfopOutboundDiscoveryScope>().eq("source_account",config.getSourceAccount()));
task.setTaskRole("ACCOUNT_DISCOVERY");task.setDiscoveryScopeId(account.getId());
if("VALIDATION".equals(c.action())){
require(c.sourceFrom()!=null&&c.sourceUntil()!=null&&c.sourceFrom().isBefore(c.sourceUntil())&&!c.sourceUntil().isAfter(c.sourceFrom().plusDays(1)),"SELFOP_QUERY_WINDOW_INVALID");
task.setTaskType("VALIDATION");task.setQueryScopeJson(json(Map.of("startTime",c.sourceFrom().toString(),"endTime",c.sourceUntil().toString(),"warehouses",config.getWarehouses().keySet())));
}else if("RECHECK".equals(c.action())){identity(c.customerCode(),64);identity(c.deliveryNo(),128);
task.setQueryScopeJson(json(new SelfopDiscoveryService.Query(null,null,c.deliveryNo(),c.customerCode(),null)));
}else{
require(c.sourceFrom()!=null&&c.sourceUntil()!=null&&c.sourceFrom().isBefore(c.sourceUntil()),"SELFOP_QUERY_WINDOW_INVALID");
require(!c.sourceUntil().isAfter(c.sourceFrom().plusDays(1)),"SELFOP_SLICE_MAX_ONE_DAY");
task.setQueryScopeJson(json(new SelfopDiscoveryService.Query(c.sourceFrom().toString(),c.sourceUntil().toString(),null,null,null)));
if(config.getSourceQueryTimezone()!=null){
ZoneId source=ZoneId.of(config.getSourceQueryTimezone());
require(source.getRules().getValidOffsets(c.sourceFrom()).size()==1&&source.getRules().getValidOffsets(c.sourceUntil()).size()==1,"SELFOP_SOURCE_TIME_AMBIGUOUS");
task.setQueryFromUtc(c.sourceFrom().atZone(source).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime());
task.setQueryUntilUtc(c.sourceUntil().atZone(source).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime());
}
}
}
task.setRequestHash(hash(Arrays.asList(c,task.getScopeHash())));return taskService.create(task);
}
}
@@ -0,0 +1,235 @@
package com.baiyue.lastleg.selfoperated.analytics.sync;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.selfoperated.analytics.client.LingxingAnalyticsClient;
import com.baiyue.lastleg.selfoperated.analytics.client.AnalyticsClientException;
import com.baiyue.lastleg.selfoperated.analytics.outbound.OutboundPolicy;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.util.*;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
@Service
public class SelfopDiscoveryService {
public record Query(String startTime,String endTime,String deliveryNo,String customerCode,Long parentTaskId) { }
private final LingxingAnalyticsClient client;
private final SelfopSyncTaskMapper tasks;
private final SelfopSyncRunMapper runs;
private final SelfopSyncStageMapper stages;
private final SelfopOutboundOrderMapper orders;
private final SelfopSyncScopeMapper scopes;
private final SelfopOutboundDiscoveryScopeMapper accounts;
private final SelfopTaskService taskService;
private final SelfopLeaseService leases;
private final SelfopAnalyticsProperties config;
private final TransactionTemplate tx;
public SelfopDiscoveryService(LingxingAnalyticsClient client,SelfopSyncTaskMapper tasks,SelfopSyncRunMapper runs,
SelfopSyncStageMapper stages,SelfopOutboundOrderMapper orders,SelfopSyncScopeMapper scopes,
SelfopOutboundDiscoveryScopeMapper accounts,SelfopTaskService taskService,SelfopLeaseService leases,
SelfopAnalyticsProperties config,TransactionTemplate tx) {
this.client=client;this.tasks=tasks;this.runs=runs;this.stages=stages;this.orders=orders;this.scopes=scopes;
this.accounts=accounts;this.taskService=taskService;this.leases=leases;this.config=config;this.tx=tx;
}
/** Network requests are outside tx; every accepted cursor/stage write checks the account fence again. */
public void collect(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease) {
SelfopCollectionPolicy.requireBusiness(task);SelfopCollectionPolicy.requireBusiness(run);
var a=accounts.selectById(task.getDiscoveryScopeId());var budget=new SelfopBudget(a.getRequestBudget(),a.getTimeBudgetSeconds());
collect(task,run,lease,budget);
}
private void collect(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease,SelfopBudget budget) {
Query query=read(task.getQueryScopeJson(),Query.class);
var manifest=task.getRoutingManifestJson()==null?new SelfopRoutingManifest():read(task.getRoutingManifestJson(),SelfopRoutingManifest.class);
if(query.deliveryNo()!=null) {if(!collectLocated(task,run,lease,budget,query,manifest))return;}
else {
while(!manifest.isDirectoryComplete()) {
if(!budget.take()){taskService.yield(task.getId(),run.getId(),lease);return;}
leases.renew(lease);
var page=client.discover(SelfopRequestContext.forTask(task,run,config),LocalDateTime.parse(query.startTime()),LocalDateTime.parse(query.endTime()),manifest.getNextPage(),config.getPageSize());
require(page.current()==manifest.getNextPage()&&page.size()==config.getPageSize(),"SELFOP_PAGE_MISMATCH");
require(page.total()!=null&&page.pages()!=null,"SELFOP_PAGE_METADATA_MISSING");
if(manifest.getTotal()==null){manifest.setTotal(page.total());manifest.setPages(page.pages());}
require(Objects.equals(manifest.getTotal(),page.total())&&Objects.equals(manifest.getPages(),page.pages()),"SELFOP_PAGE_TOTAL_CHANGED");
tx.executeWithoutResult(s->{leases.verify(lease);
for(var summary:page.records()) {
identity(summary.customerCode(),64);identity(summary.deliveryNo(),128);identity(summary.warehouseCode(),64);
String key=hash(List.of(task.getSourceAccount(),summary.customerCode(),summary.deliveryNo()));String content=hash(summary);
if(manifest.getScan()==1){
require(!manifest.getFirstScan().containsKey(key),"SELFOP_DIRECTORY_DUPLICATE");manifest.getFirstScan().put(key,content);
var payload=new SelfopPayloads.Order(task.getSourceAccount(),summary.customerCode(),summary.warehouseCode(),summary.deliveryNo(),summary.sourceNo(),summary.sourceStatus(),summary.sourceCreatedTime(),summary.sourceOutboundTime(),null);
var stage=stage(task,run,payload,manifest.getNextPage(),null,"PENDING");
var prior=stages.selectOne(new QueryWrapper<SelfopSyncStage>().eq("stage_batch_id",task.getId()).eq("object_type","OUTBOUND_ORDER").eq("object_key_hash",key));
if(prior==null)stages.insert(stage);else if("PENDING".equals(prior.getValidationStatus())){
stage.setId(prior.getId());stage.setRunId(prior.getRunId());stage.setCreatedAt(prior.getCreatedAt());stages.updateById(stage);
}else stage=prior;
var entry=new SelfopRoutingManifest.Entry();entry.setCustomerCode(summary.customerCode());entry.setDeliveryNo(summary.deliveryNo());
entry.setWarehouseCode(summary.warehouseCode());entry.setDirectoryStageId(stage.getId());manifest.getObjects().put(key,entry);
}else{
require(Objects.equals(manifest.getFirstScan().get(key),content),"SELFOP_DIRECTORY_UNSTABLE");
require(!manifest.getObjects().containsKey("seen:"+key),"SELFOP_DIRECTORY_DUPLICATE");
manifest.getObjects().put("seen:"+key,new SelfopRoutingManifest.Entry());
}
}
manifest.setNextPage(manifest.getNextPage()+1);
if(manifest.getPages()==0||manifest.getNextPage()>manifest.getPages()) {
long count=manifest.getScan()==1?manifest.getFirstScan().size():manifest.getObjects().keySet().stream().filter(k->k.startsWith("seen:")).count();
require(count==manifest.getTotal(),"SELFOP_DIRECTORY_COUNT_MISMATCH");
if(manifest.getScan()==1){manifest.setScan(2);manifest.setNextPage(1);}
else{manifest.getObjects().keySet().removeIf(k->k.startsWith("seen:"));manifest.setDirectoryComplete(true);}
}
saveManifest(task,manifest);
});
}
}
route(task,manifest,lease);
for(var entry:manifest.getObjects().entrySet()) {
var ref=entry.getValue();
if(ref.getRefreshTaskId()!=null){
var refresh=tasks.selectById(ref.getRefreshTaskId());require(refresh!=null,"SELFOP_REFRESH_TASK_MISSING");
if(!"SUCCEEDED".equals(refresh.getStatus())){
var refreshRun=taskService.begin(refresh.getId(),lease);refresh=tasks.selectById(refresh.getId());collect(refresh,refreshRun,lease,budget);
refresh=tasks.selectById(refresh.getId());
if(!"SUCCEEDED".equals(refresh.getStatus())){taskService.yield(task.getId(),run.getId(),lease);return;}
}
var refreshed=read(refresh.getRoutingManifestJson(),SelfopRoutingManifest.class).getObjects().get(entry.getKey());
require(refreshed!=null&&refreshed.getEffectiveStageId()!=null,"SELFOP_REFRESH_REFERENCE_MISSING");
ref.setEffectiveStageId(refreshed.getEffectiveStageId());ref.setWarehouseCode(refreshed.getWarehouseCode());
tx.executeWithoutResult(s->{leases.verify(lease);saveManifest(task,manifest);});
}
var latest=stages.selectOne(new QueryWrapper<SelfopSyncStage>().eq("object_type","OUTBOUND_ORDER")
.eq("object_key_hash",entry.getKey()).eq("validation_status","VALID").isNotNull("observation_sequence").orderByDesc("observation_sequence").last("LIMIT 1"));
var effective=ref.getEffectiveStageId()==null?null:stages.selectById(ref.getEffectiveStageId());
boolean newer=latest!=null&&(effective==null?latest.getObservationSequence()>task.getObservationBaseline():latest.getObservationSequence()>effective.getObservationSequence());
if(effective!=null&&!newer) continue;
if(!budget.take()){taskService.yield(task.getId(),run.getId(),lease);return;}
leases.renew(lease);
SelfopSyncTask fetchTask=task;SelfopSyncRun fetchRun=run;
if(newer){
var recheck=new SelfopSyncTask();recheck.setSourceAccount(task.getSourceAccount());recheck.setTaskRole("ACCOUNT_DISCOVERY");
recheck.setExecutionPurpose(task.getExecutionPurpose());
recheck.setDiscoveryScopeId(task.getDiscoveryScopeId());recheck.setTaskType("RECHECK");recheck.setScopeHash(task.getScopeHash());
recheck.setRequestKey("refresh:"+task.getId()+":"+entry.getKey().substring(0,24)+":"+latest.getObservationSequence());
recheck.setQueryScopeJson(json(new Query(null,null,ref.getDeliveryNo(),ref.getCustomerCode(),task.getId())));recheck.setRequestHash(hash(recheck.getQueryScopeJson()));
fetchTask=taskService.create(recheck);ref.setRefreshTaskId(fetchTask.getId());ref.setNewestSeenSequence(latest.getObservationSequence());
fetchRun=taskService.begin(fetchTask.getId(),lease);
fetchTask.setAttemptCount(fetchRun.getAttemptNo());fetchTask.setStatus("RUNNING");
}
long observation=leases.nextObservation(lease);
SelfopPayloads.Order detail;
try {detail=client.detail(SelfopRequestContext.forTask(fetchTask,fetchRun,config),ref.getWarehouseCode(),ref.getCustomerCode(),ref.getDeliveryNo());}
catch(AnalyticsClientException staleIdentity){
if(!Set.of("DETAIL_IDENTITY_MISMATCH","INTEGRATION_DATA_MISSING","LINGXING_WMS_SOURCE_REJECTED","LINGXING_WMS_INVALID_RESPONSE").contains(staleIdentity.code())||staleIdentity.retryable())throw staleIdentity;
var relocate=new SelfopSyncTask();relocate.setSourceAccount(task.getSourceAccount());relocate.setTaskRole("ACCOUNT_DISCOVERY");relocate.setDiscoveryScopeId(task.getDiscoveryScopeId());
relocate.setExecutionPurpose(task.getExecutionPurpose());
relocate.setTaskType("RECHECK");relocate.setScopeHash(task.getScopeHash());relocate.setRequestKey("relocate:"+task.getId()+":"+entry.getKey().substring(0,24)+":"+observation);
var locateQuery=new Query(null,null,ref.getDeliveryNo(),ref.getCustomerCode(),task.getId());relocate.setQueryScopeJson(json(locateQuery));relocate.setRequestHash(hash(relocate.getQueryScopeJson()));
fetchTask=taskService.create(relocate);fetchRun=taskService.begin(fetchTask.getId(),lease);ref.setRefreshTaskId(fetchTask.getId());
fetchTask.setAttemptCount(fetchRun.getAttemptNo());fetchTask.setStatus("RUNNING");
tx.executeWithoutResult(s->{leases.verify(lease);saveManifest(task,manifest);});
var located=new SelfopRoutingManifest();
if(!collectLocated(fetchTask,fetchRun,lease,budget,locateQuery,located)){tx.executeWithoutResult(s->{leases.verify(lease);saveManifest(task,manifest);});taskService.yield(task.getId(),run.getId(),lease);return;}
ref.setWarehouseCode(located.getObjects().values().iterator().next().getWarehouseCode());
tx.executeWithoutResult(s->{leases.verify(lease);saveManifest(task,manifest);});
if(!budget.take()){taskService.yield(fetchTask.getId(),fetchRun.getId(),lease);taskService.yield(task.getId(),run.getId(),lease);return;}
observation=leases.nextObservation(lease);detail=client.detail(SelfopRequestContext.forTask(fetchTask,fetchRun,config),ref.getWarehouseCode(),ref.getCustomerCode(),ref.getDeliveryNo());
}
require(Objects.equals(detail.sourceAccount(),task.getSourceAccount())&&Objects.equals(detail.customerCode(),ref.getCustomerCode())
&&Objects.equals(detail.deliveryNo(),ref.getDeliveryNo()),"SELFOP_DETAIL_IDENTITY_MISMATCH");
require(detail.items()!=null&&detail.sourceStatus()!=null,"SELFOP_DETAIL_STRUCTURE_INVALID");
identity(detail.warehouseCode(),64);
var finalFetchTask=fetchTask;var finalFetchRun=fetchRun;var acceptedDetail=detail;long acceptedObservation=observation;
tx.executeWithoutResult(s->{leases.verify(lease);
var existing=stages.selectOne(new QueryWrapper<SelfopSyncStage>().eq("stage_batch_id",finalFetchTask.getId()).eq("object_type","OUTBOUND_ORDER").eq("object_key_hash",entry.getKey()));
SelfopSyncStage full;
if(existing!=null&&"VALID".equals(existing.getValidationStatus())){
require(existing.getContentHash().equals(hash(acceptedDetail)),"SELFOP_VALID_STAGE_IMMUTABLE");full=existing;
}else{
full=stage(finalFetchTask,finalFetchRun,acceptedDetail,existing==null?1:existing.getFirstPageNo(),acceptedObservation,"VALID");
if(existing!=null){full.setId(existing.getId());full.setRunId(existing.getRunId());full.setCreatedAt(existing.getCreatedAt());stages.updateById(full);}else stages.insert(full);
}
ref.setEffectiveStageId(full.getId());ref.setWarehouseCode(acceptedDetail.warehouseCode());saveManifest(task,manifest);
if(!finalFetchTask.getId().equals(task.getId())){var refreshManifest=new SelfopRoutingManifest();refreshManifest.setDirectoryComplete(true);refreshManifest.setPages(1);refreshManifest.setTotal(1L);
var refreshRef=new SelfopRoutingManifest.Entry();refreshRef.setCustomerCode(ref.getCustomerCode());refreshRef.setDeliveryNo(ref.getDeliveryNo());refreshRef.setWarehouseCode(ref.getWarehouseCode());refreshRef.setEffectiveStageId(full.getId());
refreshManifest.getObjects().put(entry.getKey(),refreshRef);finalFetchTask.setRoutingManifestJson(json(refreshManifest));
SelfopProgressProjection.freezeRun(finalFetchTask,finalFetchRun);finalFetchRun.setStatus("ROUTED");finalFetchRun.setFetchFinishedAtUtc(utcNow());finalFetchRun.setFinishedAtUtc(utcNow());finalFetchRun.setUpdatedAt(utcNow());runs.updateById(finalFetchRun);
finalFetchTask.setStatus("SUCCEEDED");finalFetchTask.setUpdatedAt(utcNow());tasks.updateById(finalFetchTask);}
});
route(task,manifest,lease);
}
tx.executeWithoutResult(s->{leases.verify(lease);var now=utcNow();
SelfopCollectionPolicy.requireBusiness(task);SelfopCollectionPolicy.requireBusiness(run);SelfopProgressProjection.freezeRun(task,run);
run.setStatus("ROUTED");run.setCoverageStatus("COMPLETE");run.setPageCount(manifest.getPages()==null?0:manifest.getPages()*2);
run.setSourceTotalCount(manifest.getTotal());run.setUniqueObjectCount((long)manifest.getObjects().size());run.setFetchFinishedAtUtc(now);run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run);
task.setStatus("SUCCEEDED");task.setUpdatedAt(now);tasks.updateById(task);
var account=accounts.selectById(task.getDiscoveryScopeId());
if(task.getQueryFromUtc()!=null&&task.getQueryUntilUtc()!=null&&
(account.getCatalogUntilUtc()==null||!task.getQueryFromUtc().isAfter(account.getCatalogUntilUtc()))) {
account.setCatalogFromUtc(account.getCatalogFromUtc()==null?task.getQueryFromUtc():account.getCatalogFromUtc());
account.setCatalogUntilUtc(account.getCatalogUntilUtc()==null||task.getQueryUntilUtc().isAfter(account.getCatalogUntilUtc())?task.getQueryUntilUtc():account.getCatalogUntilUtc());
account.setUpdatedAt(now);accounts.updateById(account);}
});
}
private boolean collectLocated(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease,SelfopBudget budget,Query query,SelfopRoutingManifest manifest) {
if(manifest.isDirectoryComplete())return true;
// Identity relocation consumes the full paginated result before trusting one match.
while(!manifest.isDirectoryComplete()){
if(!budget.take()){taskService.yield(task.getId(),run.getId(),lease);return false;}leases.renew(lease);
var page=client.locate(SelfopRequestContext.forTask(task,run,config),query.deliveryNo(),manifest.getNextPage(),config.getPageSize());
require(page.total()!=null&&page.pages()!=null,"SELFOP_PAGE_METADATA_MISSING");
if(manifest.getTotal()==null){manifest.setTotal(page.total());manifest.setPages(page.pages());}
require(Objects.equals(manifest.getTotal(),page.total())&&Objects.equals(manifest.getPages(),page.pages()),"SELFOP_PAGE_TOTAL_CHANGED");
for(var summary:page.records())if(query.deliveryNo().equals(summary.deliveryNo())&&query.customerCode().equals(summary.customerCode())){
String key=hash(List.of(task.getSourceAccount(),summary.customerCode(),summary.deliveryNo()));require(!manifest.getObjects().containsKey(key),"SELFOP_LOCATE_AMBIGUOUS");
var e=new SelfopRoutingManifest.Entry();e.setCustomerCode(summary.customerCode());e.setDeliveryNo(summary.deliveryNo());e.setWarehouseCode(summary.warehouseCode());manifest.getObjects().put(key,e);}
manifest.setNextPage(manifest.getNextPage()+1);if(page.pages()==0||manifest.getNextPage()>page.pages()){
require(manifest.getObjects().size()==1,"SELFOP_LOCATE_UNRESOLVED");manifest.setDirectoryComplete(true);}
tx.executeWithoutResult(s->{leases.verify(lease);saveManifest(task,manifest);});
}
return true;
}
private SelfopSyncStage stage(SelfopSyncTask task,SelfopSyncRun run,SelfopPayloads.Order payload,int page,Long observation,String status){
var s=new SelfopSyncStage();s.setId(IdWorker.getId());s.setRunId(run.getId());s.setStageBatchId(task.getId());s.setLastFetchRunId(run.getId());
s.setObservationSequence(observation);s.setWarehouseCode(payload.warehouseCode());s.setObjectType("OUTBOUND_ORDER");
s.setObjectKeyHash(hash(List.of(payload.sourceAccount(),payload.customerCode(),payload.deliveryNo())));s.setFirstPageNo(page);s.setObservedAtUtc(utcNow());
s.setContentHash(hash(payload));s.setPayloadJson(json(payload));s.setValidationStatus(status);s.setCreatedAt(utcNow());s.setUpdatedAt(utcNow());return s;
}
private void saveManifest(SelfopSyncTask task,SelfopRoutingManifest manifest){task.setRoutingManifestJson(json(manifest));task.setUpdatedAt(utcNow());
var active=runs.selectOne(new QueryWrapper<SelfopSyncRun>().eq("task_id",task.getId()).eq("status","FETCHING").orderByDesc("attempt_no").last("LIMIT 1"));
if(active!=null){SelfopProgressProjection.applyCheckpoint(task,active,manifest.isDirectoryComplete()?"DETAIL":"DIRECTORY",manifest.isDirectoryComplete()?"ORDER":"PAGE",
manifest.isDirectoryComplete()?manifest.getObjects().values().stream().filter(e->e.getEffectiveStageId()!=null).count():Math.max(0,manifest.getNextPage()-1),
manifest.isDirectoryComplete()?(long)manifest.getObjects().size():manifest.getPages()==null?null:manifest.getPages().longValue(),manifest.getScan());runs.updateById(active);}
tasks.updateById(task);}
private void route(SelfopSyncTask parent,SelfopRoutingManifest manifest,SelfopLeaseService.Lease lease){
// Refresh commands only replace a parent manifest reference; never create an independent
// fact writer that could publish around that parent's half-applied barrier.
if(read(parent.getQueryScopeJson(),Query.class).parentTaskId()!=null)return;
tx.executeWithoutResult(s->{leases.verify(lease);if(!manifest.isDirectoryComplete())return;
for(var scope:scopes.selectList(new QueryWrapper<SelfopSyncScope>().eq("source_account",parent.getSourceAccount()).eq("data_family","OUTBOUND"))){
var own=new SelfopRoutingManifest();own.setDirectoryComplete(true);own.setTotal(0L);own.setPages(manifest.getPages());
for(var e:manifest.getObjects().entrySet()){
var ref=e.getValue();var previous=orders.selectOne(new QueryWrapper<SelfopOutboundOrder>().eq("source_account",parent.getSourceAccount()).eq("customer_code",ref.getCustomerCode()).eq("delivery_no",ref.getDeliveryNo()));
if(scope.getWarehouseCode().equals(ref.getWarehouseCode())||(previous!=null&&scope.getWarehouseCode().equals(previous.getWarehouseCode()))){
boolean belongs=true;
if(ref.getEffectiveStageId()!=null){var payload=read(stages.selectById(ref.getEffectiveStageId()).getPayloadJson(),SelfopPayloads.Order.class);
belongs=OutboundPolicy.belongsToSlice(payload,scope,parent.getQueryFromUtc(),parent.getQueryUntilUtc(),previous!=null,parent.getTaskType());}
if(belongs)own.getObjects().put(e.getKey(),ref);
}
}
own.setTotal((long)own.getObjects().size());
boolean ready=scope.getSourceTimezone()!=null&&(parent.getQueryFromUtc()==null||Objects.equals(scope.getSourceTimezone(),config.getSourceQueryTimezone()))
&&own.getObjects().values().stream().allMatch(e->e.getEffectiveStageId()!=null);
var child=new SelfopSyncTask();child.setSourceAccount(parent.getSourceAccount());child.setTaskRole("WAREHOUSE_SYNC");child.setScopeId(scope.getId());child.setWarehouseCode(scope.getWarehouseCode());
child.setExecutionPurpose(parent.getExecutionPurpose());
child.setUpstreamTaskId(parent.getId());child.setDiscoverySequence(parent.getDiscoverySequence());child.setObservationBaseline(parent.getObservationBaseline());
child.setRequestKey("route:"+parent.getId()+":"+scope.getWarehouseCode());child.setTaskType(parent.getTaskType());child.setQueryFromUtc(parent.getQueryFromUtc());child.setQueryUntilUtc(parent.getQueryUntilUtc());
child.setQueryScopeJson(scope.getScopeJson());child.setScopeHash(scope.getScopeHash());child.setRequestHash(hash(List.of(parent.getId(),scope.getScopeHash())));child.setStatus(ready?"READY":"WAITING_SOURCE");child.setApplyState("NOT_STARTED");
var saved=taskService.create(child);
if(!"SUCCEEDED".equals(saved.getStatus())&& !Set.of("APPLYING","APPLIED","BLOCKED").contains(saved.getApplyState())){
saved.setRoutingManifestJson(json(own));saved.setStatus(ready?"READY":"WAITING_SOURCE");saved.setUpdatedAt(utcNow());tasks.updateById(saved);}
}
});
}
}
@@ -0,0 +1,101 @@
package com.baiyue.lastleg.selfoperated.analytics.sync;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baiyue.lastleg.selfoperated.analytics.client.AnalyticsClientException;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import com.baiyue.lastleg.selfoperated.analytics.outbound.*;
import com.baiyue.lastleg.selfoperated.analytics.inventory.InventorySnapshotService;
import org.springframework.stereotype.Service;
import java.util.*;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
@Service
public class SelfopExecutionService {
private final SelfopAnalyticsProperties config;
private final SelfopTaskService taskService;
private final SelfopLeaseService leases;
private final SelfopDiscoveryService discovery;
private final SelfopInventoryCollector stock;
private final InventorySnapshotService snapshots;
private final OutboundFactService facts;
private final OutboundCandidateBuilder candidates;
private final SelfopSalesPublisher publisher;
private final SelfopSyncScopeMapper scopes;
private final SelfopSyncTaskMapper tasks;
private final SelfopSyncStageMapper stages;
private final SelfopOutboundOrderMapper orders;
private final SelfopReferenceRecovery recovery;
private final SelfopValidationRunner validation;
private final SelfopSyncRunMapper runs;
public SelfopExecutionService(SelfopAnalyticsProperties config,SelfopTaskService taskService,SelfopLeaseService leases,
SelfopDiscoveryService discovery,SelfopInventoryCollector stock,InventorySnapshotService snapshots,OutboundFactService facts,
OutboundCandidateBuilder candidates,SelfopSalesPublisher publisher,SelfopSyncScopeMapper scopes,SelfopSyncTaskMapper tasks,
SelfopSyncStageMapper stages,SelfopOutboundOrderMapper orders,SelfopReferenceRecovery recovery,SelfopValidationRunner validation,SelfopSyncRunMapper runs){
this.config=config;this.taskService=taskService;this.leases=leases;this.discovery=discovery;this.stock=stock;this.snapshots=snapshots;
this.facts=facts;this.candidates=candidates;this.publisher=publisher;this.scopes=scopes;this.tasks=tasks;this.stages=stages;this.orders=orders;this.recovery=recovery;this.validation=validation;this.runs=runs;
}
public void execute(long taskId){
var task=tasks.selectById(taskId);require(task!=null,"SELFOP_TASK_MISSING");SelfopCollectionPolicy.requireExecution(config,task.getExecutionPurpose());
if("SUCCEEDED".equals(task.getStatus())||"DEAD".equals(task.getStatus()))return;
String owner=UUID.randomUUID().toString();Map<Long,SelfopLeaseService.Lease> held=new LinkedHashMap<>();SelfopLeaseService.Lease primary;
if("ACCOUNT_DISCOVERY".equals(task.getTaskRole())){primary=leases.acquireDiscovery(task.getDiscoveryScopeId(),owner);held.put(primary.scopeId(),primary);}
else {
var ids=new TreeSet<Long>();ids.add(task.getScopeId());
if("OUTBOUND".equals(scopes.selectById(task.getScopeId()).getDataFamily())){
var warehouses=new TreeSet<String>();warehouses.add(task.getWarehouseCode());
if(task.getAffectedWarehouseCodesJson()!=null)warehouses.addAll(read(task.getAffectedWarehouseCodesJson(),List.class));
if(task.getRoutingManifestJson()!=null){var manifest=read(task.getRoutingManifestJson(),SelfopRoutingManifest.class);
for(var ref:manifest.getObjects().values())if(ref.getEffectiveStageId()!=null){var stage=stages.selectById(ref.getEffectiveStageId());var p=read(stage.getPayloadJson(),SelfopPayloads.Order.class);
warehouses.add(p.warehouseCode());var old=orders.selectOne(new QueryWrapper<SelfopOutboundOrder>().eq("source_account",p.sourceAccount()).eq("customer_code",p.customerCode()).eq("delivery_no",p.deliveryNo()));
var latest=stages.selectOne(new QueryWrapper<SelfopSyncStage>().eq("object_type","OUTBOUND_ORDER").eq("object_key_hash",stage.getObjectKeyHash()).eq("validation_status","VALID").isNotNull("observation_sequence").orderByDesc("observation_sequence").last("LIMIT 1"));
if(latest!=null)warehouses.add(latest.getWarehouseCode());
if(old!=null)warehouses.add(old.getWarehouseCode());}}
for(String w:warehouses){var s=scopes.selectOne(new QueryWrapper<SelfopSyncScope>().eq("source_account",task.getSourceAccount()).eq("warehouse_code",w).eq("data_family","OUTBOUND"));
require(s!=null,"SELFOP_WAREHOUSE_UNCONFIGURED");ids.add(s.getId());}
}
held.putAll(leases.acquireScopes(ids,owner));primary=held.get(task.getScopeId());
}
SelfopSyncRun run=null;
try{
run=taskService.begin(taskId,primary);
task=tasks.selectById(taskId);
if("VALIDATION".equals(task.getExecutionPurpose())){validation.run(task,run,primary);return;}
if(!primary.account()&&"OUTBOUND".equals(scopes.selectById(task.getScopeId()).getDataFamily()))recovery.refresh(task,held);
if(primary.account())discovery.collect(task,run,primary);
else if("INVENTORY".equals(scopes.selectById(task.getScopeId()).getDataFamily())){
if(stock.collect(task,run,primary)){snapshots.build(task.getScopeId(),run.getId(),owner,primary.fence());snapshots.publish(task.getScopeId(),run.getId(),owner,primary.fence());}
}else{
Map<String,OutboundFactService.Lease> outbound=new TreeMap<>();for(var lease:held.values())outbound.put(scopes.selectById(lease.scopeId()).getWarehouseCode(),new OutboundFactService.Lease(owner,lease.fence()));
if(!"REBUILD".equals(task.getTaskType())){
var manifest=read(task.getRoutingManifestJson(),SelfopRoutingManifest.class);var ids=manifest.getObjects().values().stream().map(SelfopRoutingManifest.Entry::getEffectiveStageId).distinct().toList();
require(ids.stream().noneMatch(Objects::isNull),"SELFOP_DETAILS_INCOMPLETE");
if(!"APPLIED".equals(task.getApplyState())){
int batch=Math.max(1,Math.min(100,config.getApplyBatchSize()));for(int i=0;i<ids.size();i+=batch){for(var lease:held.values())leases.renew(lease);facts.merge(taskId,run.getId(),ids.subList(i,Math.min(ids.size(),i+batch)),outbound);}
facts.finish(taskId,run.getId(),ids,outbound);
}
}
var reused=candidates.reusable(task.getScopeId(),run.getId(),owner,primary.fence());
if(reused!=null){publisher.reuse(task.getScopeId(),run.getId(),reused,owner,primary.fence());return;}
var previousBuild=runs.selectOne(new QueryWrapper<SelfopSyncRun>().eq("scope_id",task.getScopeId()).isNotNull("build_started_at_utc").orderByDesc("build_started_at_utc").last("LIMIT 1"));
if(previousBuild!=null&&!OutboundCandidateBuilder.regularBuildDue(task,previousBuild.getBuildStartedAtUtc(),utcNow())){
taskService.waitForBudget(taskId,run.getId(),previousBuild.getBuildStartedAtUtc().plusMinutes(30),primary);return;
}
candidates.build(task.getScopeId(),run.getId(),owner,primary.fence());
if(config.isSalesPublicationEnabled())publisher.publish(task.getScopeId(),run.getId(),owner,primary.fence());
else taskService.candidateFinished(taskId,run.getId(),primary);
}
}catch(RuntimeException e){
if(run!=null&&"BUSINESS".equals(task.getExecutionPurpose())&& e instanceof com.baiyue.lastleg.selfoperated.analytics.request.EgressRejectedException q && q.waitUntilUtc()!=null){
try{taskService.waitForBudget(taskId,run.getId(),q.waitUntilUtc(),primary);}catch(IllegalStateException lostFence){ }return;
}
if(run!=null){String code=e instanceof AnalyticsClientException c?c.code():safeCode(e.getMessage());
boolean retryable=!(e instanceof AnalyticsClientException c)||c.retryable();
try{
if("VALIDATION".equals(task.getExecutionPurpose()))validation.fail(task,run,primary,code);
else taskService.fail(taskId,run.getId(),code,retryable,primary);
}catch(IllegalStateException lostFence){/* New lease owner decides recovery. */}}
}finally{for(var lease:held.values())leases.release(lease);}
}
private static String safeCode(String message){return message!=null&&message.matches("[A-Z][A-Z0-9_]{0,63}")?message:"SELFOP_EXECUTION_FAILED";}
}
@@ -0,0 +1,64 @@
package com.baiyue.lastleg.selfoperated.analytics.sync;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.selfoperated.analytics.client.LingxingAnalyticsClient;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.*;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
@Service
public class SelfopInventoryCollector {
private final LingxingAnalyticsClient client;
private final SelfopSyncStageMapper stages;
private final SelfopSyncTaskMapper tasks;
private final SelfopSyncRunMapper runs;
private final SelfopLeaseService leases;
private final SelfopTaskService taskService;
private final SelfopAnalyticsProperties config;
private final TransactionTemplate tx;
public SelfopInventoryCollector(LingxingAnalyticsClient client,SelfopSyncStageMapper stages,SelfopSyncTaskMapper tasks,
SelfopSyncRunMapper runs,SelfopLeaseService leases,SelfopTaskService taskService,SelfopAnalyticsProperties config,TransactionTemplate tx){
this.client=client;this.stages=stages;this.tasks=tasks;this.runs=runs;this.leases=leases;this.taskService=taskService;this.config=config;this.tx=tx;
}
public boolean collect(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease){
SelfopCollectionPolicy.requireBusiness(task);SelfopCollectionPolicy.requireBusiness(run);
var manifest=task.getRoutingManifestJson()==null?new SelfopRoutingManifest():read(task.getRoutingManifestJson(),SelfopRoutingManifest.class);
var budget=new SelfopBudget(config.getRequestBudget(),config.getTimeBudgetSeconds());
while(!manifest.isDirectoryComplete()){
if(!budget.take()){taskService.yield(task.getId(),run.getId(),lease);return false;}
leases.renew(lease);var page=client.inventory(SelfopRequestContext.forTask(task,run,config),task.getWarehouseCode(),manifest.getNextPage(),config.getPageSize());
require(page.current()==manifest.getNextPage()&&page.size()==config.getPageSize(),"SELFOP_PAGE_MISMATCH");
require(page.total()!=null&&page.pages()!=null,"SELFOP_PAGE_METADATA_MISSING");
if(manifest.getTotal()==null){manifest.setTotal(page.total());manifest.setPages(page.pages());}
require(Objects.equals(manifest.getTotal(),page.total())&&Objects.equals(manifest.getPages(),page.pages()),"SELFOP_PAGE_TOTAL_CHANGED");
tx.executeWithoutResult(s->{leases.verify(lease);
for(var row:page.records()){
require(task.getSourceAccount().equals(row.sourceAccount())&&task.getWarehouseCode().equals(row.warehouseCode()),"SELFOP_STOCK_SCOPE_LEAK");
identity(row.customerCode(),64);identity(row.skuCode(),128);require(row.productQuality()!=null,"SELFOP_STOCK_IDENTITY_MISSING");
String key=hash(List.of(row.sourceAccount(),row.customerCode(),row.warehouseCode(),row.skuCode(),row.productQuality()));
if(manifest.getScan()==1){require(!manifest.getFirstScan().containsKey(key),"SELFOP_STOCK_DUPLICATE");manifest.getFirstScan().put(key,hash(row));
var stage=new SelfopSyncStage();stage.setId(IdWorker.getId());stage.setRunId(run.getId());stage.setStageBatchId(run.getId());stage.setLastFetchRunId(run.getId());
stage.setWarehouseCode(row.warehouseCode());stage.setObjectType("PRODUCT_STOCK");stage.setObjectKeyHash(key);stage.setFirstPageNo(manifest.getNextPage());
stage.setObservedAtUtc(utcNow());stage.setContentHash(hash(row));stage.setPayloadJson(json(row));stage.setValidationStatus("VALID");stage.setCreatedAt(utcNow());stage.setUpdatedAt(utcNow());stages.insert(stage);
}else{require(Objects.equals(manifest.getFirstScan().get(key),hash(row)),"SELFOP_STOCK_SCAN_UNSTABLE");
require(!manifest.getObjects().containsKey(key),"SELFOP_STOCK_DUPLICATE");manifest.getObjects().put(key,new SelfopRoutingManifest.Entry());}
}
manifest.setNextPage(manifest.getNextPage()+1);
if(manifest.getPages()==0||manifest.getNextPage()>manifest.getPages()){
require((manifest.getScan()==1?manifest.getFirstScan().size():manifest.getObjects().size())==manifest.getTotal(),"SELFOP_STOCK_COUNT_MISMATCH");
if(manifest.getScan()==1){manifest.setScan(2);manifest.setNextPage(1);}else manifest.setDirectoryComplete(true);
}
task.setRoutingManifestJson(json(manifest));task.setUpdatedAt(utcNow());tasks.updateById(task);
SelfopProgressProjection.applyCheckpoint(task,run,"INVENTORY","PAGE",Math.max(0,manifest.getNextPage()-1),manifest.getPages()==null?null:manifest.getPages().longValue(),manifest.getScan());tasks.updateById(task);runs.updateById(run);
});
}
tx.executeWithoutResult(s->{leases.verify(lease);run.setStatus("VALIDATED");run.setLastPhase("VALIDATED");run.setCoverageStatus("COMPLETE");
run.setPageCount(Math.max(1,manifest.getPages()));run.setSourceTotalCount(manifest.getTotal());run.setUniqueObjectCount((long)manifest.getFirstScan().size());
run.setFetchFinishedAtUtc(utcNow());run.setUpdatedAt(utcNow());runs.updateById(run);
});return true;
}
}
@@ -0,0 +1,82 @@
package com.baiyue.lastleg.selfoperated.analytics.sync;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.*;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
@Service
public class SelfopLeaseService {
private final SelfopCoordinationMapper locks;
private final SelfopSyncScopeMapper scopes;
private final SelfopOutboundDiscoveryScopeMapper discoveries;
private final SelfopAnalyticsProperties config;
public SelfopLeaseService(SelfopCoordinationMapper locks, SelfopSyncScopeMapper scopes,
SelfopOutboundDiscoveryScopeMapper discoveries, SelfopAnalyticsProperties config) {
this.locks=locks; this.scopes=scopes; this.discoveries=discoveries; this.config=config;
}
public record Lease(long scopeId, String owner, long fence, boolean account) { }
@Transactional
public Lease acquireDiscovery(long id, String owner) {
var row=locks.lockDiscovery(id); require(row!=null,"SELFOP_SCOPE_MISSING");
var now=utcNow(); require(available(row.getLeaseOwner(),row.getLeaseUntilUtc(),now),"SELFOP_LEASE_BUSY");
long fence=row.getFenceToken()+1; row.setLeaseOwner(owner); row.setFenceToken(fence);
row.setLeaseUntilUtc(now.plusSeconds(config.getLeaseSeconds())); row.setUpdatedAt(now);
discoveries.updateById(row); return new Lease(id,owner,fence,true);
}
/** Atomic all-or-none acquisition; never hold one warehouse while waiting for another. */
@Transactional
public Map<Long,Lease> acquireScopes(Collection<Long> ids, String owner) {
var rows=ids.stream().distinct().map(scopes::selectById)
.sorted(Comparator.comparing(SelfopSyncScope::getSourceAccount)
.thenComparing(SelfopSyncScope::getWarehouseCode).thenComparing(SelfopSyncScope::getDataFamily)).toList();
Map<Long,Lease> result=new LinkedHashMap<>(); var now=utcNow();
for(var candidate:rows){
var row=locks.lockScope(candidate.getId());
require(available(row.getLeaseOwner(),row.getLeaseUntilUtc(),now),"SELFOP_LEASE_BUSY");
long fence=row.getFenceToken()+1; row.setLeaseOwner(owner); row.setFenceToken(fence);
row.setLeaseUntilUtc(now.plusSeconds(config.getLeaseSeconds())); row.setUpdatedAt(now);
scopes.updateById(row); result.put(row.getId(),new Lease(row.getId(),owner,fence,false));
}
return result;
}
@Transactional
public void renew(Lease lease) {
var now=utcNow();
if(lease.account()) {var s=locks.lockDiscovery(lease.scopeId()); check(lease,s.getLeaseOwner(),s.getFenceToken(),s.getLeaseUntilUtc(),now);
s.setLeaseUntilUtc(now.plusSeconds(config.getLeaseSeconds())); s.setUpdatedAt(now); discoveries.updateById(s);
} else {var s=locks.lockScope(lease.scopeId()); check(lease,s.getLeaseOwner(),s.getFenceToken(),s.getLeaseUntilUtc(),now);
s.setLeaseUntilUtc(now.plusSeconds(config.getLeaseSeconds())); s.setUpdatedAt(now); scopes.updateById(s);}
locks.heartbeat(lease.scopeId(),lease.account(),lease.fence(),now);
}
@Transactional
public void release(Lease lease) {
if(lease.account()) {var s=locks.lockDiscovery(lease.scopeId());
if(Objects.equals(s.getLeaseOwner(),lease.owner()) && s.getFenceToken()==lease.fence()) {
s.setLeaseOwner(null); s.setLeaseUntilUtc(null); s.setUpdatedAt(utcNow());
discoveries.update(null,new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<SelfopOutboundDiscoveryScope>()
.eq("id",s.getId()).set("lease_owner",null).set("lease_until_utc",null).set("updated_at",utcNow())); }
} else {var s=locks.lockScope(lease.scopeId());
if(Objects.equals(s.getLeaseOwner(),lease.owner()) && s.getFenceToken()==lease.fence())
scopes.update(null,new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<SelfopSyncScope>()
.eq("id",s.getId()).set("lease_owner",null).set("lease_until_utc",null).set("updated_at",utcNow())); }
}
@Transactional
public long nextObservation(Lease lease) {
require(lease.account(),"SELFOP_ACCOUNT_LEASE_REQUIRED"); var s=locks.lockDiscovery(lease.scopeId());
check(lease,s.getLeaseOwner(),s.getFenceToken(),s.getLeaseUntilUtc(),utcNow());
long seq=s.getNextObservationSequence(); s.setNextObservationSequence(seq+1); s.setUpdatedAt(utcNow());
discoveries.updateById(s); return seq;
}
public void verify(Lease lease) {
if(lease.account()) {var s=locks.lockDiscovery(lease.scopeId());check(lease,s.getLeaseOwner(),s.getFenceToken(),s.getLeaseUntilUtc(),utcNow());}
else {var s=locks.lockScope(lease.scopeId());check(lease,s.getLeaseOwner(),s.getFenceToken(),s.getLeaseUntilUtc(),utcNow());}
}
static boolean available(String owner, LocalDateTime until, LocalDateTime now) {return owner==null || until==null || !until.isAfter(now);}
public static void check(Lease lease,String owner,Long fence,LocalDateTime until,LocalDateTime now) {
require(Objects.equals(owner,lease.owner()) && fence!=null && fence==lease.fence() && until!=null && until.isAfter(now),"SELFOP_FENCE_REJECTED");
}
}
@@ -0,0 +1,85 @@
package com.baiyue.lastleg.selfoperated.analytics.sync;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.*;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
/** Small, durable checkpoint projection. Counts describe committed work, never HTTP attempts. */
@Service
public class SelfopProgressService {
private static final Set<String> PHASES = Set.of("WAITING_SOURCE", "DIRECTORY", "DETAIL", "INVENTORY",
"VALIDATION", "MERGE", "BUILD", "PUBLISH", "WAITING", "SAMPLE_DONE", "FAILED");
private final SelfopCoordinationMapper locks;
private final SelfopSyncTaskMapper tasks;
private final SelfopSyncRunMapper runs;
private final SelfopLeaseService leases;
public SelfopProgressService(SelfopCoordinationMapper locks, SelfopSyncTaskMapper tasks,
SelfopSyncRunMapper runs, SelfopLeaseService leases) {
this.locks=locks; this.tasks=tasks; this.runs=runs; this.leases=leases;
}
/** Creation-only waiting evidence; no execution or observation is inferred. */
@Transactional
public void initializeWaiting(long taskId, String phase) {
require(Set.of("WAITING_SOURCE", "WAITING").contains(phase), "SELFOP_PROGRESS_PHASE_INVALID");
var task=locks.lockTask(taskId); require(task!=null,"SELFOP_TASK_MISSING");
require(task.getAttemptCount()==null || task.getAttemptCount()==0,"SELFOP_PROGRESS_ALREADY_STARTED");
if(task.getProgressJson()!=null)return;
var p=new LinkedHashMap<String,Object>(); p.put("attemptNo",0); p.put("phase",phase);
p.put("progressScope",scope(task)); p.put("stageStartedAt",instant(utcNow()));
p.put("unit","NONE"); p.put("completed",0); p.put("total",null);
p.put("scanRound",0);p.put("totalKnown",false);p.put("countBasis","TASK_CUMULATIVE");
p.put("sliceFrom",instant(task.getQueryFromUtc()));p.put("sliceUntil",instant(task.getQueryUntilUtc()));
p.put("stageFinishedAt",null);p.put("stages",new ArrayList<>()); persist(task,p);
}
/** Caller invokes after the corresponding manifest/stage/merge checkpoint in the same transaction. */
@Transactional
public void checkpoint(long taskId, long runId, SelfopLeaseService.Lease lease, String phase,
String unit, long completed, Long total, int scan) {
require(PHASES.contains(phase),"SELFOP_PROGRESS_PHASE_INVALID");
require(Set.of("PAGE","ORDER","INVENTORY_ROW","BUSINESS_KEY","BATCH","NONE").contains(unit),"SELFOP_PROGRESS_UNIT_INVALID");
require(completed>=0 && scan>=0 && (total==null || total>=completed),"SELFOP_PROGRESS_COUNTS_INVALID");
leases.verify(lease);
var task=locks.lockTask(taskId); var run=locks.lockRun(runId); check(task,run,lease,true);
SelfopProgressProjection.applyCheckpoint(task,run,phase,unit,completed,total,scan);
tasks.updateById(task); runs.updateById(run);
}
@Transactional
public void heartbeat(long taskId,long runId,SelfopLeaseService.Lease lease) {
leases.verify(lease); var task=locks.lockTask(taskId); var run=locks.lockRun(runId);check(task,run,lease,true);
run.setHeartbeatAtUtc(utcNow()); run.setUpdatedAt(utcNow());runs.updateById(run);
}
/** Freeze before replacing task progress, including crash takeover under the new valid fence. */
@Transactional
public void freeze(long taskId,long runId,SelfopLeaseService.Lease lease) {
leases.verify(lease);var task=locks.lockTask(taskId);var run=locks.lockRun(runId);check(task,run,lease,false);
SelfopProgressProjection.freezeRun(task,run);runs.updateById(run);
}
private void check(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease,boolean active) {
require(task!=null&&run!=null&&Objects.equals(run.getTaskId(),task.getId()),"SELFOP_PROGRESS_RUN_MISMATCH");
require(Objects.equals(lease.scopeId(),lease.account()?task.getDiscoveryScopeId():task.getScopeId()),"SELFOP_PROGRESS_SCOPE_MISMATCH");
if(active)require(Objects.equals(run.getFenceToken(),lease.fence())&&Objects.equals(task.getAttemptCount(),run.getAttemptNo())
&&run.getFinishedAtUtc()==null,"SELFOP_FENCE_REJECTED");
}
private void persist(SelfopSyncTask task,Map<String,Object> p) {
task.setProgressJson(bounded(p));task.setProgressUpdatedAtUtc(utcNow());task.setUpdatedAt(utcNow());tasks.updateById(task);
}
private static String bounded(Map<String,Object> p) {String value=json(p);require(value.getBytes(StandardCharsets.UTF_8).length<=8192,"SELFOP_PROGRESS_TOO_LARGE");return value;}
private static String scope(SelfopSyncTask task){return "ACCOUNT_DISCOVERY".equals(task.getTaskRole())?"ACCOUNT_SHARED":"WAREHOUSE";}
@SuppressWarnings("unchecked") private static Map<String,Object> progress(SelfopSyncTask task){return task.getProgressJson()==null?new LinkedHashMap<>():read(task.getProgressJson(),LinkedHashMap.class);}
@SuppressWarnings("unchecked") private static Map<String,Object> map(Object value){return value instanceof Map<?,?>?new LinkedHashMap<>((Map<String,Object>)value):new LinkedHashMap<>();}
private static Long number(Object value){return value instanceof Number n?n.longValue():null;}
private static String instant(LocalDateTime value){return value==null?null:value.toInstant(ZoneOffset.UTC).toString();}
}
@@ -0,0 +1,55 @@
package com.baiyue.lastleg.selfoperated.analytics.sync;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.*;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
@Service
public class SelfopReferenceRecovery {
private final SelfopSyncStageMapper stages;
private final SelfopSyncTaskMapper tasks;
private final SelfopSyncScopeMapper scopes;
private final SelfopOutboundDiscoveryScopeMapper accounts;
private final SelfopCoordinationMapper locks;
private final SelfopLeaseService leases;
private final SelfopTaskService taskService;
private final SelfopDiscoveryService discovery;
private final TransactionTemplate tx;
public SelfopReferenceRecovery(SelfopSyncStageMapper stages,SelfopSyncTaskMapper tasks,SelfopSyncScopeMapper scopes,
SelfopOutboundDiscoveryScopeMapper accounts,SelfopCoordinationMapper locks,SelfopLeaseService leases,SelfopTaskService taskService,
SelfopDiscoveryService discovery,TransactionTemplate tx){this.stages=stages;this.tasks=tasks;this.scopes=scopes;this.accounts=accounts;
this.locks=locks;this.leases=leases;this.taskService=taskService;this.discovery=discovery;this.tx=tx;}
public void refresh(SelfopSyncTask task,Map<Long,SelfopLeaseService.Lease> held){
SelfopCollectionPolicy.requireBusiness(task);
if(task.getRoutingManifestJson()==null||"APPLIED".equals(task.getApplyState()))return;
var manifest=read(task.getRoutingManifestJson(),SelfopRoutingManifest.class);
for(var object:manifest.getObjects().entrySet()){
var ref=object.getValue();require(ref.getEffectiveStageId()!=null,"SELFOP_DETAILS_INCOMPLETE");
var current=stages.selectById(ref.getEffectiveStageId());var latest=stages.selectOne(new QueryWrapper<SelfopSyncStage>()
.eq("object_type","OUTBOUND_ORDER").eq("object_key_hash",object.getKey()).eq("validation_status","VALID").isNotNull("observation_sequence").orderByDesc("observation_sequence").last("LIMIT 1"));
if(latest==null||latest.getObservationSequence()<=current.getObservationSequence())continue;
for(var lease:held.values())leases.renew(lease);
var account=accounts.selectOne(new QueryWrapper<SelfopOutboundDiscoveryScope>().eq("source_account",task.getSourceAccount()));
var accountLease=leases.acquireDiscovery(account.getId(),UUID.randomUUID().toString());
try{
var refresh=new SelfopSyncTask();refresh.setSourceAccount(task.getSourceAccount());refresh.setTaskRole("ACCOUNT_DISCOVERY");refresh.setDiscoveryScopeId(account.getId());refresh.setTaskType("RECHECK");
refresh.setExecutionPurpose(task.getExecutionPurpose());
refresh.setScopeHash(task.getScopeHash());refresh.setRequestKey("warehouse-refresh:"+task.getId()+":"+object.getKey().substring(0,16)+":"+latest.getObservationSequence());
refresh.setQueryScopeJson(json(new SelfopDiscoveryService.Query(null,null,ref.getDeliveryNo(),ref.getCustomerCode(),task.getId())));refresh.setRequestHash(hash(refresh.getQueryScopeJson()));
refresh=taskService.create(refresh);
if(!"SUCCEEDED".equals(refresh.getStatus())){var run=taskService.begin(refresh.getId(),accountLease);refresh=tasks.selectById(refresh.getId());discovery.collect(refresh,run,accountLease);}
refresh=tasks.selectById(refresh.getId());require("SUCCEEDED".equals(refresh.getStatus()),"SELFOP_RECHECK_YIELDED");
var recovered=read(refresh.getRoutingManifestJson(),SelfopRoutingManifest.class).getObjects().get(object.getKey());
require(recovered!=null&&recovered.getEffectiveStageId()!=null,"SELFOP_RECHECK_INCOMPLETE");
require(held.values().stream().anyMatch(l->scopes.selectById(l.scopeId()).getWarehouseCode().equals(recovered.getWarehouseCode())),"LOCK_SET_CHANGED");
ref.setEffectiveStageId(recovered.getEffectiveStageId());ref.setWarehouseCode(recovered.getWarehouseCode());ref.setRefreshTaskId(refresh.getId());ref.setNewestSeenSequence(latest.getObservationSequence());
tx.executeWithoutResult(s->{for(var lease:held.values())leases.verify(lease);leases.verify(accountLease);var locked=locks.lockTask(task.getId());
locked.setRoutingManifestJson(json(manifest));locked.setUpdatedAt(utcNow());tasks.updateById(locked);task.setRoutingManifestJson(locked.getRoutingManifestJson());});
}finally{leases.release(accountLease);}
}
}
}
@@ -0,0 +1,65 @@
package com.baiyue.lastleg.selfoperated.analytics.sync;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Objects;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
@Service
public class SelfopSalesPublisher {
private final SelfopCoordinationMapper locks;
private final SelfopSyncScopeMapper scopes;
private final SelfopSyncRunMapper runs;
private final SelfopSyncTaskMapper tasks;
private final SelfopAnalyticsProperties config;
private final com.baiyue.lastleg.selfoperated.analytics.outbound.OutboundAggregateMapper aggregates;
public SelfopSalesPublisher(SelfopCoordinationMapper locks,SelfopSyncScopeMapper scopes,SelfopSyncRunMapper runs,SelfopSyncTaskMapper tasks,SelfopAnalyticsProperties config,com.baiyue.lastleg.selfoperated.analytics.outbound.OutboundAggregateMapper aggregates){
this.locks=locks;this.scopes=scopes;this.runs=runs;this.tasks=tasks;this.config=config;this.aggregates=aggregates;
}
@Transactional
public void publish(long scopeId,long runId,String owner,long fence){
require(config.isSalesPublicationEnabled(),"SELFOP_SALES_PUBLICATION_DISABLED");
var scope=locks.lockScope(scopeId);var run=locks.lockRun(runId);var now=utcNow();
SelfopCollectionPolicy.requireExecution(config,"BUSINESS");SelfopCollectionPolicy.requireBusiness(run);
require(scope!=null&&run!=null&&"OUTBOUND".equals(scope.getDataFamily()),"SELFOP_SALES_SCOPE_INVALID");
SelfopLeaseService.check(new SelfopLeaseService.Lease(scopeId,owner,fence,false),scope.getLeaseOwner(),scope.getFenceToken(),scope.getLeaseUntilUtc(),now);
if(Objects.equals(scope.getPublishedRunId(),runId)&&"PUBLISHED".equals(run.getStatus()))return;
require(scope.getSourceTimezone()!=null&&!scope.getSourceTimezone().isBlank(),"SOURCE_TIMEZONE_UNCONFIRMED");
require(config.getQuantityUnit()!=null&&!config.getQuantityUnit().isBlank(),"QUANTITY_UNIT_UNCONFIRMED");
require(Objects.equals(run.getScopeId(),scopeId)&&Objects.equals(run.getScopeHash(),scope.getScopeHash())&&Objects.equals(run.getFenceToken(),fence)
&&"WAREHOUSE_SYNC".equals(run.getRunRole())&&"READY".equals(run.getStatus()),"SELFOP_CANDIDATE_NOT_READY");
require(locks.barriers(scope.getSourceAccount(),scope.getWarehouseCode())==0,"HALF_APPLIED_FACTS");
require(Objects.equals(run.getCalculatedFactRevision(),scope.getFactRevision()),"SELFOP_CANDIDATE_OBSOLETE");
require(run.getBuildStartedAtUtc()!=null&&aggregates.observationsChangedSince(scope.getSourceAccount(),scope.getWarehouseCode(),run.getBuildStartedAtUtc())==0,"SELFOP_CANDIDATE_OBSERVATION_CHANGED");
require("COMPLETE".equals(run.getCoverageStatus())&&run.getCoveredFromUtc()!=null&&run.getCoveredUntilUtc()!=null,"SELFOP_COVERAGE_UNPROVEN");
var task=locks.lockTask(run.getTaskId());require("APPLIED".equals(task.getApplyState())||"REBUILD".equals(task.getTaskType()),"SELFOP_TASK_NOT_APPLIED");
SelfopCollectionPolicy.requireBusiness(task);
if(scope.getPublishedRunId()!=null)runs.update(null,new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<SelfopSyncRun>()
.eq("id",scope.getPublishedRunId()).isNull("superseded_at_utc").set("superseded_at_utc",now).set("updated_at",now));
scope.setPublishedRunId(runId);scope.setPublishedFactRevision(run.getCalculatedFactRevision());scope.setRebuildRequired(0);
scope.setDiscoveryFromUtc(run.getCoveredFromUtc());scope.setDiscoveryUntilUtc(run.getCoveredUntilUtc());scope.setUpdatedAt(now);scopes.updateById(scope);
SelfopProgressProjection.applyCheckpoint(task,run,"PUBLISH","NONE",1,1L,1);SelfopProgressProjection.freezeRun(task,run);
run.setStatus("PUBLISHED");run.setPublishedAtUtc(now);run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run);
task.setResultPublishedRunId(runId);task.setStatus("SUCCEEDED");task.setUpdatedAt(now);tasks.updateById(task);
locks.completeCoveredTasks(scopeId,scope.getScopeHash(),runId,run.getCoveredFromUtc(),run.getCoveredUntilUtc(),now);
locks.completeReusedRuns(runId,now);
locks.completeCoveredMarkers(scopeId,scope.getScopeHash(),runId,run.getCoveredUntilUtc(),now);
}
@Transactional
public void reuse(long scopeId,long runId,long publishedId,String owner,long fence){
SelfopCollectionPolicy.requireExecution(config,"BUSINESS");require(config.isSalesPublicationEnabled(),"SELFOP_SALES_PUBLICATION_DISABLED");
var scope=locks.lockScope(scopeId);var run=locks.lockRun(runId);var task=locks.lockTask(run.getTaskId());var published=runs.selectById(publishedId);
SelfopCollectionPolicy.requireBusiness(task);SelfopCollectionPolicy.requireBusiness(run);SelfopCollectionPolicy.requireBusiness(published);
SelfopLeaseService.check(new SelfopLeaseService.Lease(scopeId,owner,fence,false),scope.getLeaseOwner(),scope.getFenceToken(),scope.getLeaseUntilUtc(),utcNow());
require(Objects.equals(run.getScopeId(),scopeId)&&Objects.equals(run.getFenceToken(),fence)&&Objects.equals(run.getScopeHash(),scope.getScopeHash()),"SELFOP_REUSE_SCOPE_INVALID");
require(Objects.equals(scope.getPublishedRunId(),publishedId)&&"PUBLISHED".equals(published.getStatus())&&Objects.equals(published.getCalculatedFactRevision(),scope.getFactRevision())
&&locks.barriers(scope.getSourceAccount(),scope.getWarehouseCode())==0,"SELFOP_REUSE_OBSOLETE");
require("APPLIED".equals(task.getApplyState())||"REBUILD".equals(task.getTaskType()),"SELFOP_TASK_NOT_APPLIED");
require(run.getFetchStartedAtUtc()!=null&&aggregates.observationsChangedSince(scope.getSourceAccount(),scope.getWarehouseCode(),run.getFetchStartedAtUtc())==0,"SELFOP_REUSE_OBSERVATION_CHANGED");
var now=utcNow();run.setCalculatedFactRevision(published.getCalculatedFactRevision());SelfopProgressProjection.freezeRun(task,run);run.setStatus("REUSED");run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run);
task.setResultPublishedRunId(publishedId);task.setStatus("SUCCEEDED");task.setUpdatedAt(now);tasks.update(null,new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<SelfopSyncTask>()
.eq("id",task.getId()).set("status","SUCCEEDED").set("result_published_run_id",publishedId).set("next_retry_at_utc",null).set("updated_at",now));
}
}
@@ -0,0 +1,119 @@
package com.baiyue.lastleg.selfoperated.analytics.sync;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.scheduling.support.CronExpression;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
/** Persistent cron slots + immediate polling of durable work; never starts a live call by default. */
@Service
public class SelfopSchedulePlanner {
private final SelfopAnalyticsProperties config;
private final SelfopTaskService taskService;
private final SelfopExecutionService execution;
private final SelfopSyncScopeMapper scopes;
private final SelfopOutboundDiscoveryScopeMapper accounts;
private final SelfopSyncTaskMapper tasks;
private final SelfopOutboundOrderMapper orders;
private final SelfopCoordinationMapper locks;
private final TransactionTemplate tx;
public SelfopSchedulePlanner(SelfopAnalyticsProperties config,SelfopTaskService taskService,SelfopExecutionService execution,
SelfopSyncScopeMapper scopes,SelfopOutboundDiscoveryScopeMapper accounts,SelfopSyncTaskMapper tasks,SelfopOutboundOrderMapper orders,SelfopCoordinationMapper locks,TransactionTemplate tx){
this.config=config;this.taskService=taskService;this.execution=execution;this.scopes=scopes;this.accounts=accounts;this.tasks=tasks;this.orders=orders;this.locks=locks;this.tx=tx;
}
@Scheduled(fixedDelayString="${baiyue.self-operated-analytics.poll-delay-ms:1000}")
public void tick(){
if("OFF".equals(config.getCollectionMode()))return;
if("BUSINESS".equals(config.getCollectionMode()))SelfopCollectionPolicy.requireExecution(config,"BUSINESS");
var configured=taskService.initialize();var now=utcNow();
for(var scope:configured){
if(!"BUSINESS".equals(config.getCollectionMode()))break;
if("INVENTORY".equals(scope.getDataFamily()))plan(scope,now);
else if(config.getSourceQueryTimezone()!=null&&Objects.equals(scope.getSourceTimezone(),config.getSourceQueryTimezone())){plan(scope,now);review(scope,now);}
}
for(var task:taskService.due(10))try{execution.execute(task.getId());}catch(IllegalStateException leaseBusy){/* Another worker owns the durable lease. */}
}
public void plan(SelfopSyncScope preview,LocalDateTime now){
SelfopCollectionPolicy.requireExecution(config,"BUSINESS");
tx.executeWithoutResult(s->{var scope=locks.lockScope(preview.getId());ZoneId business=ZoneId.of(scope.getBusinessTimezone());
if("OUTBOUND".equals(scope.getDataFamily())&&scope.getInitialUntilUtc()==null){
scope.setInitialUntilUtc(now);scope.setInitialFromUtc(now.atZone(ZoneOffset.UTC).withZoneSameInstant(business).minusMonths(2).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime());
scope.setUpdatedAt(now);scopes.updateById(scope);}
var latest=tasks.selectOne(new QueryWrapper<SelfopSyncTask>().eq("scope_id",scope.getId()).eq("scope_hash",scope.getScopeHash()).eq("task_type","SCHEDULED").isNotNull("planned_at_utc").orderByDesc("planned_at_utc").last("LIMIT 1"));
LocalDateTime from=latest==null?scope.getCreatedAt():latest.getPlannedAtUtc();
ZonedDateTime slot=CronExpression.parse(scope.getScheduleCron()).next(from.atZone(ZoneOffset.UTC).withZoneSameInstant(business).minusNanos(1));
ZonedDateTime last=null;int count=0;
while(slot!=null&&!slot.toInstant().isAfter(now.toInstant(ZoneOffset.UTC))&&count++<100){last=slot;slot=CronExpression.parse(scope.getScheduleCron()).next(slot);}
if(last==null&&latest==null)last=now.atZone(ZoneOffset.UTC).withZoneSameInstant(business);
if(last==null)return;
LocalDateTime planned=last.withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime();
if(latest!=null&&Objects.equals(latest.getPlannedAtUtc(),planned))return;
var request=new SelfopSyncTask();request.setSourceAccount(scope.getSourceAccount());request.setTaskRole("WAREHOUSE_SYNC");request.setScopeId(scope.getId());request.setWarehouseCode(scope.getWarehouseCode());
request.setTaskType("SCHEDULED");request.setPlannedAtUtc(planned);request.setScopeHash(scope.getScopeHash());request.setQueryScopeJson(scope.getScopeJson());
request.setRequestKey("slot:"+scope.getWarehouseCode()+":"+scope.getDataFamily()+":"+scope.getScopeHash().substring(0,12)+":"+planned);request.setRequestHash(hash(List.of(scope.getScopeHash(),planned)));
if("OUTBOUND".equals(scope.getDataFamily())){
// This durable slot marker routes through bounded shared account slices, never a month-long request.
request.setStatus("WAITING_SOURCE");request.setApplyState("NOT_APPLICABLE");
// The fixed historical window is queued atomically with its first slot marker.
// Slice identity is independent of later 08:00/20:00 slots and publication progress.
if(latest==null){
LocalDateTime start=scope.getInitialFromUtc(),until=scope.getInitialUntilUtc();
while(start.isBefore(until)){LocalDateTime end=start.plusDays(1);if(end.isAfter(until))end=until;
shared(scope,start,end,"initial:"+start+":"+end,"BACKFILL",null,null);start=end;}
}
// Prior slots retain their durable pending work; the planning cursor can advance
// before publication without skipping any interval. Re-read the recent overlap.
LocalDateTime start=latest==null?scope.getInitialUntilUtc():latest.getPlannedAtUtc().minusHours(48);
if(start.isBefore(scope.getInitialUntilUtc()))start=scope.getInitialUntilUtc();
while(start.isBefore(planned)){LocalDateTime end=start.plusDays(1);if(end.isAfter(planned))end=planned;
shared(scope,start,end,"schedule:"+planned+":"+start,"SCHEDULED",null,null);start=end;}
}
taskService.create(request);
});
}
private SelfopSyncTask shared(SelfopSyncScope scope,LocalDateTime from,LocalDateTime until,String key,String type,String customer,String delivery){
var account=accounts.selectOne(new QueryWrapper<SelfopOutboundDiscoveryScope>().eq("source_account",scope.getSourceAccount()));
var t=new SelfopSyncTask();t.setSourceAccount(scope.getSourceAccount());t.setTaskRole("ACCOUNT_DISCOVERY");t.setDiscoveryScopeId(account.getId());
t.setTaskType(type);t.setScopeHash(hash(config.getWarehouses()));t.setRequestKey(key+":"+t.getScopeHash().substring(0,12));t.setQueryFromUtc(from);t.setQueryUntilUtc(until);
ZoneId source=ZoneId.of(config.getSourceQueryTimezone());
t.setQueryScopeJson(json(new SelfopDiscoveryService.Query(from==null?null:from.atZone(ZoneOffset.UTC).withZoneSameInstant(source).toLocalDateTime().toString(),
until==null?null:until.atZone(ZoneOffset.UTC).withZoneSameInstant(source).toLocalDateTime().toString(),delivery,customer,null)));
t.setRequestHash(hash(Arrays.asList(t.getQueryScopeJson(),t.getScopeHash())));return taskService.create(t);
}
public void review(SelfopSyncScope preview,LocalDateTime now){
SelfopCollectionPolicy.requireExecution(config,"BUSINESS");
tx.executeWithoutResult(s->{var scope=locks.lockScope(preview.getId());
if(scope.getKnownOrderRoundMaxId()==null){var max=orders.selectOne(new QueryWrapper<SelfopOutboundOrder>().eq("source_account",scope.getSourceAccount()).eq("warehouse_code",scope.getWarehouseCode()).orderByDesc("id").last("LIMIT 1"));
scope.setKnownOrderRoundMaxId(max==null?0L:max.getId());scope.setKnownOrderCursorId(0L);scope.setKnownOrderRoundStartedAtUtc(now);}
var next=orders.selectOne(new QueryWrapper<SelfopOutboundOrder>().eq("source_account",scope.getSourceAccount()).eq("warehouse_code",scope.getWarehouseCode())
.gt("id",scope.getKnownOrderCursorId()).le("id",scope.getKnownOrderRoundMaxId()).orderByAsc("id").last("LIMIT 1"));
if(next!=null){String key="review:"+scope.getId()+":"+scope.getKnownOrderRoundStartedAtUtc()+":"+next.getId();
var t=shared(scope,null,null,key,"RECHECK",next.getCustomerCode(),next.getDeliveryNo());
long pending=tasks.selectCount(new QueryWrapper<SelfopSyncTask>().eq("upstream_task_id",t.getId()).ne("apply_state","APPLIED"));
long applied=tasks.selectCount(new QueryWrapper<SelfopSyncTask>().eq("upstream_task_id",t.getId()).eq("apply_state","APPLIED"));
if("SUCCEEDED".equals(t.getStatus())&&pending==0&&applied>0)scope.setKnownOrderCursorId(next.getId());
}else{scope.setKnownOrderRoundFinishedAtUtc(now);
scope.setKnownOrderRoundMaxId(null);scope.setKnownOrderCursorId(null);
scopes.update(null,new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<SelfopSyncScope>().eq("id",scope.getId())
.set("known_order_round_max_id",null).set("known_order_cursor_id",null).set("known_order_round_finished_at_utc",now));}
if(scope.getDiscoveryUntilUtc()!=null){
if(scope.getHistoryRoundUntilUtc()==null){scope.setHistoryRoundFromUtc(scope.getInitialFromUtc());scope.setHistoryRoundUntilUtc(scope.getDiscoveryUntilUtc());scope.setHistorySliceCursorUtc(scope.getInitialFromUtc());}
if(scope.getHistorySliceCursorUtc().isBefore(scope.getHistoryRoundUntilUtc())){
var end=scope.getHistorySliceCursorUtc().plusDays(1);if(end.isAfter(scope.getHistoryRoundUntilUtc()))end=scope.getHistoryRoundUntilUtc();
var t=shared(scope,scope.getHistorySliceCursorUtc(),end,"history:"+scope.getId()+":"+scope.getHistoryRoundUntilUtc()+":"+scope.getHistorySliceCursorUtc(),"RECHECK",null,null);
var child=tasks.selectOne(new QueryWrapper<SelfopSyncTask>().eq("upstream_task_id",t.getId()).eq("scope_id",scope.getId()));
if(child!=null&&"APPLIED".equals(child.getApplyState()))scope.setHistorySliceCursorUtc(end);
}else{scope.setHistoryRoundFinishedAtUtc(now);scope.setHistoryRoundUntilUtc(null);scope.setHistorySliceCursorUtc(null);scopes.update(null,new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<SelfopSyncScope>().eq("id",scope.getId())
.set("history_round_until_utc",null).set("history_slice_cursor_utc",null).set("history_round_finished_at_utc",now));}
}
scope.setUpdatedAt(now);scopes.updateById(scope);
});
}
}
@@ -0,0 +1,222 @@
package com.baiyue.lastleg.selfoperated.analytics.sync;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopCollectionPolicy.*;
@Service
public class SelfopTaskService {
private final SelfopSyncScopeMapper scopes;
private final SelfopOutboundDiscoveryScopeMapper accounts;
private final SelfopSyncTaskMapper tasks;
private final SelfopSyncRunMapper runs;
private final SelfopCoordinationMapper locks;
private final SelfopAnalyticsProperties config;
private final SelfopLeaseService leases;
public SelfopTaskService(SelfopSyncScopeMapper scopes,SelfopOutboundDiscoveryScopeMapper accounts,
SelfopSyncTaskMapper tasks,SelfopSyncRunMapper runs,SelfopCoordinationMapper locks,SelfopAnalyticsProperties config,SelfopLeaseService leases) {
this.scopes=scopes;this.accounts=accounts;this.tasks=tasks;this.runs=runs;this.locks=locks;this.config=config;this.leases=leases;
}
@Transactional
public List<SelfopSyncScope> initialize() {
identity(config.getSourceAccount(),64); require(!config.getWarehouses().isEmpty(),"SELFOP_WAREHOUSE_UNCONFIGURED");
var now=utcNow();
var a=accounts.selectOne(new QueryWrapper<SelfopOutboundDiscoveryScope>().eq("source_account",config.getSourceAccount()));
if(a==null){a=new SelfopOutboundDiscoveryScope();a.setId(IdWorker.getId());a.setSourceAccount(config.getSourceAccount());
a.setNextSequence(1L);a.setNextObservationSequence(1L);a.setFenceToken(0L);
a.setRequestBudget(config.getRequestBudget());a.setTimeBudgetSeconds(config.getTimeBudgetSeconds());
a.setMinRequestIntervalMs(config.getMinRequestIntervalMs());a.setCreatedAt(now);a.setUpdatedAt(now);
require(a.getRequestBudget()>0&&a.getTimeBudgetSeconds()>0&&a.getMinRequestIntervalMs()>0,"SELFOP_BUDGET_INVALID");
try{accounts.insert(a);}catch(DuplicateKeyException ignored){ }
}
for(var e:config.getWarehouses().entrySet()) for(String family:List.of("OUTBOUND","INVENTORY")) {
identity(e.getKey(),64);var p=e.getValue();ZoneId.of(p.getBusinessTimezone());
var existing=scopes.selectOne(new QueryWrapper<SelfopSyncScope>().eq("source_account",config.getSourceAccount()).eq("warehouse_code",e.getKey()).eq("data_family",family));
Map<String,Object> definition=new TreeMap<>();definition.put("customerMode","ALL_AUTHORIZED");definition.put("sourceAccount",config.getSourceAccount());
definition.put("warehouseCode",e.getKey());definition.put("dataFamily",family);definition.put("businessTimezone",p.getBusinessTimezone());
definition.put("sourceTimezone",p.getSourceTimezone());definition.put("quantityUnit",config.getQuantityUnit());
definition.put("qualities",family.equals("INVENTORY")?"ALL":"ACTUAL_OUTBOUND");
definition.put("staleAfterSeconds",family.equals("INVENTORY")?p.getInventoryStaleAfterSeconds():p.getOutboundStaleAfterSeconds());
String digest=hash(definition);
if(existing!=null){
if("VALIDATION".equals(config.getCollectionMode()))continue;
if(!existing.getScopeHash().equals(digest)){
existing=locks.lockScope(existing.getId());
var update=new UpdateWrapper<SelfopSyncScope>().eq("id",existing.getId())
.set("scope_json",json(definition)).set("scope_hash",digest).set("business_timezone",p.getBusinessTimezone()).set("source_timezone",p.getSourceTimezone())
.set("schedule_cron",family.equals("OUTBOUND")?p.getOutboundCron():p.getInventoryCron())
.set("stale_after_seconds",family.equals("OUTBOUND")?p.getOutboundStaleAfterSeconds():p.getInventoryStaleAfterSeconds())
.set("fence_token",existing.getFenceToken()+1).set("lease_owner",null).set("lease_until_utc",null).set("updated_at",now);
if("OUTBOUND".equals(family)){
update.set("discovery_from_utc",null).set("discovery_until_utc",null).set("rebuild_required",1).set("invalidated_at_utc",now)
.set("fact_revision",existing.getFactRevision()+1).set("known_order_round_max_id",null).set("known_order_cursor_id",null)
.set("history_round_until_utc",null).set("history_slice_cursor_utc",null);
if(existing.getInitialUntilUtc()!=null)update.set("initial_from_utc",existing.getInitialUntilUtc().atZone(ZoneOffset.UTC)
.withZoneSameInstant(ZoneId.of(p.getBusinessTimezone())).minusMonths(2).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime());
}
scopes.update(null,update);
}
continue;
}
var s=new SelfopSyncScope();s.setId(IdWorker.getId());s.setSourceAccount(config.getSourceAccount());s.setWarehouseCode(e.getKey());s.setDataFamily(family);
s.setScopeJson(json(definition));s.setScopeHash(digest);s.setBusinessTimezone(p.getBusinessTimezone());s.setSourceTimezone(p.getSourceTimezone());
s.setScheduleCron(family.equals("OUTBOUND")?p.getOutboundCron():p.getInventoryCron());
s.setStaleAfterSeconds(family.equals("OUTBOUND")?p.getOutboundStaleAfterSeconds():p.getInventoryStaleAfterSeconds());
s.setFactRevision(0L);s.setPublishedFactRevision(0L);s.setRebuildRequired(0);s.setFenceToken(0L);
s.setReviewRequestBudget(p.getReviewRequestBudget());s.setReviewTimeBudgetSeconds(p.getReviewTimeBudgetSeconds());s.setReviewTargetDays(p.getReviewTargetDays());
require(s.getStaleAfterSeconds()>0&&s.getReviewRequestBudget()>0&&s.getReviewTimeBudgetSeconds()>0,"SELFOP_BUDGET_INVALID");
s.setCreatedAt(now);s.setUpdatedAt(now);try{scopes.insert(s);}catch(DuplicateKeyException ignored){ }
}
return scopes.selectList(new QueryWrapper<SelfopSyncScope>().eq("source_account",config.getSourceAccount()).orderByAsc("warehouse_code","data_family"));
}
@Transactional
public SelfopSyncTask create(SelfopSyncTask task) {
identity(task.getRequestKey(),128); var existing=tasks.selectOne(new QueryWrapper<SelfopSyncTask>()
.eq("source_account",task.getSourceAccount()).eq("request_key",task.getRequestKey()));
if(existing!=null){require(existing.getRequestHash().equals(task.getRequestHash()),"SELFOP_IDEMPOTENCY_CONFLICT");return existing;}
if(task.getExecutionPurpose()==null)task.setExecutionPurpose(configuredPurpose(config));
requireExecution(config,task.getExecutionPurpose());
var now=utcNow();task.setId(IdWorker.getId());task.setAttemptCount(0);task.setCreatedAt(now);task.setUpdatedAt(now);
task.setTaskNo("LXCOL-"+now.atZone(ZoneOffset.UTC).withZoneSameInstant(ZoneId.of("Asia/Shanghai")).toLocalDate().toString().replace("-","")+"-"+UUID.randomUUID().toString().replace("-","").substring(0,16).toUpperCase(Locale.ROOT));
if("VALIDATION".equals(task.getExecutionPurpose())){
require("VALIDATION".equals(task.getTaskType()),"SELFOP_VALIDATION_ENTRY_REQUIRED");
if(task.getValidationRootTaskId()==null){task.setValidationRootTaskId(task.getId());task.setValidationBudgetJson(com.baiyue.lastleg.selfoperated.analytics.request.SelfopValidationBudget.freeze(now));}
task.setApplyState("NOT_APPLICABLE");
}
task.setStatus(task.getStatus()==null?"READY":task.getStatus());
task.setApplyState(task.getApplyState()==null?"NOT_APPLICABLE":task.getApplyState());
if("ACCOUNT_DISCOVERY".equals(task.getTaskRole())) {
var account=locks.lockDiscovery(task.getDiscoveryScopeId());task.setDiscoverySequence(account.getNextSequence());
task.setObservationBaseline(account.getNextObservationSequence()-1);account.setNextSequence(account.getNextSequence()+1);account.setUpdatedAt(now);accounts.updateById(account);
}
try{tasks.insert(task);}catch(DuplicateKeyException ignored){existing=tasks.selectOne(new QueryWrapper<SelfopSyncTask>()
.eq("source_account",task.getSourceAccount()).eq("request_key",task.getRequestKey()).last("FOR UPDATE"));
require(existing!=null&&existing.getRequestHash().equals(task.getRequestHash()),"SELFOP_IDEMPOTENCY_CONFLICT");return existing;}
return task;
}
@Transactional
public SelfopSyncRun begin(long taskId,SelfopLeaseService.Lease lease) {
leases.verify(lease);
var task=locks.lockTask(taskId);require(task!=null&&!"SUCCEEDED".equals(task.getStatus())&&!"DEAD".equals(task.getStatus()),"SELFOP_TASK_NOT_EXECUTABLE");
checkTaskLease(task,lease);
requireExecution(config,task.getExecutionPurpose());
require(!"SAMPLE_DONE".equals(task.getStatus()),"SELFOP_VALIDATION_TERMINATED");
if(lease.account()) {var account=locks.lockDiscovery(lease.scopeId());account.setActiveTaskId(taskId);account.setUpdatedAt(utcNow());accounts.updateById(account);}
var now=utcNow();var active=runs.selectOne(new QueryWrapper<SelfopSyncRun>().eq("task_id",taskId).eq("status","FETCHING").orderByDesc("id").last("LIMIT 1"));
if(task.getScopeId()!=null && "INVENTORY".equals(scopes.selectById(task.getScopeId()).getDataFamily())) {
var yielded=runs.selectOne(new QueryWrapper<SelfopSyncRun>().eq("task_id",taskId).eq("status","YIELDED").orderByDesc("id").last("LIMIT 1"));
if(yielded!=null){yielded.setStatus("FETCHING");yielded.setFinishedAtUtc(null);yielded.setFenceToken(lease.fence());yielded.setUpdatedAt(now);
runs.update(null,new UpdateWrapper<SelfopSyncRun>().eq("id",yielded.getId()).set("status","FETCHING").set("fence_token",lease.fence()).set("finished_at_utc",null).set("updated_at",now));
task.setStatus("RUNNING");task.setUpdatedAt(now);tasks.updateById(task);return yielded;}
}
if(active!=null){require(!Objects.equals(active.getFenceToken(),lease.fence()),"SELFOP_TASK_ALREADY_RUNNING");
SelfopProgressProjection.freezeRun(task,active);
active.setStatus("ABORTED");active.setLastPhase("FETCHING");active.setFinishedAtUtc(now);active.setErrorCode("LEASE_EXPIRED");active.setErrorMessage("执行租约失效");active.setUpdatedAt(now);runs.updateById(active);}
if(task.getScopeId()!=null&&"INVENTORY".equals(scopes.selectById(task.getScopeId()).getDataFamily())) {
// Every genuinely new inventory attempt starts at page one, including a crash that never called fail().
task.setRoutingManifestJson(null);
tasks.update(null,new UpdateWrapper<SelfopSyncTask>().eq("id",taskId).set("routing_manifest_json",null));
}
var run=new SelfopSyncRun();run.setId(IdWorker.getId());run.setTaskId(taskId);run.setScopeId(task.getScopeId());
run.setExecutionPurpose(task.getExecutionPurpose());run.setHeartbeatAtUtc(now);
run.setSourceAccount(task.getSourceAccount());run.setWarehouseCode(task.getWarehouseCode());run.setRunRole(task.getTaskRole());run.setUpstreamTaskId(task.getUpstreamTaskId());
run.setAttemptNo(task.getAttemptCount()+1);run.setScopeHash(task.getScopeHash());run.setScopeSnapshotJson(task.getQueryScopeJson());
run.setFenceToken(lease.fence());run.setStatus("FETCHING");run.setLastPhase("FETCHING");run.setFetchStartedAtUtc(now);
run.setPageCount(0);run.setUniqueObjectCount(0L);run.setCoverageStatus("UNKNOWN");run.setCreatedAt(now);run.setUpdatedAt(now);
if(task.getScopeId()!=null){var scope=scopes.selectById(task.getScopeId());run.setBasePublishedRunId(scope.getPublishedRunId());
Map<String,Object> snapshot=new TreeMap<>(read(scope.getScopeJson(),Map.class));
snapshot.put("initialFromUtc",scope.getInitialFromUtc());snapshot.put("initialUntilUtc",scope.getInitialUntilUtc());
snapshot.put("businessTimezone",scope.getBusinessTimezone());snapshot.put("sourceTimezone",scope.getSourceTimezone());
snapshot.put("staleAfterSeconds",scope.getStaleAfterSeconds());run.setScopeSnapshotJson(json(snapshot));
if("INVENTORY".equals(scope.getDataFamily())){var sales=scopes.selectOne(new QueryWrapper<SelfopSyncScope>().eq("source_account",task.getSourceAccount()).eq("warehouse_code",task.getWarehouseCode()).eq("data_family","OUTBOUND"));
if(sales!=null)sales=locks.lockScope(sales.getId());
run.setReferenceSalesRunId(sales==null?null:sales.getPublishedRunId());}}
runs.insert(run);task.setAttemptCount(run.getAttemptNo());task.setStatus("RUNNING");task.setUpdatedAt(now);
String initialPhase=lease.account()?"DIRECTORY":("INVENTORY".equals(scopes.selectById(task.getScopeId()).getDataFamily())?"INVENTORY":"MERGE");
SelfopProgressProjection.applyCheckpoint(task,run,initialPhase,"PAGE",0,null,1);tasks.updateById(task);return run;
}
@Transactional
public void yield(long taskId,long runId,SelfopLeaseService.Lease lease) {
leases.verify(lease);
var now=utcNow();var task=locks.lockTask(taskId);var run=locks.lockRun(runId);checkRunLease(task,run,lease);requireBusiness(task);requireBusiness(run);SelfopProgressProjection.freezeRun(task,run);run.setStatus("YIELDED");run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run);
task.setStatus("RETRY_WAIT");task.setNextRetryAtUtc(now.plusSeconds(1));task.setUpdatedAt(now);tasks.updateById(task);
}
@Transactional
public void fail(long taskId,long runId,String code,boolean retryable,SelfopLeaseService.Lease lease) {
leases.verify(lease);
var now=utcNow();var task=locks.lockTask(taskId);var run=locks.lockRun(runId);checkRunLease(task,run,lease);requireBusiness(task);requireBusiness(run);SelfopProgressProjection.freezeRun(task,run);run.setLastPhase(run.getStatus());run.setStatus("FAILED");run.setErrorCode(code);
run.setErrorMessage("自营同步失败,需核对运行记录");run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run);
// A yielded execution is not a failed request; finite failures are counted separately.
long failures=runs.selectCount(new QueryWrapper<SelfopSyncRun>().eq("task_id",taskId).eq("status","FAILED"));
task.setStatus(retryable&&failures<config.getMaxAttempts()?"RETRY_WAIT":"DEAD");
if("APPLYING".equals(task.getApplyState())) task.setApplyState("BLOCKED");
if(task.getScopeId()!=null&&"INVENTORY".equals(scopes.selectById(task.getScopeId()).getDataFamily())) {
task.setRoutingManifestJson(null);
tasks.update(null,new UpdateWrapper<SelfopSyncTask>().eq("id",taskId).set("routing_manifest_json",null));
}
if(Set.of("SELFOP_DIRECTORY_DUPLICATE","SELFOP_DIRECTORY_COUNT_MISMATCH","SELFOP_DIRECTORY_UNSTABLE","SELFOP_PAGE_TOTAL_CHANGED").contains(code)) {
task.setRoutingManifestJson(null);
tasks.update(null,new UpdateWrapper<SelfopSyncTask>().eq("id",taskId).set("routing_manifest_json",null));
}
task.setNextRetryAtUtc(now.plusSeconds(Math.min(3600,5L<<Math.min(9,(int)failures))));task.setUpdatedAt(now);tasks.updateById(task);
}
@Transactional
public SelfopSyncTask retry(long id,String requestKey,String operator,String reason) {
identity(reason,500);
identity(requestKey,128);var preview=tasks.selectById(id);require(preview!=null,"SELFOP_TASK_MISSING");
var commandQuery=new QueryWrapper<SelfopSyncTask>().eq("source_account",preview.getSourceAccount()).eq("request_key","retry:"+requestKey);
var prior=tasks.selectOne(commandQuery);
if(prior!=null){require(prior.getRequestHash().equals(hash(List.of(id,requestKey,reason))),"SELFOP_IDEMPOTENCY_CONFLICT");return preview;}
// Match executor lock ordering: account/warehouse scope first, task second.
String activeOwner;LocalDateTime activeUntil;
if("ACCOUNT_DISCOVERY".equals(preview.getTaskRole())){var scope=locks.lockDiscovery(preview.getDiscoveryScopeId());require(scope!=null,"SELFOP_SCOPE_MISSING");activeOwner=scope.getLeaseOwner();activeUntil=scope.getLeaseUntilUtc();}
else{var scope=locks.lockScope(preview.getScopeId());require(scope!=null,"SELFOP_SCOPE_MISSING");activeOwner=scope.getLeaseOwner();activeUntil=scope.getLeaseUntilUtc();}
prior=tasks.selectOne(new QueryWrapper<SelfopSyncTask>().eq("source_account",preview.getSourceAccount()).eq("request_key","retry:"+requestKey).last("FOR UPDATE"));
if(prior!=null){require(prior.getRequestHash().equals(hash(List.of(id,requestKey,reason))),"SELFOP_IDEMPOTENCY_CONFLICT");return tasks.selectById(id);}
require(SelfopLeaseService.available(activeOwner,activeUntil,utcNow()),"SELFOP_RETRY_LEASE_BUSY");
var original=locks.lockTask(id);require(original!=null,"SELFOP_TASK_MISSING");
requireBusiness(original);requireExecution(config,original.getExecutionPurpose());
require(!"SUCCEEDED".equals(original.getStatus()),"SELFOP_TASK_ALREADY_SUCCEEDED");
// Resume original logical task, retaining apply barrier and stage set. Record the manual command separately.
var audit=new SelfopSyncTask();audit.setSourceAccount(original.getSourceAccount());audit.setTaskRole(original.getTaskRole());audit.setScopeId(original.getScopeId());
audit.setExecutionPurpose(original.getExecutionPurpose());
audit.setWarehouseCode(original.getWarehouseCode());audit.setDiscoveryScopeId(original.getDiscoveryScopeId());audit.setUpstreamTaskId(original.getId());
audit.setRequestKey("retry:"+requestKey);audit.setRequestHash(hash(List.of(id,requestKey,reason)));audit.setTaskType("MANUAL_RETRY");audit.setScopeHash(original.getScopeHash());
audit.setQueryScopeJson(json(Map.of("resumesTaskNo",original.getTaskNo(),"reason",reason)));audit.setStatus("SUCCEEDED");audit.setApplyState("NOT_APPLICABLE");audit.setRequestedBy(operator);
create(audit);
original.setStatus("READY");original.setNextRetryAtUtc(utcNow());original.setRequestedBy(operator);original.setUpdatedAt(utcNow());tasks.updateById(original);
return original;
}
public List<SelfopSyncTask> due(int limit){return locks.due(utcNow(),limit,config.getCollectionMode());}
@Transactional
public void candidateFinished(long taskId,long runId,SelfopLeaseService.Lease lease) {
leases.verify(lease);var task=locks.lockTask(taskId);var run=locks.lockRun(runId);
checkRunLease(task,run,lease);
require("READY".equals(run.getStatus()),"SELFOP_CANDIDATE_NOT_READY");
run.setFinishedAtUtc(utcNow());run.setUpdatedAt(utcNow());runs.updateById(run);
// Candidate readiness is not business success until a published result is linked.
task.setStatus("RETRY_WAIT");task.setNextRetryAtUtc(utcNow().plusSeconds(config.getMinimumBuildIntervalSeconds()));task.setUpdatedAt(utcNow());tasks.updateById(task);
}
@Transactional
public void waitForBudget(long taskId,long runId,LocalDateTime until,SelfopLeaseService.Lease lease) {
leases.verify(lease);var task=locks.lockTask(taskId);var run=locks.lockRun(runId);checkRunLease(task,run,lease);requireBusiness(task);requireBusiness(run);
SelfopProgressProjection.freezeRun(task,run);run.setLastPhase(run.getStatus());run.setStatus("YIELDED");run.setFinishedAtUtc(utcNow());run.setUpdatedAt(utcNow());runs.updateById(run);
task.setStatus("RETRY_WAIT");task.setNextRetryAtUtc(until);task.setUpdatedAt(utcNow());tasks.updateById(task);
}
private static void checkTaskLease(SelfopSyncTask task,SelfopLeaseService.Lease lease){
require(task!=null&&(lease.account()?"ACCOUNT_DISCOVERY".equals(task.getTaskRole())&&Objects.equals(task.getDiscoveryScopeId(),lease.scopeId())
:"WAREHOUSE_SYNC".equals(task.getTaskRole())&&Objects.equals(task.getScopeId(),lease.scopeId())),"SELFOP_TASK_LEASE_SCOPE_MISMATCH");
}
private static void checkRunLease(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease){
checkTaskLease(task,lease);require(run!=null&&Objects.equals(run.getTaskId(),task.getId())&&Objects.equals(run.getFenceToken(),lease.fence()),"SELFOP_RUN_LEASE_MISMATCH");
}
}
@@ -0,0 +1,208 @@
package com.baiyue.lastleg.selfoperated.analytics.sync;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.selfoperated.analytics.client.*;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import com.baiyue.lastleg.selfoperated.analytics.request.*;
import lombok.Data;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.time.Duration;
import java.util.*;
import java.util.function.Supplier;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
/** Bounded sample workflow. Deliberately has no facts, scope, SKU, candidate or publisher dependencies. */
@Service
public class SelfopValidationRunner {
private final LingxingAnalyticsClient client;
private final SelfopSyncStageMapper stages;
private final SelfopSyncTaskMapper tasks;
private final SelfopSyncRunMapper runs;
private final SelfopCoordinationMapper locks;
private final SelfopLeaseService leases;
private final SelfopEgressService egress;
private final SelfopAnalyticsProperties config;
private final TransactionTemplate tx;
public SelfopValidationRunner(LingxingAnalyticsClient client,SelfopSyncStageMapper stages,
SelfopSyncTaskMapper tasks,SelfopSyncRunMapper runs,SelfopCoordinationMapper locks,
SelfopLeaseService leases,SelfopEgressService egress,SelfopAnalyticsProperties config,TransactionTemplate tx) {
this.client=client;this.stages=stages;this.tasks=tasks;this.runs=runs;this.locks=locks;
this.leases=leases;this.egress=egress;this.config=config;this.tx=tx;
}
public void run(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease) {
require("VALIDATION".equals(config.getCollectionMode())&&"VALIDATION".equals(task.getExecutionPurpose())
&&"VALIDATION".equals(run.getExecutionPurpose()),"SELFOP_VALIDATION_PURPOSE_REQUIRED");
require(task.getValidationRootTaskId()!=null,"SELFOP_VALIDATION_ROOT_REQUIRED");
require(lease.account(),"SELFOP_ACCOUNT_LEASE_REQUIRED");
var context=SelfopRequestContext.forTask(task,run,config);
try {
var state=task.getRoutingManifestJson()==null?new Sample():read(task.getRoutingManifestJson(),Sample.class);
Map<?,?> query=read(task.getQueryScopeJson(),Map.class);
var from=LocalDateTime.parse(Objects.toString(query.get("startTime"),""));
var until=LocalDateTime.parse(Objects.toString(query.get("endTime"),""));
require(until.isAfter(from)&&Duration.between(from,until).compareTo(Duration.ofDays(1))<=0,"SELFOP_VALIDATION_WINDOW_INVALID");
if(!state.warehousesRead) {
leases.renew(lease); var page=request(task,run,lease,()->client.warehouses(context,1,10)); validatePage(page,1);
tx.executeWithoutResult(s->{check(task,run,lease);
for(var warehouse:page.records()) {
identity(warehouse.warehouseCode(),64);
}
run.setObservedDimensionsJson(json(Map.of("validationWarehouseSamples",page.records(),"truncated",page.pages()>1)));
state.warehousesRead=true;state.pages++;save(task,run,state,"DIRECTORY","PAGE",state.pages,null);
});
}
while(!state.directoryRead&&state.outboundPage<=2) {
int current=state.outboundPage;leases.renew(lease);
var page=request(task,run,lease,()->client.discover(context,from,until,current,10));validatePage(page,current);
tx.executeWithoutResult(s->{check(task,run,lease);
for(var summary:page.records()) {
require(Objects.equals(task.getSourceAccount(),summary.sourceAccount()),"SELFOP_SAMPLE_ACCOUNT_MISMATCH");
identity(summary.customerCode(),64);identity(summary.warehouseCode(),64);identity(summary.deliveryNo(),128);
String key=hash(List.of(summary.customerCode(),summary.deliveryNo()));
require(state.orders.stream().noneMatch(o->key.equals(hash(List.of(o.customerCode(),o.deliveryNo())))),"SELFOP_SAMPLE_DIRECTORY_DUPLICATE");
state.orders.add(summary);
stage(task,run,"OUTBOUND_ORDER","directory:"+key,summary.warehouseCode(),current,summary,"PENDING",null);
}
state.outboundPage++;state.pages++;state.directoryRead=page.pages()==0||current>=page.pages()||state.outboundPage>2;
save(task,run,state,"DIRECTORY","PAGE",state.pages,null);
});
}
while(state.detailIndex<state.orders.size()) {
var summary=state.orders.get(state.detailIndex);leases.renew(lease);
var detail=request(task,run,lease,()->client.detail(context,summary.warehouseCode(),summary.customerCode(),summary.deliveryNo()));
validateOrder(task,summary,detail);
tx.executeWithoutResult(s->{check(task,run,lease);
boolean unknown=detail.items().stream().anyMatch(i->i.actualQuantity()==null);
boolean invalid=detail.items().stream().anyMatch(i->i.actualQuantity()!=null&&i.actualQuantity().signum()<0);
stage(task,run,"OUTBOUND_ORDER",hash(List.of(detail.customerCode(),detail.deliveryNo())),detail.warehouseCode(),1,detail,
invalid?"INVALID":unknown?"PENDING":"VALID",invalid?"NEGATIVE_QUANTITY":unknown?"QUANTITY_UNKNOWN":null);
state.detailIndex++;save(task,run,state,"DETAIL","ORDER",state.detailIndex,(long)state.orders.size());
});
}
// Each configured warehouse is sampled independently, never inferred from directory absence.
for(String warehouse:config.getWarehouses().keySet()) {
int current=state.inventoryNextPage.getOrDefault(warehouse,1);
while(current<=2&&!state.inventoryFinished.contains(warehouse)) {
leases.renew(lease);int pageNo=current;
var page=request(task,run,lease,()->client.inventory(context,warehouse,pageNo,10));validatePage(page,current);
tx.executeWithoutResult(s->{check(task,run,lease);
for(var stock:page.records()) {
require(Objects.equals(stock.sourceAccount(),task.getSourceAccount())&&warehouse.equals(stock.warehouseCode()),"SELFOP_SAMPLE_INVENTORY_IDENTITY");
identity(stock.customerCode(),64);identity(stock.skuCode(),128);require(stock.productQuality()!=null,"SELFOP_SAMPLE_INVENTORY_STRUCTURE");
String key=hash(List.of(stock.customerCode(),stock.warehouseCode(),stock.skuCode(),stock.productQuality()));
require(state.inventoryKeys.add(key),"SELFOP_SAMPLE_INVENTORY_DUPLICATE");
boolean unknown=stock.physicalQuantity()==null||stock.availableQuantity()==null||stock.frozenQuantity()==null||stock.transitQuantity()==null;
boolean invalid=Arrays.asList(stock.physicalQuantity(),stock.availableQuantity(),stock.frozenQuantity(),stock.transitQuantity()).stream().filter(Objects::nonNull).anyMatch(q->q.signum()<0);
stage(task,run,"PRODUCT_STOCK",key,warehouse,pageNo,stock,invalid?"INVALID":unknown?"PENDING":"VALID",invalid?"NEGATIVE_QUANTITY":unknown?"QUANTITY_UNKNOWN":null);
}
state.inventoryNextPage.put(warehouse,pageNo+1);state.pages++;state.inventoryRows+=page.records().size();
if(page.pages()==0||pageNo>=page.pages()||pageNo==2)state.inventoryFinished.add(warehouse);
save(task,run,state,"INVENTORY","INVENTORY_ROW",state.inventoryRows,null);
});current++;
}
}
finish(task,run,lease,"VALIDATION_SAMPLE_SUFFICIENT",false);
} catch(EgressRejectedException stop) {
// No YIELDED or retry scheduling: every validation root is a single finite session.
finish(task,run,lease,stop.sampleDone()?stop.code():"VALIDATION_REQUEST_REJECTED",!stop.sampleDone());
} catch(AnalyticsClientException failure) {
finish(task,run,lease,failure.code(),true);
} catch(RuntimeException invalid) {
if("SELFOP_FENCE_REJECTED".equals(invalid.getMessage())||"SELFOP_LEASE_BUSY".equals(invalid.getMessage()))throw invalid;
finish(task,run,lease,"VALIDATION_STRUCTURE_REJECTED",true);
}
}
/** Execution-level failures still terminate the original bounded validation session. */
public void fail(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease,String code) {
finish(task,run,lease,code,true);
}
private void check(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease) {
leases.verify(lease);var stored=locks.lockTask(task.getId());var active=locks.lockRun(run.getId());
require(stored!=null&&active!=null&&Objects.equals(stored.getDiscoveryScopeId(),lease.scopeId())
&&Objects.equals(active.getTaskId(),stored.getId())&&Objects.equals(active.getFenceToken(),lease.fence())
&&Objects.equals(stored.getAttemptCount(),active.getAttemptNo())&&"VALIDATION".equals(stored.getExecutionPurpose())
&&"VALIDATION".equals(active.getExecutionPurpose())&&Objects.equals(stored.getSourceAccount(),task.getSourceAccount())
&&Objects.equals(active.getSourceAccount(),stored.getSourceAccount())
&&Objects.equals(stored.getValidationRootTaskId(),task.getValidationRootTaskId()),"SELFOP_FENCE_REJECTED");
}
private void stage(SelfopSyncTask task,SelfopSyncRun run,String type,String key,String warehouse,int page,Object payload,String status,String code) {
// VALID outbound stages require an observation sequence in the schema. Samples never
// acquire a formal sequence: structure success remains explicit pending sample evidence.
if("VALID".equals(status)){status="PENDING";code="SAMPLE_VALIDATED";}
String keyHash=hash(key);
var prior=stages.selectOne(new QueryWrapper<SelfopSyncStage>().eq("stage_batch_id",task.getId()).eq("object_type",type).eq("object_key_hash",keyHash));
if(prior!=null){require(prior.getContentHash().equals(hash(payload)),"SELFOP_SAMPLE_STAGE_CHANGED");return;}
var row=new SelfopSyncStage();row.setId(IdWorker.getId());row.setStageBatchId(task.getId());row.setRunId(run.getId());row.setLastFetchRunId(run.getId());
row.setObjectType(type);row.setObjectKeyHash(keyHash);row.setWarehouseCode(warehouse);row.setFirstPageNo(page);row.setObservedAtUtc(utcNow());
row.setContentHash(hash(payload));row.setPayloadJson(json(payload));row.setValidationStatus(status);row.setValidationCode(code);
row.setCreatedAt(utcNow());row.setUpdatedAt(utcNow());stages.insert(row);
}
private void save(SelfopSyncTask task,SelfopSyncRun run,Sample state,String phase,String unit,long completed,Long total) {
task.setRoutingManifestJson(json(state));SelfopProgressProjection.applyCheckpoint(task,run,phase,unit,completed,total,1);
tasks.update(null,new UpdateWrapper<SelfopSyncTask>().eq("id",task.getId()).set("routing_manifest_json",task.getRoutingManifestJson())
.set("progress_json",task.getProgressJson()).set("progress_updated_at_utc",task.getProgressUpdatedAtUtc()).set("updated_at",utcNow()));
runs.updateById(run);
}
private void finish(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease,String code,boolean failed) {
// Quota owns root stop reason and consumed allowance. Never overwrite it from a stale task object.
tx.executeWithoutResult(s->{check(task,run,lease);
if(failed)egress.failValidation(task.getValidationRootTaskId(),code);else egress.finishSample(task.getValidationRootTaskId(),code);
var saved=locks.lockTask(task.getId());var attempt=locks.lockRun(run.getId());
boolean dead=failed||"DEAD".equals(saved.getStatus())||"FAILED".equals(attempt.getStatus());
SelfopProgressProjection.applyCheckpoint(saved,attempt,dead?"FAILED":"SAMPLE_DONE","NONE",0,null,1);
SelfopProgressProjection.freezeRun(saved,attempt);attempt.setStatus(dead?"FAILED":"SAMPLE_DONE");
if(attempt.getErrorCode()==null)attempt.setErrorCode(code);
attempt.setCoverageStatus("UNKNOWN");attempt.setFetchFinishedAtUtc(utcNow());attempt.setFinishedAtUtc(utcNow());attempt.setUpdatedAt(utcNow());runs.updateById(attempt);
tasks.update(null,new UpdateWrapper<SelfopSyncTask>().eq("id",task.getId()).set("status",dead?"DEAD":"SAMPLE_DONE")
.set("next_retry_at_utc",null).set("progress_json",saved.getProgressJson()).set("progress_updated_at_utc",saved.getProgressUpdatedAtUtc()).set("updated_at",utcNow()));
});
}
private <T> T request(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease,Supplier<T> request) {
while(true) {
try{return request.get();}
catch(EgressRejectedException wait) {
if(!Set.of("ACCOUNT_INTERVAL_WAIT","ACCOUNT_REQUEST_INFLIGHT").contains(wait.code())||wait.waitUntilUtc()==null)throw wait;
var root=tasks.selectById(task.getValidationRootTaskId());
require(root!=null&&root.getValidationBudgetJson()!=null,"SELFOP_VALIDATION_ROOT_REQUIRED");
var budget=read(root.getValidationBudgetJson(),SelfopValidationBudget.class);
if(!wait.waitUntilUtc().isBefore(budget.deadlineAtUtc))throw new EgressRejectedException("VALIDATION_DEADLINE",true,null);
while(utcNow().isBefore(wait.waitUntilUtc())) {
leases.renew(lease);
try{Thread.sleep(Math.min(1000,Math.max(1,Duration.between(utcNow(),wait.waitUntilUtc()).toMillis())));}
catch(InterruptedException interrupted){Thread.currentThread().interrupt();throw new EgressRejectedException("VALIDATION_INTERRUPTED",false,null);}
}
}
}
}
private static void validatePage(LingxingAnalyticsClient.Page<?> page,int current) {
require(page.current()!=null&&page.current()==current&&page.size()!=null&&page.size()==10&&page.total()!=null&&page.total()>=0
&&page.pages()!=null&&page.pages()>=0&&page.records().size()<=10,"SELFOP_SAMPLE_PAGE_INVALID");
}
private static void validateOrder(SelfopSyncTask task,LingxingAnalyticsClient.Summary summary,SelfopPayloads.Order order) {
require(Objects.equals(task.getSourceAccount(),order.sourceAccount())&&Objects.equals(summary.warehouseCode(),order.warehouseCode())
&&Objects.equals(summary.customerCode(),order.customerCode())&&Objects.equals(summary.deliveryNo(),order.deliveryNo()),"SELFOP_SAMPLE_DETAIL_IDENTITY");
require(order.sourceStatus()!=null&&order.items()!=null,"SELFOP_SAMPLE_DETAIL_STRUCTURE");
for(var item:order.items())identity(item.skuCode(),128);
}
@Data
public static class Sample {
private boolean warehousesRead;
private int pages;
private int outboundPage=1;
private boolean directoryRead;
private List<LingxingAnalyticsClient.Summary> orders=new ArrayList<>();
private int detailIndex;
private Map<String,Integer> inventoryNextPage=new LinkedHashMap<>();
private Set<String> inventoryFinished=new LinkedHashSet<>();
private Set<String> inventoryKeys=new LinkedHashSet<>();
private long inventoryRows;
}
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.selfoperated.analytics.sync;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import io.swagger.v3.oas.annotations.Operation;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
@RestController
public class SyncController {
private final SelfopCommandService commands;
public SyncController(SelfopCommandService commands){this.commands=commands;}
public record Accepted(String taskNo,String warehouseCode,String status) { }
@PostMapping("/api/platform/v1/last-leg/self-operated-analytics/sync-tasks")
@PreAuthorize("hasAuthority('platform:self-operated-analytics:sync')")
@Operation(summary="创建自营分析人工采集或恢复任务",description="平台独立写权限 platform:self-operated-analytics:sync;持久化后异步执行,执行开关默认关闭。BACKFILL使用不超过一天的来源时间切片;未知源时区仅允许暂存。")
public ApiResponse<Accepted> request(@AuthenticationPrincipal AuthenticatedUser user,@RequestBody SelfopCommandService.Command command){
if(user==null||user.tenantId()==null||user.tenantId()!=0||!"PLATFORM".equals(user.appCode())||user.impersonated()||!user.permissions().contains("platform:self-operated-analytics:sync"))
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅限已授权平台人员");
var task=commands.request(command,String.valueOf(user.userId()));return ApiResponse.success(new Accepted(task.getTaskNo(),command.warehouseCode(),"ACCEPTED"));
}
}
@@ -1,8 +1,10 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
import com.baiyue.lastleg.service.reference.ReturnCustomerReferenceClient;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
@@ -16,12 +18,15 @@ import java.util.Map;
@Service
public class ReturnOrderReferenceResolver {
private static final Logger log = LoggerFactory.getLogger(ReturnOrderReferenceResolver.class);
private final FleetCustomerDirectory customers;
private final ReturnCustomerReferenceClient customers;
private final WarehouseResourceGateway warehouses;
private final String internalToken;
public ReturnOrderReferenceResolver(FleetCustomerDirectory customers, WarehouseResourceGateway warehouses) {
public ReturnOrderReferenceResolver(ReturnCustomerReferenceClient customers, WarehouseResourceGateway warehouses,
@Value("${baiyue.security.internal-service-token}") String internalToken) {
this.customers = customers;
this.warehouses = warehouses;
this.internalToken = internalToken;
}
public void populate(List<LastlegOrder> orders) {
@@ -33,14 +38,14 @@ public class ReturnOrderReferenceResolver {
}
private void populate(List<LastlegOrder> orders, boolean required) {
Map<String, FleetCustomerDirectory.Customer> customerReferences = new HashMap<>();
Map<String, ReturnCustomerReferenceClient.Customer> customerReferences = new HashMap<>();
Map<String, WarehouseResourceGateway.WarehouseReference> warehouseReferences = new HashMap<>();
for (LastlegOrder order : orders) {
if (!"RETURN_RELABEL".equals(order.getOrderType())) continue;
if (!StringUtils.hasText(order.getCustomerName())) {
try {
var customer = customerReferences.computeIfAbsent(order.getCustomerId(), customers::find);
order.setCustomerName(customer.name());
var customer = customerReferences.computeIfAbsent(order.getCustomerId(), this::customerReference);
order.setCustomerName(customer.tenantName());
} catch (ResponseStatusException error) {
if (required) throw error;
log.warn("Return customer reference unavailable for order {}, status {}", order.getId(), error.getStatusCode().value());
@@ -59,4 +64,18 @@ public class ReturnOrderReferenceResolver {
}
}
}
private ReturnCustomerReferenceClient.Customer customerReference(String id) {
try {
var result = customers.customers(internalToken, 1, 30, null, "ACTIVE", Long.parseLong(id));
if (result == null || !result.successful() || result.data() == null)
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "客户目录暂不可用,请稍后重试");
return result.data().records().stream()
.filter(row -> row.id() != null && row.id() > 0 && row.id().toString().equals(id) && "ACTIVE".equals(row.status()))
.findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择有效客户"));
} catch (ResponseStatusException error) { throw error; }
catch (RuntimeException error) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "客户目录暂不可用,请稍后重试");
}
}
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.service.reference;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
/** IAM owns customer references used to fill return-order snapshots. */
@FeignClient(name = "iam-service", contextId = "returnCustomerReferenceClient", url = "${baiyue.clients.iam.url:}")
public interface ReturnCustomerReferenceClient {
@GetMapping("/internal/iam/finance-customers")
ApiResponse<PageResult<Customer>> customers(@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam("page") long page, @RequestParam("size") long size, @RequestParam("keyword") String keyword,
@RequestParam("status") String status, @RequestParam("customerId") Long customerId);
record Customer(Long id, String tenantCode, String tenantName, String status, String contactName, String contactPhone) { }
}
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.transfer.gateway;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import java.util.List;
/** Authoritative warehouse catalog for resolving the frozen transfer destination. */
@FeignClient(name = "warehouse-service", contextId = "transferWarehouseCatalogClient",
url = "${baiyue.clients.domestic-warehouse.url:}")
public interface TransferWarehouseCatalogClient {
@GetMapping("/internal/warehouses/order-catalog")
ApiResponse<List<Warehouse>> activeWarehouses(@RequestHeader("X-Internal-Service-Token") String token);
record Warehouse(Long id, String warehouseCode, String warehouseName, String warehouseType, String countryCode) { }
}
@@ -2,7 +2,7 @@ package com.baiyue.lastleg.transfer.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient;
import com.baiyue.lastleg.fleet.driver.FleetWarehouseClient;
import com.baiyue.lastleg.transfer.gateway.TransferWarehouseCatalogClient;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses;
@@ -41,7 +41,7 @@ public class WarehouseTransferReceiptCoordinator {
@Resource private WarehouseTransferReceiptCommandMapper commands;
@Resource private WarehouseResourceGateway warehouse;
@Resource private DomesticWarehouseDirectoryClient directory;
@Resource private FleetWarehouseClient catalog;
@Resource private TransferWarehouseCatalogClient catalog;
@Resource private PlatformTransactionManager transactionManager;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;