feat: add self-operated analytics and enforce Lastleg module boundaries
This commit is contained in:
+3
-39
@@ -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);
|
||||
}
|
||||
|
||||
+31
@@ -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();
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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);
|
||||
}
|
||||
|
||||
+6
@@ -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);
|
||||
}
|
||||
+12
-29
@@ -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>();
|
||||
|
||||
+9
@@ -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,
|
||||
|
||||
+23
@@ -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";
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -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
|
||||
|
||||
+9
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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) { }
|
||||
}
|
||||
|
||||
+46
@@ -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);}
|
||||
}
|
||||
+4
-1
@@ -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);
|
||||
|
||||
+14
@@ -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; }
|
||||
}
|
||||
+25
@@ -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) { }
|
||||
}
|
||||
+248
@@ -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); }
|
||||
}
|
||||
+94
@@ -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,"平台自营分析只读权限不足");
|
||||
}
|
||||
}
|
||||
+140
@@ -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; }
|
||||
}
|
||||
+179
@@ -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");
|
||||
}
|
||||
}
|
||||
+30
@@ -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);
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+16
@@ -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);
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+66
@@ -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;
|
||||
}
|
||||
}
|
||||
+23
@@ -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;
|
||||
}
|
||||
}
|
||||
+33
@@ -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;
|
||||
}
|
||||
+33
@@ -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;
|
||||
}
|
||||
+23
@@ -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;
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
+16
@@ -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) { }
|
||||
}
|
||||
+68
@@ -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();}
|
||||
}
|
||||
+11
@@ -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());
|
||||
}
|
||||
}
|
||||
+28
@@ -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;
|
||||
}
|
||||
}
|
||||
+27
@@ -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;
|
||||
}
|
||||
+30
@@ -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;
|
||||
}
|
||||
+37
@@ -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;
|
||||
}
|
||||
}
|
||||
+56
@@ -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;
|
||||
}
|
||||
+51
@@ -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;
|
||||
}
|
||||
+33
@@ -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;
|
||||
}
|
||||
+50
@@ -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;
|
||||
}
|
||||
+163
@@ -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) <= #{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);
|
||||
}
|
||||
+33
@@ -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) { }
|
||||
}
|
||||
+159
@@ -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());
|
||||
}
|
||||
}
|
||||
+245
@@ -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");
|
||||
}
|
||||
}
|
||||
+25
@@ -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);
|
||||
}
|
||||
+87
@@ -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");
|
||||
}
|
||||
}
|
||||
+27
@@ -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、关注、排序或分页条件");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -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))");}
|
||||
}
|
||||
+40
@@ -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+"必填且不能超长");}
|
||||
}
|
||||
+267
@@ -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));}
|
||||
}
|
||||
+49
@@ -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);
|
||||
}
|
||||
+39
@@ -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){}
|
||||
}
|
||||
+147
@@ -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;
|
||||
}
|
||||
}
|
||||
+41
@@ -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){}
|
||||
}
|
||||
+10
@@ -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) {}
|
||||
+14
@@ -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) {}
|
||||
+350
@@ -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;}
|
||||
}
|
||||
}
|
||||
+115
@@ -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 >= #{from} AND r.inventory_daily_sample_date < #{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 >= #{f.from} </if>"
|
||||
+ "<if test='f.until != null'>AND t.created_at < #{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 >= #{f.from} </if>"
|
||||
+ "<if test='f.until != null'>AND r.created_at < #{f.until} </if>";
|
||||
String TASK_BOUND = " AND (t.created_at < #{b.createdAt} OR (t.created_at=#{b.createdAt} AND t.id <= #{b.id})) ";
|
||||
String RUN_BOUND = " AND (r.created_at < #{b.createdAt} OR (r.created_at=#{b.createdAt} AND r.id <= #{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);
|
||||
}
|
||||
+34
@@ -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) {}
|
||||
}
|
||||
+41
@@ -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) {}
|
||||
}
|
||||
+8
@@ -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;}
|
||||
}
|
||||
+12
@@ -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;
|
||||
}
|
||||
}
|
||||
+14
@@ -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);}
|
||||
}
|
||||
+12
@@ -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){}
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
+158
@@ -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);}
|
||||
}
|
||||
+31
@@ -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);}
|
||||
}
|
||||
+44
@@ -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);
|
||||
}
|
||||
+54
@@ -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");
|
||||
}
|
||||
}
|
||||
+28
@@ -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");
|
||||
}
|
||||
}
|
||||
+104
@@ -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; }
|
||||
}
|
||||
+51
@@ -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());
|
||||
}
|
||||
}
|
||||
+17
@@ -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;}
|
||||
}
|
||||
+65
@@ -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);
|
||||
}
|
||||
}
|
||||
+235
@@ -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);}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+101
@@ -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";}
|
||||
}
|
||||
+64
@@ -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;
|
||||
}
|
||||
}
|
||||
+82
@@ -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");
|
||||
}
|
||||
}
|
||||
+85
@@ -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();}
|
||||
}
|
||||
+55
@@ -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);}
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
@@ -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));
|
||||
}
|
||||
}
|
||||
+119
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
+222
@@ -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");
|
||||
}
|
||||
}
|
||||
+208
@@ -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;
|
||||
}
|
||||
}
|
||||
+25
@@ -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"));
|
||||
}
|
||||
}
|
||||
+25
-6
@@ -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, "客户目录暂不可用,请稍后重试");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
@@ -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) { }
|
||||
}
|
||||
+17
@@ -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
-2
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user