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;
@@ -46,6 +46,18 @@ lastleg:
enabled: ${LASTLEG_SMART_DISPATCH_API_ENABLED:true}
baiyue:
self-operated-analytics:
collection-mode: ${SELFOP_COLLECTION_MODE:OFF}
environment: ${DEPLOY_ENV:test}
daily-request-budget: ${SELFOP_DAILY_REQUEST_BUDGET:0}
business-min-request-interval-ms: ${SELFOP_MIN_REQUEST_INTERVAL_MS:5000}
execution-enabled: ${SELFOP_EXECUTION_ENABLED:false}
sales-publication-enabled: ${SELFOP_SALES_PUBLICATION_ENABLED:false}
integration-base-url: http://integration-service:9210
internal-service-token: ${INTERNAL_SERVICE_TOKEN:}
source-account: ${SELFOP_SOURCE_ACCOUNT:lingxing-primary}
source-query-timezone: Asia/Shanghai
quantity-unit: PCS
http:
clients:
unload-photo-file:
@@ -3588,3 +3588,304 @@ PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 新签收及新合箱必须打印并扫码确认标签;既有实物按原状态继续作业。
SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='label_status'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN label_status VARCHAR(32) NULL COMMENT ''WAIT_LABEL待贴标 CONFIRMED已扫码确认标签''');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 平台自营领星统计:独立事实与不可变发布版本;仅空库初始化,不自动执行。
CREATE TABLE ll_selfop_outbound_discovery_scope (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
catalog_from_utc DATETIME(3) NULL COMMENT '账号完整目录连续覆盖UTC下界,不等同销量覆盖',
catalog_until_utc DATETIME(3) NULL COMMENT '账号完整目录连续覆盖UTC上界,不等同销量覆盖',
next_sequence BIGINT NOT NULL DEFAULT 1 COMMENT '新发现任务的单调调度序号;重试沿用,只安排任务,不判断对象内容的新旧',
next_observation_sequence BIGINT NOT NULL DEFAULT 1 COMMENT '每次实际出库详情请求前分配的单调采集代次;与 task 调度序号独立,重试请求也分配新代次',
active_task_id BIGINT NULL COMMENT '当前共享发现/详情补齐任务',
lease_owner VARCHAR(128) NULL COMMENT '账号发现执行者',
lease_until_utc DATETIME(3) NULL COMMENT '账号发现租约到期时刻',
fence_token BIGINT NOT NULL DEFAULT 0 COMMENT '防止过期发现进程继续写共享暂存或派发任务',
request_budget INT NOT NULL COMMENT '一次调度允许的目录、详情请求总预算,必须为正',
time_budget_seconds INT NOT NULL COMMENT '一次调度允许占用的执行时长,必须为正',
min_request_interval_ms INT NOT NULL COMMENT '同账号请求最小间隔,按已核准配额配置',
api_budget_json JSON NULL COMMENT '账号所有采集族共用的预算、统计日、保守预留及确认消耗、尝试许可和独占交接依据',
api_cooldown_until_utc DATETIME(3) NULL COMMENT '账号全部真实请求共用冷却UTC截止,重启与跨日不清除',
api_pause_reason VARCHAR(64) NULL COMMENT '账号停止或等待的固定原因码,不保存供应商原始错误或凭据',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_discovery_account (source_account),
CONSTRAINT chk_selfop_outbound_discovery_scope CHECK (request_budget > 0 AND time_budget_seconds > 0 AND min_request_interval_ms > 0 AND fence_token >= 0 AND next_sequence > 0 AND next_observation_sequence > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='账号共享出库目录与详情发现协调作用域';
CREATE TABLE ll_selfop_sync_scope (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计',
data_family VARCHAR(16) NOT NULL COMMENT 'OUTBOUND 或 INVENTORY;按账号+仓库+任务族互斥',
scope_json JSON NOT NULL COMMENT '本仓范围规则,customerMode=ALL_AUTHORIZED,库存为全部品质;当前账号全部授权货主均自营,不将当天出现的货主列表当白名单',
scope_hash CHAR(64) NOT NULL COMMENT '仓库、货主范围规则、品质、时区及单位口径的规范化摘要;不包含当天观测到的客户/SKU 集合',
business_timezone VARCHAR(64) NOT NULL COMMENT '本仓报表及调度时区,初始建议值为 Asia/Shanghai,各仓独立核准',
source_timezone VARCHAR(64) NULL COMMENT '本仓已核准的出库源时间时区',
schedule_cron VARCHAR(128) NOT NULL COMMENT '本仓、本任务族的调度表达式,按 business_timezone 执行',
stale_after_seconds INT NOT NULL COMMENT '本仓、本任务族数据过期阈值,必须为正;库存初始可采用 26 小时,后续按仓调整',
warehouse_policy_ref VARCHAR(128) NULL COMMENT '未来按仓告警/补货策略的稳定引用;一期为空,不建告警规则表',
initial_from_utc DATETIME(3) NULL COMMENT '首采固定UTC下界,按本仓业务时区减一个月',
initial_until_utc DATETIME(3) NULL COMMENT '首采固定UTC截止,重试不重新取当前时间',
discovery_from_utc DATETIME(3) NULL COMMENT '本仓已发布的连续销量覆盖UTC下界',
discovery_until_utc DATETIME(3) NULL COMMENT '本仓已发布的连续销量覆盖UTC上界,不含端点',
fact_revision BIGINT NOT NULL DEFAULT 0 COMMENT '出库事实归并修订号;内容实际变化才增加',
published_fact_revision BIGINT NOT NULL DEFAULT 0 COMMENT '本仓最后已发布销量版所使用的事实修订号',
rebuild_required TINYINT NOT NULL DEFAULT 0 COMMENT '事实变化待重建标记;跨仓修订同时置旧、新仓为 1',
invalidated_at_utc DATETIME(3) NULL COMMENT '最近一次因事实修订使当前版待重建的时刻',
published_run_id BIGINT NULL COMMENT '唯一对外可见版本,指向本任务族已发布执行批次',
lease_owner VARCHAR(128) NULL COMMENT '当前执行者令牌,不使用仅进程内锁',
lease_until_utc DATETIME(3) NULL COMMENT '租约到期时刻',
fence_token BIGINT NOT NULL DEFAULT 0 COMMENT '每次取得租约递增,事实提交和发布均校验',
known_order_cursor_id BIGINT NULL COMMENT '本仓已可靠处理的稳定订单内部标识游标',
known_order_round_max_id BIGINT NULL COMMENT '本轮已知单复查固定最大内部标识',
known_order_round_started_at_utc DATETIME(3) NULL COMMENT '本仓已知单复查轮次开始UTC时间',
known_order_round_finished_at_utc DATETIME(3) NULL COMMENT '本仓已知单完整轮转完成UTC时间',
history_round_from_utc DATETIME(3) NULL COMMENT '本仓历史核对固定UTC下界',
history_round_until_utc DATETIME(3) NULL COMMENT '本仓历史核对固定UTC上界',
history_slice_cursor_utc DATETIME(3) NULL COMMENT '本仓下一待核对历史切片UTC位置',
history_round_finished_at_utc DATETIME(3) NULL COMMENT '上一轮历史时间片全部完成时刻',
review_request_budget INT NOT NULL COMMENT '本仓单次复查最大请求数,必须为正',
review_time_budget_seconds INT NOT NULL COMMENT '本仓单次复查最大执行秒数,必须为正',
review_target_days INT NOT NULL DEFAULT 7 COMMENT '建议每 7 天轮转一遍,属于待配额验证的目标,不保证首次大量详情在单次调度内完成',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_scope (source_account,warehouse_code,data_family),
KEY idx_selfop_scope_lease (lease_until_utc),
CONSTRAINT chk_selfop_sync_scope CHECK (data_family IN ('OUTBOUND','INVENTORY') AND stale_after_seconds > 0 AND review_request_budget > 0 AND review_time_budget_seconds > 0 AND review_target_days > 0 AND fact_revision >= 0 AND published_fact_revision >= 0 AND fence_token >= 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='分仓任务族租约及发布指针';
CREATE TABLE ll_selfop_sync_task (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
task_no VARCHAR(64) NOT NULL COMMENT '稳定采集业务任务号,重试沿用,不展示内部数据库标识',
execution_purpose VARCHAR(16) NOT NULL COMMENT 'BUSINESS或VALIDATION;由服务端冻结用途,子任务继承',
validation_root_task_id BIGINT NULL COMMENT '验证会话唯一预算根任务,根引用自身,正式任务为空',
validation_budget_json JSON NULL COMMENT '验证根冻结硬预算、累计消耗、库存分仓页数、截止时刻及停止原因',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
task_role VARCHAR(24) NOT NULL COMMENT 'ACCOUNT_DISCOVERY 或 WAREHOUSE_SYNC',
scope_id BIGINT NULL COMMENT '分仓任务必须引用分仓作用域;仅账号共享发现为空',
warehouse_code VARCHAR(64) NULL COMMENT '来源仓库原始编码,按仓独立统计',
discovery_scope_id BIGINT NULL COMMENT '共享发现必填,引用账号发现协调器',
upstream_task_id BIGINT NULL COMMENT '出库分仓任务引用已持久化目录的共享发现切片;库存无此上游',
discovery_sequence BIGINT NULL COMMENT '共享任务及分仓子任务继承的调度顺序;重试保持不变,不用于事实覆盖判定',
observation_baseline BIGINT NULL COMMENT '共享任务建立时已分配的最大详情采集代次,分仓子任务继承;用于识别旧任务恢复期间出现的其他任务新观测',
request_key VARCHAR(128) NOT NULL COMMENT '共享发现包含切片/计划槽位;分仓任务包含仓库、任务族及切片或计划槽位;人工附请求幂等键',
request_hash CHAR(64) NOT NULL COMMENT '请求类型、查询范围、范围摘要的指纹;同键不同请求拒绝',
task_type VARCHAR(24) NOT NULL COMMENT 'SCHEDULED、MANUAL_RETRY、BACKFILL、RECHECK、REBUILD、VALIDATION;验证不由自动计划创建',
planned_at_utc DATETIME(3) NULL COMMENT '计划时点;人工请求可空',
query_from_utc DATETIME(3) NULL COMMENT '出库查询区间UTC下界,库存不填',
query_until_utc DATETIME(3) NULL COMMENT '出库查询区间UTC上界,不含端点,库存不填',
query_scope_json JSON NOT NULL COMMENT '不变的账号、货主、仓库、品质和查询条件快照;按单补查在此记录单号',
routing_manifest_json JSON NULL COMMENT '共享切片目录完整标记、每仓对象键集合/数量和详情就绪状态;各对象记录 effectiveStageId,需新代次复查时记录 refreshTaskId,完成后指向新有效暂存;不改写旧载荷',
scope_hash CHAR(64) NOT NULL COMMENT '建任务时的业务范围摘要',
status VARCHAR(20) NOT NULL COMMENT 'WAITING_SOURCE、READY、RUNNING、RETRY_WAIT、SUCCEEDED、DEAD、SAMPLE_DONE;样本结束不等于正式成功',
apply_state VARCHAR(20) NOT NULL COMMENT '正式出库归并NOT_STARTED、APPLYING、BLOCKED、APPLIED;共享发现、库存、重建及验证为NOT_APPLICABLE',
affected_warehouse_codes_json JSON NULL COMMENT '本次归并可能改变的仓集合,包含原仓与新仓;首段事实写入前登记,扩大集合须先取得新增仓租约并锁 scope,不能写完再补',
apply_started_at_utc DATETIME(3) NULL COMMENT '首段事实提交UTC时间',
apply_completed_at_utc DATETIME(3) NULL COMMENT '全部必需事实归并完成UTC时间,失败不填',
attempt_count INT NOT NULL DEFAULT 0 COMMENT '已创建的执行尝试数',
next_retry_at_utc DATETIME(3) NULL COMMENT '有限退避后下一次可执行时刻',
requested_by VARCHAR(128) NULL COMMENT '人工请求的平台操作人,仅内部审计',
result_published_run_id BIGINT NULL COMMENT '正式成功任务实际发布或复用的版本;验证和共享发现为空',
progress_json JSON NULL COMMENT '与可靠检查点一起提交的有界阶段、单位、尝试和质量进度,不保存单据集合',
progress_updated_at_utc DATETIME(3) NULL COMMENT '最后可靠业务检查点UTC时间,与执行心跳分开',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_task_no (task_no),
UNIQUE KEY uk_selfop_task_request (source_account,request_key),
KEY idx_selfop_task_poll (status,next_retry_at_utc,id),
KEY idx_selfop_task_upstream (upstream_task_id,warehouse_code),
KEY idx_selfop_task_apply (apply_state,id),
KEY idx_selfop_task_scope_list (scope_id,created_at,id),
KEY idx_selfop_task_account_list (source_account,task_role,created_at,id),
CONSTRAINT chk_selfop_sync_task CHECK (attempt_count >= 0 AND execution_purpose IN ('BUSINESS','VALIDATION') AND ((execution_purpose = 'BUSINESS' AND validation_root_task_id IS NULL AND validation_budget_json IS NULL) OR (execution_purpose = 'VALIDATION' AND validation_root_task_id IS NOT NULL AND apply_state = 'NOT_APPLICABLE' AND result_published_run_id IS NULL AND status <> 'SUCCEEDED')) AND ((task_role = 'ACCOUNT_DISCOVERY' AND scope_id IS NULL AND warehouse_code IS NULL AND discovery_scope_id IS NOT NULL) OR (task_role = 'WAREHOUSE_SYNC' AND scope_id IS NOT NULL AND warehouse_code IS NOT NULL)))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='一次计划或人工请求';
CREATE TABLE ll_selfop_sync_run (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
task_id BIGINT NOT NULL COMMENT '本次执行请求',
execution_purpose VARCHAR(16) NOT NULL COMMENT '从逻辑任务冻结继承,验证尝试不能作为正式统计版本',
scope_id BIGINT NULL COMMENT '仅账号共享发现为空;分仓执行必须有作用域',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
warehouse_code VARCHAR(64) NULL COMMENT '来源仓库原始编码,按仓独立统计',
run_role VARCHAR(24) NOT NULL COMMENT 'ACCOUNT_DISCOVERY 或 WAREHOUSE_SYNC',
upstream_task_id BIGINT NULL COMMENT '分仓出库计算引用的共享切片及其稳定暂存集合',
attempt_no INT NOT NULL COMMENT '同一请求从 1 起递增',
scope_hash CHAR(64) NOT NULL COMMENT '本版本的业务范围摘要',
scope_snapshot_json JSON NOT NULL COMMENT '分仓版只含本仓货主/品质、固定首采 [B,E0)、时区、调度与过期阈值;账号发现保存待路由仓集合,不冒充业务版',
observed_dimensions_json JSON NULL COMMENT '本次实际出现的仓库/货主集合及数量摘要,仅作采集证据;例如已观测 70 货主不等于固定授权范围',
fence_token BIGINT NOT NULL COMMENT '本尝试持有的栅栏版本',
base_published_run_id BIGINT NULL COMMENT '执行开始时的上一可见版本',
reference_sales_run_id BIGINT NULL COMMENT '库存构建开始时固定的本仓已发布销量版,用其不可变 SKU 维度生成出库已知商品占位;没有已发布销量版时为空,不读取半归并订单事实',
inventory_daily_sample_date DATE NULL COMMENT '北京时间当日首个合格计划库存发布代表日期,非代表为空',
inventory_sample_rule_version VARCHAR(32) NULL COMMENT '日库存代表选择规则 DAILY_SCHEDULED_FIRST_V1,与日标记原子保存',
status VARCHAR(24) NOT NULL COMMENT 'FETCHING、VALIDATED、ROUTED、FACTS_APPLIED、BUILDING、READY、PUBLISHED、REUSED、YIELDED、FAILED、ABORTED、SAMPLE_DONE;复用和验证不是版本头',
last_phase VARCHAR(24) NOT NULL COMMENT '失败/中止前所在阶段,支持判断是否已经归并事实',
fetch_started_at_utc DATETIME(3) NULL COMMENT '实际采集开始UTC时间',
fetch_finished_at_utc DATETIME(3) NULL COMMENT '实际采集完成UTC时间,未完成为空',
build_started_at_utc DATETIME(3) NULL COMMENT '实际开始有界候选构建UTC时间,用于构建耗时及首采合并间隔',
finished_at_utc DATETIME(3) NULL COMMENT '本次尝试终止UTC时间',
published_at_utc DATETIME(3) NULL COMMENT '实际原子切换发布指针UTC时间',
superseded_at_utc DATETIME(3) NULL COMMENT '被新发布版替代的首次UTC时间,同事务填写且重试不重置',
page_count INT NOT NULL DEFAULT 0 COMMENT '已完整获取页数',
source_total_count BIGINT NULL COMMENT '上游可靠返回的总数;未返回则为空',
unique_object_count BIGINT NOT NULL DEFAULT 0 COMMENT '规范化去重后对象数',
progress_snapshot_json JSON NULL COMMENT '尝试终止时冻结的有界可靠进度摘要,历史尝试不随逻辑任务改写',
heartbeat_at_utc DATETIME(3) NULL COMMENT '有效执行者最新UTC心跳,不代表业务检查点推进',
coverage_status VARCHAR(16) NOT NULL COMMENT 'UNKNOWN、PARTIAL、COMPLETE;分页完整与数值有效性分别判断',
covered_from_utc DATETIME(3) NULL COMMENT '本仓销量版本实际连续覆盖UTC下界',
covered_until_utc DATETIME(3) NULL COMMENT '本仓销量版本实际连续覆盖UTC上界,不含端点',
complete_day_from DATE NULL COMMENT '本仓完整自然日区间首日,包含',
complete_day_until DATE NULL COMMENT '本仓完整自然日区间末日,不含',
applied_fact_revision BIGINT NULL COMMENT '本次尝试归并后的事实修订号,库存为空',
calculated_fact_revision BIGINT NULL COMMENT '候选销量版本计算所依据的固定事实修订号',
error_code VARCHAR(64) NULL COMMENT '内部可诊断错误分类,不保存认证信息',
error_message VARCHAR(500) NULL COMMENT '脱敏后的固定错误摘要',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_run_attempt (task_id,attempt_no),
UNIQUE KEY uk_selfop_inventory_daily (source_account,warehouse_code,inventory_daily_sample_date),
KEY idx_selfop_run_scope (scope_id,id),
KEY idx_selfop_run_status (status,updated_at),
CONSTRAINT chk_selfop_inventory_daily CHECK ((inventory_daily_sample_date IS NULL AND inventory_sample_rule_version IS NULL) OR (inventory_daily_sample_date IS NOT NULL AND inventory_sample_rule_version IS NOT NULL AND inventory_sample_rule_version = 'DAILY_SCHEDULED_FIRST_V1' AND execution_purpose = 'BUSINESS' AND run_role = 'WAREHOUSE_SYNC' AND status = 'PUBLISHED' AND coverage_status = 'COMPLETE')),
CONSTRAINT chk_selfop_sync_run CHECK (attempt_no > 0 AND page_count >= 0 AND unique_object_count >= 0 AND execution_purpose IN ('BUSINESS','VALIDATION') AND (execution_purpose <> 'VALIDATION' OR status NOT IN ('PUBLISHED','REUSED')) AND (source_total_count IS NULL OR source_total_count >= 0) AND ((run_role = 'ACCOUNT_DISCOVERY' AND scope_id IS NULL AND warehouse_code IS NULL) OR (run_role = 'WAREHOUSE_SYNC' AND scope_id IS NOT NULL AND warehouse_code IS NOT NULL)))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='执行尝试、覆盖证明和版本头';
CREATE TABLE ll_selfop_sync_stage (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
run_id BIGINT NOT NULL COMMENT '首次写入该暂存对象的执行尝试',
stage_batch_id BIGINT NOT NULL COMMENT '出库取共享发现 task.id,便于断点续取;库存取本次 run.id,严禁跨尝试混页',
last_fetch_run_id BIGINT NOT NULL COMMENT '最后一次补齐该对象的执行尝试,保留重试来源',
observation_sequence BIGINT NULL COMMENT '该完整出库详情实际请求的采集代次;有效出库对象必填,目录待补对象和库存为空;不能填 task 调度序号',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计',
previous_warehouse_code VARCHAR(64) NULL COMMENT '路由时从已有单据识别的原仓库;归并前再校验,跨仓时通知两仓',
object_type VARCHAR(16) NOT NULL COMMENT 'OUTBOUND_ORDER 或 PRODUCT_STOCK',
object_key_hash CHAR(64) NOT NULL COMMENT '规范化业务键摘要;原业务键保留在载荷中并核对',
first_page_no INT NOT NULL COMMENT '首次发现页码',
observed_at_utc DATETIME(3) NOT NULL COMMENT '实际接收该对象的时刻',
content_hash CHAR(64) NOT NULL COMMENT '完整规范化对象摘要',
payload_json JSON NOT NULL COMMENT '目录阶段可先存订单头,详情齐后补完整 SKU;VALID 后不再修改;库存保存产品库存字段',
before_payload_json JSON NULL COMMENT '订单归并事务内记录归并前的完整规范化事实;新增单为空',
validation_status VARCHAR(16) NOT NULL COMMENT '暂存校验状态:PENDING待核验、VALID有效、INVALID异常',
validation_code VARCHAR(64) NULL COMMENT '字段缺失、冲突、未知时区等原因',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_stage_object (stage_batch_id,object_type,object_key_hash),
KEY idx_selfop_stage_history (object_type,object_key_hash,stage_batch_id),
KEY idx_selfop_stage_observation (object_type,object_key_hash,validation_status,observation_sequence),
KEY idx_selfop_stage_route (stage_batch_id,warehouse_code,validation_status),
CONSTRAINT chk_selfop_sync_stage CHECK (first_page_no > 0 AND (object_type <> 'OUTBOUND_ORDER' OR validation_status <> 'VALID' OR observation_sequence IS NOT NULL))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='待验证事实与修订证据';
CREATE TABLE ll_selfop_outbound_order (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计',
delivery_no VARCHAR(128) NOT NULL COMMENT 'OMP 发货单号,采用列表及详情同一身份',
source_no VARCHAR(128) NULL COMMENT 'WMS 来源出库单号,缺失不以内部 ID 回退展示',
source_status INT NOT NULL COMMENT '原始状态,100 已出库、99 已取消;不改造成内部状态码',
source_created_time VARCHAR(64) NULL COMMENT '来源创建时间原始字符串,不使用采集时间替代',
source_outbound_time VARCHAR(64) NULL COMMENT '来源实际出库时间原始字符串,不使用采集时间替代',
source_timezone VARCHAR(64) NULL COMMENT '解析采用的源时区',
outbound_at_utc DATETIME(3) NULL COMMENT '已核准的实际出库时间点;非出库状态可空',
business_date DATE NULL COMMENT '已核准业务时区下的出库日',
business_timezone VARCHAR(64) NOT NULL COMMENT '该事实当前归属仓所用业务时区,迁仓后重新确定业务日',
sales_eligible TINYINT NOT NULL DEFAULT 0 COMMENT '仅已出库、时间与全部数量有效时为 1',
revision_no BIGINT NOT NULL DEFAULT 1 COMMENT '该单内容变更次数,不等于拉取次数',
content_hash CHAR(64) NOT NULL COMMENT '订单头及完整 SKU 明细规范化摘要',
last_run_id BIGINT NOT NULL COMMENT '最后一次确认或修订的执行尝试',
last_observation_sequence BIGINT NOT NULL COMMENT '最后接受的完整详情采集代次;仅防旧观测重放,不按 task 调度序号拒绝较晚实际读取的事实,不冒充领星源版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_order (source_account,customer_code,delivery_no),
KEY idx_selfop_order_daily (source_account,warehouse_code,sales_eligible,business_date,id),
KEY idx_selfop_order_run (last_run_id),
CONSTRAINT chk_selfop_outbound_order CHECK (revision_no > 0 AND last_observation_sequence > 0 AND sales_eligible IN (0,1) AND (sales_eligible = 0 OR (source_status = 100 AND source_timezone IS NOT NULL AND outbound_at_utc IS NOT NULL AND business_date IS NOT NULL)))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='最后确认的单据事实';
CREATE TABLE ll_selfop_outbound_item (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
order_id BIGINT NOT NULL COMMENT '对应外部出库事实',
sku_code VARCHAR(128) NOT NULL COMMENT '原始 productSku',
product_name VARCHAR(255) NULL COMMENT '来源商品名称,仅作展示',
actual_quantity DECIMAL(20,6) NOT NULL COMMENT '详情 actualDeliveryQty,必须为非负有效数值',
quantity_unit VARCHAR(32) NOT NULL COMMENT '已核准业务单位;未确认单位不发布正式数量统计',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_order_sku (order_id,sku_code),
KEY idx_selfop_item_sku (sku_code,order_id),
CONSTRAINT chk_selfop_outbound_item CHECK (actual_quantity >= 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='整单替换的实际出库明细';
CREATE TABLE ll_selfop_inventory_snapshot (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
snapshot_run_id BIGINT NOT NULL COMMENT 'INVENTORY 执行尝试;发布后该批明细不可修改',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计',
sku_code VARCHAR(128) NOT NULL COMMENT '原始产品 SKU',
product_quality SMALLINT NOT NULL COMMENT '来源品质,当前 0 正品、1 次品;全量均保存,不套用整箱库存',
product_name VARCHAR(255) NULL COMMENT '来源名称',
physical_quantity DECIMAL(20,6) NULL COMMENT '来源stkQty实物库存,未知为空',
available_quantity DECIMAL(20,6) NULL COMMENT '来源useStkQty可用库存,未知为空,不再次扣冻结',
frozen_quantity DECIMAL(20,6) NULL COMMENT '来源freezeQty冻结库存,未知为空',
transit_quantity DECIMAL(20,6) NULL COMMENT '来源transitQty在途数量,未知为空,不计可售库存',
source_unique_key VARCHAR(512) NULL COMMENT '原 uniqueKey4tenant,只作来源证据',
observed_at_utc DATETIME(3) NOT NULL COMMENT '所在页实际采集时间;缺行占位取本批采集完成时间',
quality_status VARCHAR(16) NOT NULL COMMENT 'VALID、UNKNOWN、INVALID;STALE 按批次年龄实时派生',
quality_reason VARCHAR(64) NULL COMMENT 'SOURCE_ROW_MISSING、FIELD_MISSING、NEGATIVE_QUANTITY 等',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_stock_snapshot (snapshot_run_id,source_account,customer_code,warehouse_code,sku_code,product_quality),
KEY idx_selfop_stock_lookup (source_account,customer_code,warehouse_code,sku_code,product_quality,snapshot_run_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='产品库存全量批次明细';
CREATE TABLE ll_selfop_sku_daily_sales (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
sales_run_id BIGINT NOT NULL COMMENT 'OUTBOUND 执行尝试,同时是计算版本',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计',
sku_code VARCHAR(128) NOT NULL COMMENT '原始产品 SKU',
business_date DATE NOT NULL COMMENT '已核准业务日',
outbound_quantity DECIMAL(20,6) NOT NULL COMMENT '有效出库实际件数之和,非负',
outbound_order_count BIGINT NOT NULL COMMENT '该维度去重发货单数,非负',
quantity_unit VARCHAR(32) NOT NULL COMMENT '同一统计维度内一致的业务单位',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_daily_version (sales_run_id,source_account,customer_code,warehouse_code,sku_code,business_date),
KEY idx_selfop_daily_window (sales_run_id,business_date,customer_code,sku_code),
CONSTRAINT chk_selfop_sku_daily_sales CHECK (outbound_quantity >= 0 AND outbound_order_count >= 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本的稀疏日事实';
CREATE TABLE ll_selfop_sku_profile (
id BIGINT NOT NULL COMMENT '应用生成的记录标识',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
profile_run_id BIGINT NOT NULL COMMENT '本仓销量或库存候选版本;与对应 daily/snapshot 使用同一 run,发布后该版本全部维度行不可修改',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计',
sku_code VARCHAR(128) NOT NULL COMMENT '原始产品 SKU',
product_name VARCHAR(255) NULL COMMENT '本版本确认的名称,不实时连接其他版本名称',
quantity_unit VARCHAR(32) NULL COMMENT '本版本已核准单位;未知或两版本单位冲突时不计算混合指标',
first_seen_at_utc DATETIME(3) NULL COMMENT '本证据族真实观测到本仓商品的最早接收时刻;缺行占位不能生成观察证据',
earliest_evidence_at_utc DATETIME(3) NULL COMMENT '库存版取真实库存首次观察时刻;销量版从该版有效事实重新求最早已核准出库时间,可随改期、取消或迁仓撤回/后移',
evidence_type VARCHAR(24) NOT NULL COMMENT 'INVENTORY_OBSERVATION、VALID_OUTBOUND、NONE;不得将销量证据复制成库存真实观察证据',
evidence_run_id BIGINT NULL COMMENT '本证据族真实依据的采集批次,无证据占位为空',
last_seen_run_id BIGINT NULL COMMENT '本证据族最近真实观测批次,无证据占位为空',
PRIMARY KEY (id),
UNIQUE KEY uk_selfop_profile_version (profile_run_id,source_account,customer_code,warehouse_code,sku_code),
KEY idx_selfop_profile_evidence (profile_run_id,earliest_evidence_at_utc,customer_code,sku_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='随发布版本固定的 SKU 维度与观察依据';
@@ -0,0 +1,115 @@
openapi: 3.0.3
info:
title: Self-operated Analytics Internal Egress Authority
version: 1.0.0
paths:
/internal/lastleg/self-operated-analytics/egress/authorize:
post:
operationId: authorizeSelfoperatedEgressAttempt
summary: 一次性消费已持久预留的领星请求许可
description: >-
仅内部服务令牌。客户端已登记的 context 和 operation 必须完全匹配;
requestHash 是 Integration 对 OMP 路径和有序业务 JSON 的摘要,首次消费绑定,
后续完成必须完全匹配。同 attemptId 再次消费拒绝。allowedAttempts 固定 1,
expiresAt 最多 30 秒且不超过验证根截止或人工独占窗口。OFF 默认拒绝,
非 production 环境拒绝 BUSINESS。独占交接证明及接续预算由服务端配置核验。
security: [{ internalServiceToken: [] }]
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/AuthorizationRequest' }
responses:
'200':
description: HTTP 200;许可 code=200,正常拒绝 code=B0001,data 为显式预算决策
content:
application/json:
schema:
type: object
properties:
code: { type: string }
data:
oneOf:
- { $ref: '#/components/schemas/AttemptPermit' }
- { $ref: '#/components/schemas/AuthorizationDenial' }
'401': { description: 内部令牌无效 }
'503': { description: 内部令牌未配置 }
/internal/lastleg/self-operated-analytics/egress/complete:
post:
operationId: completeSelfoperatedEgressAttempt
summary: 记录真实尝试次数和账号冷却
description: >-
回调 context、operation、requestHash 必须匹配已消费许可。actualHttpAttempts 仅 0 或 1;
即使 0 也不退回预留,响应丢失同样保留保守消耗。重复已确认完成为幂等成功。
429 对账号全部采集族持久冷却;验证根 DEAD、run FAILED、禁止自动续跑。
未确认结果的许可到期后保守冷却,不使用换日、换仓、重试或重启清零预算。
security: [{ internalServiceToken: [] }]
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/CompletionRequest' }
responses:
'200':
description: ApiResponse code 字符串 200,data 为布尔 true
content:
application/json:
schema:
type: object
properties:
code: { type: string }
data: { type: boolean, enum: [true] }
'401': { description: 内部令牌无效 }
components:
securitySchemes:
internalServiceToken: { type: apiKey, in: header, name: X-Internal-Service-Token }
schemas:
EgressContext:
type: object
required: [sourceAccount, executionPurpose, rootTaskId, sessionId, attemptId, exclusiveGrantId]
properties:
sourceAccount: { type: string, maxLength: 64 }
executionPurpose: { type: string, enum: [VALIDATION, BUSINESS] }
rootTaskId: { type: string, pattern: '^[0-9]+$' }
sessionId: { type: string, maxLength: 128 }
attemptId: { type: string, maxLength: 128 }
exclusiveGrantId: { type: string, maxLength: 128 }
Operation:
type: string
enum: [WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY]
AuthorizationRequest:
type: object
required: [context, operation, requestHash]
properties:
context: { $ref: '#/components/schemas/EgressContext' }
operation: { $ref: '#/components/schemas/Operation' }
requestHash: { type: string, pattern: '^[a-f0-9]{64}$' }
AuthorizationDenial:
type: object
required: [errorCode, sampleDone, waitUntil]
properties:
errorCode: { type: string, pattern: '^[A-Z][A-Z0-9_]{0,63}$' }
sampleDone: { type: boolean }
waitUntil: { type: string, format: date-time, nullable: true, description: UTC Instant 或显式 null }
AttemptPermit:
allOf:
- { $ref: '#/components/schemas/AuthorizationRequest' }
- type: object
required: [allowedAttempts, expiresAt, exclusiveConfirmed, budgetConsumed]
properties:
allowedAttempts: { type: integer, enum: [1] }
expiresAt: { type: string, format: date-time }
exclusiveConfirmed: { type: boolean, enum: [true] }
budgetConsumed: { type: boolean, enum: [true] }
CompletionRequest:
allOf:
- { $ref: '#/components/schemas/AuthorizationRequest' }
- type: object
required: [actualHttpAttempts, accountPaused]
properties:
actualHttpAttempts: { type: integer, minimum: 0, maximum: 1 }
errorCode: { type: string, nullable: true }
upstreamStatus: { type: integer, nullable: true }
sourceCode: { type: integer, nullable: true }
accountPaused: { type: boolean }
cooldownUntil: { type: string, format: date-time, nullable: true }
@@ -0,0 +1,62 @@
openapi: 3.0.3
info:
title: Platform Self-operated Analytics Sync Commands
version: 1.0.0
paths:
/api/platform/v1/last-leg/self-operated-analytics/sync-tasks:
post:
operationId: requestSelfoperatedAnalyticsSyncTask
summary: 创建人工采集、复查、重建或恢复任务
description: >-
仅平台账号且须 platform:self-operated-analytics:sync 权限。命令先持久化,
不在 HTTP 请求内运行历史采集。collectionMode 默认 OFF;VALIDATION 仅用于持久预算内的样本暂存,
BUSINESS 仅允许生产环境,并要求真实出口取得服务端核验的人工独占许可。
出库来源时间及业务时间统一 Asia/Shanghai;首次正式目标固定为 E0 前两个滚动月。
BACKFILL 单片不超过一天。RETRY 使用 taskNo 和非空 reason 恢复原分仓逻辑任务,
保留半归并屏障及旧尝试,拒绝验证会话复活、账号共享任务伪装单仓、有效执行租约及上游未齐;
requestKey 同键异载荷拒绝。成功受理只表示排队,不表示采集或发布成功。
security:
- bearerAuth: []
x-required-authorities:
- platform:self-operated-analytics:sync
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SyncCommand'
responses:
'200':
description: 平台 ApiResponse 包络,成功 code 为字符串 200;返回稳定业务任务号与 ACCEPTED
content:
application/json:
schema:
type: object
properties:
code: { type: string }
message: { type: string }
data:
type: object
properties:
taskNo: { type: string }
warehouseCode: { type: string }
status: { type: string, enum: [ACCEPTED] }
'401': { description: 未认证 }
'403': { description: 非平台身份或无独立写权限 }
components:
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
schemas:
SyncCommand:
type: object
required: [requestKey, warehouseCode, action]
properties:
requestKey: { type: string, minLength: 1, maxLength: 100 }
warehouseCode: { type: string, minLength: 1, maxLength: 64 }
action: { type: string, enum: [INVENTORY, BACKFILL, RECHECK, REBUILD, RETRY, VALIDATION] }
taskNo: { type: string, minLength: 1, maxLength: 64, description: RETRY 必填,原始分仓业务任务号 }
reason: { type: string, minLength: 1, maxLength: 500, description: RETRY 必填,随操作人和原任务审计保存 }
sourceFrom: { type: string, example: '2026-10-11T00:00:00' }
sourceUntil: { type: string, example: '2026-10-12T00:00:00' }
customerCode: { type: string, maxLength: 64, description: RECHECK 必填 }
deliveryNo: { type: string, maxLength: 128, description: RECHECK 必填 }
@@ -0,0 +1,700 @@
openapi: 3.0.3
info:
title: 平台自营出库销量库存及采集运行只读接口
version: 1.0.0
description: >-
全部GET要求PLATFORM身份、tenantId=0、非代登录及独立platform:self-operated-analytics:list权限。
来源及业务时区Asia/Shanghai,单位PCS,账号和仓库由服务端已配置范围校验。
经营查询首屏生成context,包含版本对、独立范围摘要、queryAsOf/queryExpiresAt。
后续列表、图表、榜单和库存复用完整上下文,版本0保持不可用,不偷偷补入新版本。
上下文最多30分钟且不超过旧销量版superseded_at_utc+7天;queryAsOf不得未来或早于所选版本发布时间。
过期/范围不符明确要求刷新整页,不能静默切版;各请求在同一REPEATABLE_READ快照内校验和读取。
五周期包含今日,CUSTOM两端包含且endDate不晚于anchorDate;只有CUSTOM接受startDate/endDate。
默认maxQueryDays366,粒度DAY。正式outboundQuantity与observedQuantity独立,全部未知仍为空。
每日COMPLETE仅证明全天覆盖及有效日行或可靠SKU存在;今日/部分日正式量空,观察量可用,未知日期保持空。
SKU区间正式量仅全部日完整才输出;observedQuantity为可靠点合计,完整/部分/未知日数独立。
切换图表不改变完整7/30日榜单及30日日均分母;不触发领星请求。
运行接口不受经营context限制。动态库存时效、重建和修订待核对独立返回。
阶段进度区分ACCOUNT_SHARED/WAREHOUSE及TASK_CUMULATIVE/ATTEMPT_ONLY,不伪造总百分比。
total未知为空、计数不能跨PAGE/ORDER/INVENTORY_ROW/BUSINESS_KEY/SKU/BATCH单位求和,NONE为非计数阶段且completed/total为空、totalKnown=false;阶段检查点与心跳分开。
任务号稳定,内部诊断ID不作为业务编号回退。SKU订单数不可相加为仓总单数。
重试沿既有POST sync-tasks,action=RETRY/requestKey/warehouseCode/taskNo/reason,使用独立sync权限并最终复核资格。
security: [{bearerAuth: []}]
paths:
/api/platform/v1/last-leg/self-operated-analytics/sku-sales-summary:
get:
summary: SKU五周期或历史累计清单
description: '候选为两版profile并集,SQL按选定日期聚合后按observedQuantity降序空值末尾、SKU及货主升序,再分页。未知日不填零。 权限platform:self-operated-analytics:list。'
parameters:
- {$ref: '#/components/parameters/sourceAccount'}
- {$ref: '#/components/parameters/warehouseCode'}
- {$ref: '#/components/parameters/customerCode'}
- {$ref: '#/components/parameters/sku'}
- {$ref: '#/components/parameters/salesRunId'}
- {$ref: '#/components/parameters/inventoryRunId'}
- {$ref: '#/components/parameters/salesScopeHash'}
- {$ref: '#/components/parameters/inventoryScopeHash'}
- {$ref: '#/components/parameters/queryAsOf'}
- {$ref: '#/components/parameters/rangePreset'}
- {$ref: '#/components/parameters/startDate'}
- {$ref: '#/components/parameters/endDate'}
- {$ref: '#/components/parameters/bucket'}
- {$ref: '#/components/parameters/current'}
- {$ref: '#/components/parameters/size'}
responses:
'200':
description: '统一ApiResponse,业务失败HTTP200且code非200。'
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data: {$ref: '#/components/schemas/SalesPage'}
'401': {description: 缺少可信认证身份}
'403': {description: 非平台身份或缺少独立只读权限}
/api/platform/v1/last-leg/self-operated-analytics/sku-daily-sales:
get:
summary: 精确单SKU日趋势
description: 'customerCode和sku必填,精确原始业务键匹配。最多366日且返回完整升序日期轴,不分页。无候选明确业务错误。 权限platform:self-operated-analytics:list。'
parameters:
- {$ref: '#/components/parameters/sourceAccount'}
- {$ref: '#/components/parameters/warehouseCode'}
- {$ref: '#/components/parameters/customerCodeRequired'}
- {$ref: '#/components/parameters/skuRequired'}
- {$ref: '#/components/parameters/salesRunId'}
- {$ref: '#/components/parameters/inventoryRunId'}
- {$ref: '#/components/parameters/salesScopeHash'}
- {$ref: '#/components/parameters/inventoryScopeHash'}
- {$ref: '#/components/parameters/queryAsOf'}
- {$ref: '#/components/parameters/rangePreset'}
- {$ref: '#/components/parameters/startDate'}
- {$ref: '#/components/parameters/endDate'}
- {$ref: '#/components/parameters/bucket'}
responses:
'200':
description: '统一ApiResponse,业务失败HTTP200且code非200。'
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data: {$ref: '#/components/schemas/Trend'}
'401': {description: 缺少可信认证身份}
'403': {description: 非平台身份或缺少独立只读权限}
/api/platform/v1/last-leg/self-operated-analytics/hot-skus:
get:
summary: 完整7/30日热销Top100
description: '独立截至queryAsOf昨日的完整窗口,数量大于零,数量降序、SKU单数降序、SKU及货主升序,先Top100再分页;当前缺货仍可入榜。 权限platform:self-operated-analytics:list。'
parameters:
- {$ref: '#/components/parameters/sourceAccount'}
- {$ref: '#/components/parameters/warehouseCode'}
- {$ref: '#/components/parameters/customerCode'}
- {$ref: '#/components/parameters/sku'}
- {$ref: '#/components/parameters/salesRunId'}
- {$ref: '#/components/parameters/inventoryRunId'}
- {$ref: '#/components/parameters/salesScopeHash'}
- {$ref: '#/components/parameters/inventoryScopeHash'}
- {$ref: '#/components/parameters/queryAsOf'}
- {$ref: '#/components/parameters/windowDays'}
- {$ref: '#/components/parameters/current'}
- {$ref: '#/components/parameters/size'}
responses:
'200':
description: '统一ApiResponse,业务失败HTTP200且code非200。'
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data: {$ref: '#/components/schemas/MetricPage'}
'401': {description: 缺少可信认证身份}
'403': {description: 非平台身份或缺少独立只读权限}
/api/platform/v1/last-leg/self-operated-analytics/slow-skus:
get:
summary: 固定完整30日有库存低出库Top100
description: '固定30天,不接受windowDays或rangePreset。正品有效新鲜可售量大于零、完整窗口且起点前可靠存在。数量升序、可售量降序、SKU及货主升序。 权限platform:self-operated-analytics:list。'
parameters:
- {$ref: '#/components/parameters/sourceAccount'}
- {$ref: '#/components/parameters/warehouseCode'}
- {$ref: '#/components/parameters/customerCode'}
- {$ref: '#/components/parameters/sku'}
- {$ref: '#/components/parameters/salesRunId'}
- {$ref: '#/components/parameters/inventoryRunId'}
- {$ref: '#/components/parameters/salesScopeHash'}
- {$ref: '#/components/parameters/inventoryScopeHash'}
- {$ref: '#/components/parameters/queryAsOf'}
- {$ref: '#/components/parameters/current'}
- {$ref: '#/components/parameters/size'}
responses:
'200':
description: '统一ApiResponse,业务失败HTTP200且code非200。'
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data: {$ref: '#/components/schemas/MetricPage'}
'401': {description: 缺少可信认证身份}
'403': {description: 非平台身份或缺少独立只读权限}
/api/platform/v1/last-leg/self-operated-analytics/inventory-coverage:
get:
summary: 正品库存与固定30日可售天数
description: '固定30天,不接受图表周期。可售仅品质0可用量,不扣冻结或加在途次品。先UNKNOWN/INVALID/STALE,再有效零库存为0,再校验正库存单位、窗口、观察期;零销量天数为空。 权限platform:self-operated-analytics:list。'
parameters:
- {$ref: '#/components/parameters/attention'}
- {$ref: '#/components/parameters/sortBy'}
- {$ref: '#/components/parameters/coverageDaysThreshold'}
- {$ref: '#/components/parameters/sourceAccount'}
- {$ref: '#/components/parameters/warehouseCode'}
- {$ref: '#/components/parameters/customerCode'}
- {$ref: '#/components/parameters/sku'}
- {$ref: '#/components/parameters/salesRunId'}
- {$ref: '#/components/parameters/inventoryRunId'}
- {$ref: '#/components/parameters/salesScopeHash'}
- {$ref: '#/components/parameters/inventoryScopeHash'}
- {$ref: '#/components/parameters/queryAsOf'}
- {$ref: '#/components/parameters/current'}
- {$ref: '#/components/parameters/size'}
responses:
'200':
description: '统一ApiResponse,业务失败HTTP200且code非200。'
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data: {$ref: '#/components/schemas/MetricPage'}
'401': {description: 缺少可信认证身份}
'403': {description: 非平台身份或缺少独立只读权限}
/api/platform/v1/last-leg/self-operated-analytics/sync-overview:
get:
summary: 各仓任务族运行概览
description: 'warehouseCode可选。轻量读取scope/task/run/账号保护。无发布版和OFF仍返回配置任务。首采COMPLETE须正式发布覆盖且无rebuild或未闭合归并。共享上游明确账号共享范围。 权限platform:self-operated-analytics:list。'
parameters:
- {$ref: '#/components/parameters/sourceAccount'}
- {$ref: '#/components/parameters/warehouseCodeOptional'}
- {$ref: '#/components/parameters/dataFamily'}
responses:
'200':
description: '统一ApiResponse,业务失败HTTP200且code非200。'
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data: {$ref: '#/components/schemas/Overview'}
'401': {description: 缺少可信认证身份}
'403': {description: 非平台身份或缺少独立只读权限}
/api/platform/v1/last-leg/self-operated-analytics/sync-tasks:
get:
summary: 本仓逻辑采集任务
description: '任务按创建时间及稳定键倒序。首屏返回listUpperCreatedAt/listUpperId,后页完整重传;新插入不挤移页。只读有限检查点,不展开stage或大manifest。 权限platform:self-operated-analytics:list。'
parameters:
- {$ref: '#/components/parameters/sourceAccount'}
- {$ref: '#/components/parameters/warehouseCode'}
- {$ref: '#/components/parameters/dataFamily'}
- {$ref: '#/components/parameters/taskType'}
- {$ref: '#/components/parameters/status'}
- {$ref: '#/components/parameters/taskNo'}
- {$ref: '#/components/parameters/dateFrom'}
- {$ref: '#/components/parameters/dateUntil'}
- {$ref: '#/components/parameters/current'}
- {$ref: '#/components/parameters/size'}
- {$ref: '#/components/parameters/listUpperCreatedAt'}
- {$ref: '#/components/parameters/listUpperId'}
responses:
'200':
description: '统一ApiResponse,业务失败HTTP200且code非200。'
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data: {$ref: '#/components/schemas/TaskPage'}
'401': {description: 缺少可信认证身份}
'403': {description: 非平台身份或缺少独立只读权限}
/api/platform/v1/last-leg/self-operated-analytics/sync-tasks/{taskNo}:
get:
summary: 本仓业务任务号详情
description: '路径taskNo为稳定业务任务号,实际归属与请求账号仓必须一致。不以内部ID回退。详情含最新尝试和关联共享上游。 权限platform:self-operated-analytics:list。'
parameters:
- {$ref: '#/components/parameters/sourceAccount'}
- {$ref: '#/components/parameters/warehouseCode'}
- {name: taskNo, in: path, required: true, schema: {type: string, maxLength: 64}}
responses:
'200':
description: '统一ApiResponse,业务失败HTTP200且code非200。'
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data: {$ref: '#/components/schemas/TaskDetail'}
'401': {description: 缺少可信认证身份}
'403': {description: 非平台身份或缺少独立只读权限}
/api/platform/v1/last-leg/self-operated-analytics/sync-runs:
get:
summary: 本仓或任务号的尝试历史
description: '纯运行元数据分页。当前尝试读同一attemptNo的task进度,终态尝试只读run冻结快照;心跳过期UNCONFIRMED,不自动改判成功失败。 权限platform:self-operated-analytics:list。'
parameters:
- {$ref: '#/components/parameters/sourceAccount'}
- {$ref: '#/components/parameters/warehouseCode'}
- {$ref: '#/components/parameters/dataFamily'}
- {$ref: '#/components/parameters/taskType'}
- {$ref: '#/components/parameters/status'}
- {$ref: '#/components/parameters/taskNo'}
- {$ref: '#/components/parameters/dateFrom'}
- {$ref: '#/components/parameters/dateUntil'}
- {$ref: '#/components/parameters/current'}
- {$ref: '#/components/parameters/size'}
- {$ref: '#/components/parameters/listUpperCreatedAt'}
- {$ref: '#/components/parameters/listUpperId'}
responses:
'200':
description: '统一ApiResponse,业务失败HTTP200且code非200。'
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data: {$ref: '#/components/schemas/AttemptPage'}
'401': {description: 缺少可信认证身份}
'403': {description: 非平台身份或缺少独立只读权限}
/api/platform/v1/last-leg/self-operated-analytics/owners:
get:
summary: 已采集货主编码分页
description: 固定同屏版本,只读权限platform:self-operated-analytics:list。不调用外部API。概览不接受SKU/attention/sortBy/分页/图表范围,候选不存在data为空。
parameters:
- {$ref: '#/components/parameters/sourceAccount'}
- {$ref: '#/components/parameters/warehouseCode'}
- {$ref: '#/components/parameters/salesRunId'}
- {$ref: '#/components/parameters/inventoryRunId'}
- {$ref: '#/components/parameters/salesScopeHash'}
- {$ref: '#/components/parameters/inventoryScopeHash'}
- {$ref: '#/components/parameters/queryAsOf'}
- {$ref: '#/components/parameters/keyword'}
- {$ref: '#/components/parameters/current'}
- {$ref: '#/components/parameters/size'}
responses:
'200':
description: 统一ApiResponse
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data: {$ref: '#/components/schemas/OwnerPage'}
'401': {description: 缺少可信认证身份}
'403': {description: 非平台身份或缺少独立只读权限}
/api/platform/v1/last-leg/self-operated-analytics/owner-overview:
get:
summary: 单货主完整候选30日经营概览
description: 固定同屏版本,只读权限platform:self-operated-analytics:list。不调用外部API。概览不接受SKU/attention/sortBy/分页/图表范围,候选不存在data为空。
parameters:
- {$ref: '#/components/parameters/sourceAccount'}
- {$ref: '#/components/parameters/warehouseCode'}
- {$ref: '#/components/parameters/salesRunId'}
- {$ref: '#/components/parameters/inventoryRunId'}
- {$ref: '#/components/parameters/salesScopeHash'}
- {$ref: '#/components/parameters/inventoryScopeHash'}
- {$ref: '#/components/parameters/queryAsOf'}
- {$ref: '#/components/parameters/customerCodeRequired'}
- {$ref: '#/components/parameters/coverageDaysThreshold'}
responses:
'200':
description: 统一ApiResponse
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data: {$ref: '#/components/schemas/OwnerOverview'}
'401': {description: 缺少可信认证身份}
'403': {description: 非平台身份或缺少独立只读权限}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
parameters:
keyword: {name: keyword, in: query, schema: {type: string, minLength: 1, maxLength: 64}, description: 区分大小写的货主编码字面前缀,先过滤去重再分页}
attention: {name: attention, in: query, schema: {type: string, default: ALL, enum: [ALL, NO_OUTBOUND_30D, COVERAGE_GT_THRESHOLD, FROZEN_STOCK, DEFECTIVE_STOCK, UNASSESSABLE]}}
sortBy: {name: sortBy, in: query, schema: {type: string, default: AVAILABLE_DESC, enum: [AVAILABLE_DESC, COVERAGE_DAYS_DESC, PHYSICAL_DESC, OUTBOUND_30D_ASC]}, description: 完整候选先筛选后按未舍入值排序分页,未知末尾,同值SKU和货主升序}
coverageDaysThreshold: {name: coverageDaysThreshold, in: query, schema: {type: integer, minimum: 1}, description: COVERAGE_GT_THRESHOLD必填,严格大于且未舍入比较,不设置默认经营政策}
sourceAccount: {name: sourceAccount, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 64}}
warehouseCode: {name: warehouseCode, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 64}}
customerCode: {name: customerCode, in: query, required: false, schema: {type: string, minLength: 1, maxLength: 64}}
sku: {name: sku, in: query, required: false, schema: {type: string, minLength: 1, maxLength: 128}}
rangePreset: {name: rangePreset, in: query, required: false, schema: {type: string, enum: [TODAY, LAST_3_DAYS, THIS_WEEK, LAST_15_DAYS, THIS_MONTH, CUSTOM], default: TODAY}}
startDate: {name: startDate, in: query, required: false, schema: {type: string, format: date}}
endDate: {name: endDate, in: query, required: false, schema: {type: string, format: date}}
bucket: {name: bucket, in: query, required: false, schema: {type: string, enum: [DAY], default: DAY}}
windowDays: {name: windowDays, in: query, required: false, schema: {type: integer, enum: [7, 30], default: 30}}
current: {name: current, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}}
size: {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 100, default: 20}}
salesRunId: {name: salesRunId, in: query, required: false, schema: {type: string, pattern: '^[0-9]+$'}}
inventoryRunId: {name: inventoryRunId, in: query, required: false, schema: {type: string, pattern: '^[0-9]+$'}}
salesScopeHash: {name: salesScopeHash, in: query, required: false, schema: {type: string, pattern: '^[0-9a-f]{64}$'}}
inventoryScopeHash: {name: inventoryScopeHash, in: query, required: false, schema: {type: string, pattern: '^[0-9a-f]{64}$'}}
queryAsOf: {name: queryAsOf, in: query, required: false, schema: {type: string, format: date-time}}
dataFamily: {name: dataFamily, in: query, required: false, schema: {type: string, enum: [OUTBOUND, INVENTORY]}}
taskType: {name: taskType, in: query, required: false, schema: {type: string}}
status: {name: status, in: query, required: false, schema: {type: string}}
taskNo: {name: taskNo, in: query, required: false, schema: {type: string, maxLength: 64}}
dateFrom: {name: dateFrom, in: query, required: false, schema: {type: string, format: date}}
dateUntil: {name: dateUntil, in: query, required: false, schema: {type: string, format: date}}
listUpperCreatedAt: {name: listUpperCreatedAt, in: query, required: false, schema: {type: string, format: date-time}}
listUpperId: {name: listUpperId, in: query, required: false, schema: {type: string, pattern: '^[0-9]+$'}}
warehouseCodeOptional: {name: warehouseCode, in: query, schema: {type: string, minLength: 1, maxLength: 64}}
customerCodeRequired: {name: customerCode, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 64}}
skuRequired: {name: sku, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
schemas:
OwnerOption:
type: object
properties:
customerCode: {type: string}
customerName: {type: string, nullable: true}
OwnerPage:
type: object
properties:
context: {$ref: '#/components/schemas/Context'}
records: {type: array, items: {$ref: '#/components/schemas/OwnerOption'}}
current: {type: integer}
size: {type: integer}
total: {type: string}
pages: {type: string}
excludedReasons: {type: object, additionalProperties: {type: string}}
QuantityTotal:
type: object
properties:
overallTotal: {type: number, nullable: true}
knownSubtotal: {type: number, nullable: true}
unknownSkuCount: {type: string}
dataStatus: {type: string, enum: [COMPLETE, PARTIAL, UNKNOWN]}
reasonCodes: {type: array, items: {type: string}}
RiskShare:
type: object
description: 分母为同一OwnerOverview的physicalQuantity。未知时正式命中与比例为空。未设置阈值返回THRESHOLD_NOT_SET。
properties:
matchedSkuCount: {type: string, nullable: true}
matchedPhysicalQuantity: {type: number, nullable: true}
physicalQuantityRatio: {type: number, minimum: 0, maximum: 1, nullable: true}
knownMatchedSkuCount: {type: string, nullable: true}
knownMatchedPhysicalQuantity: {type: number, nullable: true}
unassessableSkuCount: {type: string}
unassessableKnownPhysicalQuantity: {type: number, nullable: true}
reasonCodes: {type: array, items: {type: string}}
OwnerOverview:
type: object
nullable: true
properties:
context: {$ref: '#/components/schemas/Context'}
customerCode: {type: string}
customerName: {type: string, nullable: true}
skuCount: {type: string, nullable: true}
physicalSkuCount: {$ref: '#/components/schemas/QuantityTotal'}
physicalQuantity: {$ref: '#/components/schemas/QuantityTotal'}
availableQuantity: {$ref: '#/components/schemas/QuantityTotal'}
frozenQuantity: {$ref: '#/components/schemas/QuantityTotal'}
defectivePhysicalQuantity: {$ref: '#/components/schemas/QuantityTotal'}
transitQuantity: {$ref: '#/components/schemas/QuantityTotal'}
outboundQuantity30d: {$ref: '#/components/schemas/QuantityTotal'}
averageDailyOutboundQuantity: {$ref: '#/components/schemas/QuantityTotal'}
noOutbound30d: {$ref: '#/components/schemas/RiskShare'}
coverageAboveThreshold: {$ref: '#/components/schemas/RiskShare'}
Context:
type: object
properties:
salesRunId: {type: string, nullable: true}
inventoryRunId: {type: string, nullable: true}
salesScopeHash: {type: string, nullable: true}
inventoryScopeHash: {type: string, nullable: true}
queryAsOf: {type: string, format: date-time, nullable: true}
queryExpiresAt: {type: string, format: date-time, nullable: true}
businessTimezone: {type: string, nullable: true}
anchorDate: {type: string, format: date, nullable: true}
rangeStartDate: {type: string, format: date, nullable: true}
rangeEndDateExclusive: {type: string, format: date, nullable: true}
calculationWindowDays: {type: integer, nullable: true}
completeDayFrom: {type: string, format: date, nullable: true}
completeDayUntil: {type: string, format: date, nullable: true}
salesCoveredFrom: {type: string, format: date-time, nullable: true}
salesCoveredUntil: {type: string, format: date-time, nullable: true}
salesPublishedAt: {type: string, format: date-time, nullable: true}
inventoryObservedFrom: {type: string, format: date-time, nullable: true}
inventoryObservedUntil: {type: string, format: date-time, nullable: true}
salesStatus: {type: string, nullable: true}
inventoryStatus: {type: string, nullable: true}
rebuildRequired: {type: boolean, nullable: true}
revisionReviewStatus: {type: string, nullable: true}
reasonCodes: {type: array, items: {type: string}}
SalesSummary:
type: object
properties:
sourceAccount: {type: string, nullable: true}
warehouseCode: {type: string, nullable: true}
customerCode: {type: string, nullable: true}
sku: {type: string, nullable: true}
productName: {type: string, nullable: true}
quantityUnit: {type: string, nullable: true}
outboundQuantity: {type: number, nullable: true}
observedQuantity: {type: number, nullable: true}
dataStatus: {type: string, nullable: true}
reasonCodes: {type: array, items: {type: string}}
completeDayCount: {type: integer, nullable: true}
partialDayCount: {type: integer, nullable: true}
unknownDayCount: {type: integer, nullable: true}
Point:
type: object
properties:
businessDate: {type: string, format: date, nullable: true}
outboundQuantity: {type: number, nullable: true}
observedQuantity: {type: number, nullable: true}
dataStatus: {type: string, nullable: true}
reasonCodes: {type: array, items: {type: string}}
quantityUnit: {type: string, nullable: true}
coveredFrom: {type: string, format: date-time, nullable: true}
coveredUntil: {type: string, format: date-time, nullable: true}
Trend:
type: object
properties:
context: {$ref: '#/components/schemas/Context'}
summary: {$ref: '#/components/schemas/SalesSummary'}
points: {type: array, items: {$ref: '#/components/schemas/Point'}}
Metric:
type: object
properties:
customerName: {type: string, nullable: true}
defectivePhysicalQuantity: {type: number, nullable: true}
outboundQuantity30d: {type: number, nullable: true}
lastObservedOutboundDate: {type: string, format: date, nullable: true}
observedHistoryStartDate: {type: string, format: date, nullable: true}
attentionReasons: {type: array, items: {type: string}}
quantityStatuses: {type: object, additionalProperties: {type: string, enum: [VALID, UNKNOWN, STALE]}}
sourceAccount: {type: string, nullable: true}
customerCode: {type: string, nullable: true}
warehouseCode: {type: string, nullable: true}
sku: {type: string, nullable: true}
productName: {type: string, nullable: true}
quantityUnit: {type: string, nullable: true}
outboundQuantity: {type: number, nullable: true}
skuOutboundOrderCount: {type: string, nullable: true}
todayObservedQuantity: {type: number, nullable: true}
physicalQuantity: {type: number, nullable: true}
availableQuantity: {type: number, nullable: true}
frozenQuantity: {type: number, nullable: true}
transitQuantity: {type: number, nullable: true}
inventoryObservedAt: {type: string, format: date-time, nullable: true}
averageDailyOutboundQuantity: {type: number, nullable: true}
inventoryCoverageDays: {type: number, nullable: true}
inventoryQuality: {type: string, nullable: true}
salesCoverage: {type: string, nullable: true}
observationEligible: {type: boolean, nullable: true}
earliestReliableEvidenceAt: {type: string, format: date-time, nullable: true}
status: {type: string, nullable: true}
reasonCodes: {type: array, items: {type: string}}
Protection:
type: object
properties:
accountCooldownUntil: {type: string, format: date-time, nullable: true}
accountPauseReason: {type: string, nullable: true}
dailyRequestBudget: {type: integer, nullable: true}
reservedHttpRequests: {type: string, nullable: true}
confirmedHttpRequests: {type: string, nullable: true}
budgetDate: {type: string, format: date, nullable: true}
Validation:
type: object
properties:
validationSessionNo: {type: string, nullable: true}
budgetLimits: {type: object, additionalProperties: {type: string, nullable: true}}
budgetConsumed: {type: object, additionalProperties: {type: string, nullable: true}}
deadlineAt: {type: string, format: date-time, nullable: true}
stopReason: {type: string, nullable: true}
Attempt:
type: object
properties:
runId: {type: string, nullable: true}
taskNo: {type: string, nullable: true}
attemptNo: {type: integer, nullable: true}
status: {type: string, nullable: true}
phase: {type: string, nullable: true}
executionPurpose: {type: string, nullable: true}
startedAt: {type: string, format: date-time, nullable: true}
finishedAt: {type: string, format: date-time, nullable: true}
phaseStartedAt: {type: string, format: date-time, nullable: true}
progressUpdatedAt: {type: string, format: date-time, nullable: true}
heartbeatAt: {type: string, format: date-time, nullable: true}
executionHealth: {type: string, nullable: true}
elapsedSeconds: {type: string, nullable: true}
errorCode: {type: string, nullable: true}
failureSummary: {type: string, nullable: true}
retryable: {type: boolean, nullable: true}
coverageStatus: {type: string, nullable: true}
coveredFrom: {type: string, format: date-time, nullable: true}
coveredUntil: {type: string, format: date-time, nullable: true}
publishedAt: {type: string, format: date-time, nullable: true}
progress: {$ref: '#/components/schemas/Progress'}
Task:
type: object
properties:
taskNo: {type: string, nullable: true}
taskType: {type: string, nullable: true}
dataFamily: {type: string, nullable: true}
warehouseCode: {type: string, nullable: true}
taskRole: {type: string, nullable: true}
executionPurpose: {type: string, nullable: true}
queryFrom: {type: string, format: date-time, nullable: true}
queryUntil: {type: string, format: date-time, nullable: true}
taskStatus: {type: string, nullable: true}
applyState: {type: string, nullable: true}
attemptCount: {type: integer, nullable: true}
latestAttemptNo: {type: integer, nullable: true}
resultPublicationState: {type: string, nullable: true}
resultPublishedRunId: {type: string, nullable: true}
nextRetryAt: {type: string, format: date-time, nullable: true}
allowedActions: {type: array, items: {type: string}}
actionBlockReason: {type: string, nullable: true}
progressUpdatedAt: {type: string, format: date-time, nullable: true}
progress: {$ref: '#/components/schemas/Progress'}
validation: {$ref: '#/components/schemas/Validation'}
latestAttempt: {$ref: '#/components/schemas/Attempt'}
TaskDetail:
type: object
properties:
task: {$ref: '#/components/schemas/Task'}
sharedUpstream: {$ref: '#/components/schemas/Task'}
protection: {$ref: '#/components/schemas/Protection'}
observedAt: {type: string, format: date-time, nullable: true}
InventoryDailySamples:
type: object
nullable: true
description: 仅库存任务族提供。北京时间近30个完整日的正式日代表积累;缺日不补零或沿用旧批次,不是周转率。
required: [windowFrom, windowUntil, expectedDays, sampleCount, missingDates]
properties:
windowFrom: {type: string, format: date, description: 包含起日}
windowUntil: {type: string, format: date, description: 排他上界,即北京时间今日}
expectedDays: {type: integer, enum: [30]}
sampleCount: {type: integer, minimum: 0, maximum: 30}
missingDates: {type: array, items: {type: string, format: date}}
accumulationStartedOn: {type: string, format: date, nullable: true, description: 本仓最早正式日代表日期,无样本时为空}
Warehouse:
type: object
properties:
warehouseCode: {type: string, nullable: true}
warehouseName: {type: string, nullable: true}
dataFamily: {type: string, nullable: true}
executionEnabled: {type: boolean, nullable: true}
publicationEnabled: {type: boolean, nullable: true}
disabledReason: {type: string, nullable: true}
nextScheduledAt: {type: string, format: date-time, nullable: true}
observedAt: {type: string, format: date-time, nullable: true}
initialFrom: {type: string, format: date-time, nullable: true}
initialUntil: {type: string, format: date-time, nullable: true}
initialCoverageStatus: {type: string, nullable: true}
salesCoveredFrom: {type: string, format: date-time, nullable: true}
salesCoveredUntil: {type: string, format: date-time, nullable: true}
lastSuccessfulTaskNo: {type: string, nullable: true}
lastSucceededAt: {type: string, format: date-time, nullable: true}
lastPublishedAt: {type: string, format: date-time, nullable: true}
inventoryObservedFrom: {type: string, format: date-time, nullable: true}
inventoryObservedUntil: {type: string, format: date-time, nullable: true}
dataFreshness: {type: string, nullable: true}
rebuildRequired: {type: boolean, nullable: true}
revisionReviewStatus: {type: string, nullable: true}
inventoryDailySamples: {$ref: '#/components/schemas/InventoryDailySamples'}
currentTask: {$ref: '#/components/schemas/Task'}
sharedUpstream: {$ref: '#/components/schemas/Task'}
protection: {$ref: '#/components/schemas/Protection'}
Overview:
type: object
properties:
sourceAccount: {type: string, nullable: true}
environmentLabel: {type: string, nullable: true}
collectionMode: {type: string, nullable: true}
observedAt: {type: string, format: date-time, nullable: true}
warehouses: {type: array, items: {$ref: '#/components/schemas/Warehouse'}}
Progress:
type: object
properties:
progressScope: {type: string, nullable: true}
countBasis: {type: string, nullable: true}
attemptNo: {type: string, nullable: true}
scanRound: {type: string, nullable: true}
phase: {type: string, nullable: true}
completed: {type: string, nullable: true}
total: {type: string, nullable: true}
totalKnown: {type: boolean, nullable: true}
unit: {type: string, nullable: true}
sliceFrom: {type: string, format: date-time, nullable: true}
sliceUntil: {type: string, format: date-time, nullable: true}
stageStartedAt: {type: string, format: date-time, nullable: true}
stageFinishedAt: {type: string, format: date-time, nullable: true}
validationState: {type: string, nullable: true}
invalidCount: {type: string, nullable: true}
unknownCount: {type: string, nullable: true}
pendingReviewCount: {type: string, nullable: true}
countUnit: {type: string, nullable: true}
summaryAsOf: {type: string, format: date-time, nullable: true}
directoryComplete: {type: boolean, nullable: true}
stages: {type: array, items: {$ref: '#/components/schemas/Progress'}}
SalesPage:
type: object
properties:
context: {$ref: '#/components/schemas/Context'}
records: {type: array, items: {$ref: '#/components/schemas/SalesSummary'}}
current: {type: integer, nullable: true}
size: {type: integer, nullable: true}
total: {type: string, nullable: true}
pages: {type: string, nullable: true}
excludedReasons: {type: object, additionalProperties: {type: string, nullable: true}}
MetricPage:
type: object
properties:
context: {$ref: '#/components/schemas/Context'}
records: {type: array, items: {$ref: '#/components/schemas/Metric'}}
current: {type: integer, nullable: true}
size: {type: integer, nullable: true}
total: {type: string, nullable: true}
pages: {type: string, nullable: true}
excludedReasons: {type: object, additionalProperties: {type: string, nullable: true}}
TaskPage:
type: object
properties:
records: {type: array, items: {$ref: '#/components/schemas/Task'}}
current: {type: integer, nullable: true}
size: {type: integer, nullable: true}
total: {type: string, nullable: true}
listUpperCreatedAt: {type: string, format: date-time, nullable: true}
listUpperId: {type: string, nullable: true}
observedAt: {type: string, format: date-time, nullable: true}
AttemptPage:
type: object
properties:
records: {type: array, items: {$ref: '#/components/schemas/Attempt'}}
current: {type: integer, nullable: true}
size: {type: integer, nullable: true}
total: {type: string, nullable: true}
listUpperCreatedAt: {type: string, format: date-time, nullable: true}
listUpperId: {type: string, nullable: true}
observedAt: {type: string, format: date-time, nullable: true}

Some files were not shown because too many files have changed in this diff Show More