This commit is contained in:
2026-10-14 00:07:12 +08:00
parent 3d3507a770
commit d029f9a19e
16 changed files with 655 additions and 29 deletions
+2
View File
@@ -78,6 +78,8 @@
| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | | 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` |
| 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | | 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` |
| Lastleg 内部模块化与依赖基线 | `lastleg-service/` | | Lastleg 内部模块化与依赖基线 | `lastleg-service/` |
| 自营领星采集、经营统计、SKU 日销量 | `integration-service/` 采集适配、`lastleg-service/selfoperated/` 查询;按实际链路只读相关模块 |
| 跨客户同仓预约、预约跟进、到港日历 | `lastleg-service/inboundplan/`、`appointmentplanning/`;到港事实查 `firstleg/portschedule/` |
| 车辆、司机证照、车次、外接预订、车队报价、出车记录 | `lastleg-service/fleet/`;先读 `lastleg-service/AGENTS.md` 对应入口,客户排车关联再沿 `dispatch/fleet/` 实际调用 | | 车辆、司机证照、车次、外接预订、车队报价、出车记录 | `lastleg-service/fleet/`;先读 `lastleg-service/AGENTS.md` 对应入口,客户排车关联再沿 `dispatch/fleet/` 实际调用 |
| PDF 标签、审计、业务事件直投 | `print/`、`audit-service/`、`common-event-contract/` | | PDF 标签、审计、业务事件直投 | `print/`、`audit-service/`、`common-event-contract/` |
+1 -11
View File
@@ -106,17 +106,7 @@
## 数据库表索引与注释约束 ## 数据库表索引与注释约束
| 业务域 | 表名 | 中文含义 | 表名与中文含义按需查 `docs/business-index.md` 的“数据库表索引”;修改约束如下。
|---|---|---|
| 车次外接 | `ll_fleet_external_booking` | 外接询价、约定报价与承运容量预订;历史进入车次历史快照 |
| 核心订单与事件 | `ll_loading_*`、`ll_event_*`、`ll_order*`、`ll_inbound_order*` | 事件收件/发件、尾程订单、明细及状态历史 |
| 入仓计划与作业 | `ll_inbound_plan*`、`ll_inbound_pda*`、`ll_inbound_pallet_merge`、`ll_inbound_fba_appointment*` | 入仓计划、托盘分配、PDA 作业、合托及 FBA 预约 |
| 库存 | `ll_inventory_*`、`ll_inbound_stock_receipt`、`ll_return_stock_receipt` | 库存余额、仓位、流水、幂等及原入仓/退回实收来源;同托多票数量逐明细核平,重派按实际退回凭据扣减额度 |
| 派车与出库 | `ll_dispatch_*`、`ll_outbound_*`、`ll_delivery_order` | 派车、PDA 拣货、出库及派送 |
| 转仓与换标 | `ll_warehouse_transfer_*`、`ll_relabel_*` | 转仓单、库存转移、目标收货及换标 |
| 一件代发与废弃 | `ll_dropship_*`、`ll_cargo_discard_*` | 托盘/箱唛库存、流水和废弃审批 |
| 采购、卡车与本土散板 | `ll_overseas_procurement_*`、`ll_us_truck_*`、`ll_local_looseboard_*` | 海外采购、美国卡车运输及本土散板订单 |
| 通用资料 | `ll_overseas_quote*`、`ll_monthly_exchange_rate*`、`ll_vehicle` | 海外仓报价、月度汇率及车辆档案;不保存车辆派送履约历史 |
- 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义,不得只写英文、字段名或类型名。 - 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义,不得只写英文、字段名或类型名。
- `SchemaChineseCommentContractTest` 自动扫描全部 `ll_` 表,无需维护表名白名单;新增字段必须同步更新基线 SQL、建表增量和存量备注迁移,增量脚本的 `ADD/MODIFY COLUMN` 不得丢失中文备注。 - `SchemaChineseCommentContractTest` 自动扫描全部 `ll_` 表,无需维护表名白名单;新增字段必须同步更新基线 SQL、建表增量和存量备注迁移,增量脚本的 `ADD/MODIFY COLUMN` 不得丢失中文备注。
+14
View File
@@ -66,3 +66,17 @@
| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql`、`src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java` | | 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql`、`src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java` |
| 测试 | `src/test/java/com/baiyue/lastleg/` | | 测试 | `src/test/java/com/baiyue/lastleg/` |
| 真实事件消费验收 | `src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java`;覆盖真实 V2 建单、独立入仓单快照及下游转运/换标可消费性 | | 真实事件消费验收 | `src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java`;覆盖真实 V2 建单、独立入仓单快照及下游转运/换标可消费性 |
## 数据库表索引
| 业务域 | 表名 | 中文含义 |
|---|---|---|
| 车次外接 | `ll_fleet_external_booking` | 外接询价、约定报价与承运容量预订;历史进入车次历史快照 |
| 核心订单与事件 | `ll_loading_*`、`ll_event_*`、`ll_order*`、`ll_inbound_order*` | 事件收件/发件、尾程订单、明细及状态历史 |
| 入仓计划与作业 | `ll_inbound_plan*`、`ll_inbound_pda*`、`ll_inbound_pallet_merge`、`ll_inbound_fba_appointment*` | 入仓计划、托盘分配、PDA 作业、合托及 FBA 预约 |
| 库存 | `ll_inventory_*`、`ll_inbound_stock_receipt`、`ll_return_stock_receipt` | 库存余额、仓位、流水、幂等及原入仓/退回实收来源;同托多票数量逐明细核平,重派按实际退回凭据扣减额度 |
| 派车与出库 | `ll_dispatch_*`、`ll_outbound_*`、`ll_delivery_order` | 派车、PDA 拣货、出库及派送 |
| 转仓与换标 | `ll_warehouse_transfer_*`、`ll_relabel_*` | 转仓单、库存转移、目标收货及换标 |
| 一件代发与废弃 | `ll_dropship_*`、`ll_cargo_discard_*` | 托盘/箱唛库存、流水和废弃审批 |
| 采购、卡车与本土散板 | `ll_overseas_procurement_*`、`ll_us_truck_*`、`ll_local_looseboard_*` | 海外采购、美国卡车运输及本土散板订单 |
| 通用资料 | `ll_overseas_quote*`、`ll_monthly_exchange_rate*`、`ll_vehicle` | 海外仓报价、月度汇率及车辆档案;不保存车辆派送履约历史 |
@@ -0,0 +1,30 @@
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 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;
import java.util.Map;
@RestController
@RequestMapping("/api/platform/v1/last-leg/self-operated-analytics")
@PreAuthorize("hasAuthority('platform:self-operated-analytics:list')")
public class PlatformInventoryTurnoverController {
private final InventoryTurnoverQueryService service;
public PlatformInventoryTurnoverController(InventoryTurnoverQueryService service){this.service=service;}
@GetMapping("/inventory-turnover")
@Operation(summary="固定版本的7/15/30日SKU与货主库存周转")
public ApiResponse<InventoryTurnoverResponses.Response> query(@AuthenticationPrincipal AuthenticatedUser user,
@ModelAttribute InventoryTurnoverRequest request,@RequestParam Map<String,String> parameters) {
if(user==null||user.tenantId()==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if(!"PLATFORM".equals(user.appCode())||user.tenantId()!=0L||user.impersonated()||!user.permissions().contains(PlatformAnalyticsController.READ_PERMISSION))
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"平台自营分析只读权限不足");
InventoryTurnoverRequest.validateParameters(parameters.keySet());
return ApiResponse.success(service.query(request));
}
}
@@ -0,0 +1,71 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import com.baiyue.common.core.exception.BizException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.*;
import java.time.temporal.ChronoUnit;
import java.util.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import static com.baiyue.lastleg.selfoperated.analytics.query.InventoryTurnoverResponses.*;
/** Tokens carry evidence, not authority: all versions and missing dates are reconstructed at asOf. */
@Component
public class InventoryTurnoverContext {
static final ZoneId ZONE=ZoneId.of("Asia/Shanghai");
static final String RULE="DAILY_SCHEDULED_FIRST_V1";
private final AnalyticsReadMapper analytics;
private final InventoryTurnoverReadMapper mapper;
private final ObjectMapper json;
private final Clock clock;
@Autowired public InventoryTurnoverContext(AnalyticsReadMapper analytics,InventoryTurnoverReadMapper mapper,ObjectMapper json) {
this(analytics,mapper,json,Clock.systemUTC());
}
InventoryTurnoverContext(AnalyticsReadMapper analytics,InventoryTurnoverReadMapper mapper,ObjectMapper json,Clock clock) {
this.analytics=analytics;this.mapper=mapper;this.json=json;this.clock=clock;
}
public record Evidence(String account,String warehouse,LocalDate end,int days,Instant asOf,
Long salesRun,Long inventoryRun,String salesHash,String inventoryHash,boolean rebuild,
List<Sample> samples,LocalDate accumulationStartedOn) {}
record Frozen(Context response,AnalyticsQueryService.Snapshot snapshot) {}
Frozen resolve(InventoryTurnoverRequest q) {
Instant now=clock.instant(); q.validate(now.atZone(ZONE).toLocalDate());
Evidence supplied=q.snapshotContext()==null?null:decode(q.snapshotContext());
Instant asOf=supplied==null?now.truncatedTo(ChronoUnit.MICROS):supplied.asOf();
if(asOf==null||asOf.isAfter(now)||!now.isBefore(asOf.plusSeconds(1800))) throw refresh();
LocalDate end=q.end(asOf.atZone(ZONE).toLocalDate());
if(supplied!=null&&(!Objects.equals(q.sourceAccount(),supplied.account())||!Objects.equals(q.warehouseCode(),supplied.warehouse())
||!end.equals(supplied.end())||q.days()!=supplied.days())) throw refresh();
var scopes=analytics.scopes(q.sourceAccount(),q.warehouseCode());
var sales=version(scopes,"OUTBOUND",asOf); var stock=version(scopes,"INVENTORY",asOf);
var aq=new AnalyticsQuery(q.sourceAccount(),q.warehouseCode(),null,null,"CUSTOM",end.minusDays(29),end,"DAY",null,1,20,
sales==null?0L:sales.id(),stock==null?0L:stock.id(),sales==null?null:sales.scopeHash(),stock==null?null:stock.scopeHash(),asOf);
// Staleness is evaluated at queryAsOf, so pagination does not age a fixed snapshot midway.
var snapshot=new AnalyticsQueryService(analytics,json,Clock.fixed(asOf,ZoneOffset.UTC)).context(aq,true,false,false,null);
var mapping=new TreeMap<LocalDate,Long>();
for(var sample:mapper.samples(q.sourceAccount(),q.warehouseCode(),utc(asOf),end.minusDays(29),end.plusDays(1))) {
if(sample.sampleDate()==null||sample.runId()==null||mapping.put(sample.sampleDate(),sample.runId())!=null) throw refresh();
}
List<Sample> samples=new ArrayList<>();
for(LocalDate date=end.minusDays(29);!date.isAfter(end);date=date.plusDays(1)) samples.add(new Sample(date,mapping.get(date)));
var began=mapper.accumulationStartedOn(q.sourceAccount(),q.warehouseCode(),utc(asOf));
var c=snapshot.context();
var evidence=new Evidence(q.sourceAccount(),q.warehouseCode(),end,q.days(),asOf,c.salesRunId(),c.inventoryRunId(),
c.salesScopeHash(),c.inventoryScopeHash(),c.rebuildRequired(),List.copyOf(samples),began);
if(supplied!=null&&!supplied.equals(evidence)) throw refresh();
return new Frozen(new Context(encode(evidence),q.sourceAccount(),q.warehouseCode(),end,q.days(),asOf,c.queryExpiresAt(),c,
c.inventoryObservedFrom(),c.inventoryObservedUntil(),RULE,List.copyOf(samples),samples.stream().filter(s->s.runId()==null).map(Sample::date).toList(),began),snapshot);
}
private AnalyticsReadRows.Run version(List<AnalyticsReadRows.Scope> scopes,String family,Instant asOf) {
var scope=scopes.stream().filter(s->family.equals(s.dataFamily())).findFirst().orElse(null);
return scope==null?null:mapper.versionAt(scope.id(),scope.scopeHash(),utc(asOf));
}
private Evidence decode(String token) {
try {var value=json.readValue(Base64.getUrlDecoder().decode(token),Evidence.class);if(value==null)throw refresh();return value;} catch(Exception e){throw refresh();}
}
private String encode(Evidence evidence) {
try{return Base64.getUrlEncoder().withoutPadding().encodeToString(json.writeValueAsBytes(evidence));}catch(Exception e){throw new IllegalStateException("Cannot encode turnover context",e);}
}
private static LocalDateTime utc(Instant value){return LocalDateTime.ofInstant(value,ZoneOffset.UTC);}
private static BizException refresh(){return new BizException("库存周转上下文无效或已过期,请整体刷新");}
}
@@ -0,0 +1,78 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.*;
import static com.baiyue.lastleg.selfoperated.analytics.query.InventoryTurnoverResponses.*;
/** Maps separately qualified quantities; a missing value never becomes a formal zero. */
public final class InventoryTurnoverPolicy {
private InventoryTurnoverPolicy() {}
static Map<String,Object> normalized(Map<String,Object> source) {
Map<String,Object> result=new HashMap<>();
if(source!=null)source.forEach((key,value)->result.put(key.toLowerCase(Locale.ROOT),value));
return result;
}
static BigDecimal decimal(Map<String,Object> row,String key) {
Object value=row.get(key);return value==null?null:new BigDecimal(value.toString());
}
static int count(Map<String,Object> row,String key){BigDecimal value=decimal(row,key);return value==null?0:value.intValueExact();}
static String text(Map<String,Object> row,String key){Object value=row.get(key);return value==null?null:value.toString();}
static BigDecimal divide(BigDecimal a,int n){return a==null?null:a.divide(BigDecimal.valueOf(n),6,RoundingMode.HALF_UP);}
private static BigDecimal scale(BigDecimal value){return value==null?null:value.setScale(6,RoundingMode.HALF_UP);}
static List<Window> windows(Map<String,Object> row,LocalDate until) {
List<Window> result=new ArrayList<>();
for(int n:List.of(7,15,30)) {
String key="w"+n+"_";var formal=decimal(row,key+"outbound");var observed=decimal(row,key+"observed");
int complete=count(row,key+"complete"),partial=count(row,key+"partial");
result.add(new Window(n,until.minusDays(n),until,formal,observed,divide(formal,n),complete,partial,
n-complete-partial,formal!=null?"COMPLETE":observed!=null?"PARTIAL":"UNKNOWN",
formal!=null?List.of():List.of("SALES_WINDOW_INCOMPLETE")));
}
return List.copyOf(result);
}
static Total total(Map<String,Object> row,String field,boolean owner) {
var value=decimal(row,field);int unknown=owner?count(row,field+"_unknown"):value==null?1:0;
return new Total(value,owner?decimal(row,field+"_known"):value,unknown,unknown==0?List.of():List.of("INVENTORY_UNKNOWN_OR_STALE"));
}
private static Risk risk(Map<String,Object> row,String name,Total physical,boolean owner,boolean enabled) {
if(!enabled)return new Risk(null,null,0,null);
int unknown=owner?count(row,name+"_unknown"):row.get(name)==null?1:0;
BigDecimal matched=owner?decimal(row,name+"_count"):BigDecimal.valueOf(count(row,name));
BigDecimal quantity=owner?decimal(row,name+"_physical"):BigDecimal.ONE.equals(matched)?physical.value():BigDecimal.ZERO;
BigDecimal ratio=unknown==0&&physical.value()!=null&&physical.value().signum()>0&&quantity!=null
?quantity.divide(physical.value(),6,RoundingMode.HALF_UP):null;
return new Risk(matched,quantity,unknown,ratio);
}
static Row row(Map<String,Object> raw,InventoryTurnoverRequest q,Context context) {
var r=normalized(raw);boolean owner="OWNER".equals(q.group());
Total physical=total(r,"physical",owner),available=total(r,"available",owner);
var windows=windows(r,context.endDate().plusDays(1));var base=windows.stream().filter(w->w.days()==q.days()).findFirst().orElseThrow();
var coverage=owner?null:scale(decimal(r,"coverage_days"));var average=scale(decimal(r,"average_physical"));
var turnover=scale(decimal(r,"turnover"));int sampleCount=count(r,"sample_count");
String coverageStatus=owner?"NOT_APPLICABLE":available.value()==null?"INVENTORY_UNKNOWN_OR_STALE":available.value().signum()==0?"NO_AVAILABLE_STOCK":
base.outboundQuantity()==null||count(r,"unassessable")>0?"UNASSESSABLE":base.outboundQuantity().signum()==0?"NO_OUTBOUND_IN_WINDOW":"VALID";
String sampleStatus=sampleCount<q.days()?"INSUFFICIENT":average!=null&&average.signum()==0?"ZERO_AVERAGE_STOCK":turnover==null?"SALES_WINDOW_UNAVAILABLE":"VALID";
List<LocalDate> missing=new ArrayList<>();String dates=text(r,"missing_dates");
if(dates!=null&&!dates.isBlank())for(String date:dates.split(","))missing.add(LocalDate.parse(date.substring(0,10)));
List<String> attention=new ArrayList<>();
if(count(r,owner?"no_outbound_count":"no_outbound")>0)attention.add("NO_OUTBOUND_30D");
if(count(r,owner?"slow_count":"slow")>0)attention.add("COVERAGE_GT_THRESHOLD");
if(decimal(r,"frozen")!=null&&decimal(r,"frozen").signum()>0)attention.add("FROZEN_STOCK");
if(decimal(r,"defective")!=null&&decimal(r,"defective").signum()>0)attention.add("DEFECTIVE_STOCK");
if(count(r,"unassessable")>0)attention.add("UNASSESSABLE");
String last=text(r,"last_outbound");
return new Row(text(r,"customer_code"),null,owner?null:text(r,"sku_code"),owner?null:text(r,"product_name"),physical,available,
total(r,"frozen",owner),total(r,"defective",owner),owner?decimal(r,"physical_skus"):physical.value()==null?null:physical.value().signum()>0?BigDecimal.ONE:BigDecimal.ZERO,
windows,base.averageDailyOutboundQuantity(),coverage,coverageStatus,average,turnover,sampleStatus,sampleCount,q.days(),List.copyOf(missing),
"VALID".equals(sampleStatus)?List.of():List.of(sampleStatus),last==null?null:LocalDate.parse(last.substring(0,10)),List.copyOf(attention),
risk(r,"no_outbound",physical,owner,true),risk(r,"slow",physical,owner,q.coverageDaysThreshold()!=null),count(r,"unassessable"));
}
static Overview overview(Map<String,Object> raw) {
var r=normalized(raw);int noUnknown=count(r,"no_outbound_unknown");var noKnown=decimal(r,"no_outbound_count");
return new Overview(total(r,"physical",true),total(r,"available",true),
new Total(noUnknown==0?noKnown:null,noKnown,noUnknown,noUnknown==0?List.of():List.of("SALES_OR_STOCK_UNASSESSABLE")),
new Total(decimal(r,"unassessable"),decimal(r,"unassessable"),0,List.of()));
}
}
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import java.util.*;
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.InventoryTurnoverResponses.*;
import static com.baiyue.lastleg.selfoperated.analytics.query.InventoryTurnoverPolicy.*;
@Service
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public class InventoryTurnoverQueryService {
private final InventoryTurnoverContext contexts;
private final InventoryTurnoverReadMapper mapper;
public InventoryTurnoverQueryService(InventoryTurnoverContext contexts,InventoryTurnoverReadMapper mapper){this.contexts=contexts;this.mapper=mapper;}
public Response query(InventoryTurnoverRequest request) {
var frozen=contexts.resolve(request);var input=InventoryTurnoverSql.input(request,frozen);var context=frozen.response();
long total=mapper.count(input);
var rows=request.offset()>=total?List.<Row>of():mapper.page(input).stream().map(r->row(r,request,context)).toList();
Overview summary=null;Charts charts=null;
if(request.overview()) {
summary=overview(mapper.overview(input));
List<OwnerPhysical> owners=new ArrayList<>();int unknown=0;
for(var raw:mapper.owners(input)) {
var r=normalized(raw);
if("META".equals(text(r,"kind")))unknown=count(r,"unknown_count");
else owners.add(new OwnerPhysical(text(r,"customer_code"),null,decimal(r,"physical")));
}
charts=new Charts(List.copyOf(owners),unknown,windows(normalized(mapper.chartWindows(input)),context.endDate().plusDays(1)));
}
return new Response(context,summary,charts,rows,total);
}
}
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import org.apache.ibatis.annotations.*;
@Mapper
public interface InventoryTurnoverReadMapper {
record SampleRun(LocalDate sampleDate,Long runId) {}
@Select("SELECT "+AnalyticsReadMapper.RUN_COLUMNS+" FROM ll_selfop_sync_run WHERE scope_id=#{scopeId} "
+"AND scope_hash=#{scopeHash} AND status='PUBLISHED' AND run_role='WAREHOUSE_SYNC' AND execution_purpose='BUSINESS' "
+"AND published_at_utc<=#{asOf} ORDER BY published_at_utc DESC,id DESC LIMIT 1")
AnalyticsReadRows.Run versionAt(@Param("scopeId") Long scopeId,@Param("scopeHash") String scopeHash,@Param("asOf") LocalDateTime asOf);
// The immutable first-daily marker is set only by publication. Recheck its original schedule evidence.
String SAMPLE_WHERE=" r.source_account=#{account} AND r.warehouse_code=#{warehouse} "
+"AND r.inventory_sample_rule_version='DAILY_SCHEDULED_FIRST_V1' AND r.status='PUBLISHED' "
+"AND r.execution_purpose='BUSINESS' AND r.run_role='WAREHOUSE_SYNC' AND r.coverage_status='COMPLETE' "
+"AND r.published_at_utc<=#{asOf} AND t.execution_purpose='BUSINESS' AND t.task_type='SCHEDULED' "
+"AND t.task_role='WAREHOUSE_SYNC' AND r.page_count>=1 AND r.source_total_count>=0 "
+"AND r.source_total_count=r.unique_object_count AND r.fetch_started_at_utc<=r.fetch_finished_at_utc "
+"AND DATE(DATE_ADD(t.planned_at_utc,INTERVAL 8 HOUR))=r.inventory_daily_sample_date "
+"AND DATE(DATE_ADD(r.fetch_started_at_utc,INTERVAL 8 HOUR))=r.inventory_daily_sample_date "
+"AND DATE(DATE_ADD(r.fetch_finished_at_utc,INTERVAL 8 HOUR))=r.inventory_daily_sample_date ";
@Select("SELECT r.inventory_daily_sample_date sample_date,r.id run_id FROM ll_selfop_sync_run r "
+"JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE "+SAMPLE_WHERE
+"AND r.inventory_daily_sample_date>=#{from} AND r.inventory_daily_sample_date<#{until} ORDER BY r.inventory_daily_sample_date")
List<SampleRun> samples(@Param("account") String account,@Param("warehouse") String warehouse,
@Param("asOf") LocalDateTime asOf,@Param("from") LocalDate from,@Param("until") LocalDate until);
@Select("SELECT MIN(r.inventory_daily_sample_date) FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE "+SAMPLE_WHERE)
LocalDate accumulationStartedOn(@Param("account") String account,@Param("warehouse") String warehouse,@Param("asOf") LocalDateTime asOf);
@SelectProvider(type=InventoryTurnoverSql.class,method="page")
List<Map<String,Object>> page(@Param("p") InventoryTurnoverSql.Input input);
@SelectProvider(type=InventoryTurnoverSql.class,method="count")
long count(@Param("p") InventoryTurnoverSql.Input input);
@SelectProvider(type=InventoryTurnoverSql.class,method="overview")
Map<String,Object> overview(@Param("p") InventoryTurnoverSql.Input input);
@SelectProvider(type=InventoryTurnoverSql.class,method="owners")
List<Map<String,Object>> owners(@Param("p") InventoryTurnoverSql.Input input);
@SelectProvider(type=InventoryTurnoverSql.class,method="chartWindows")
Map<String,Object> chartWindows(@Param("p") InventoryTurnoverSql.Input input);
}
@@ -0,0 +1,49 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import com.baiyue.common.core.exception.BizException;
import java.time.LocalDate;
import java.util.Set;
import org.springframework.format.annotation.DateTimeFormat;
/** Separate from DETAIL: its dates, windows and filters must never be silently mixed. */
public record InventoryTurnoverRequest(String sourceAccount, String warehouseCode,
@DateTimeFormat(iso=DateTimeFormat.ISO.DATE) LocalDate endDate, Integer baseWindowDays,
String groupBy, String customerCode, String sku, String focus, Integer coverageDaysThreshold,
String turnoverStatus, String sortBy, Integer current, Integer size, Boolean includeOverview,
String snapshotContext) {
private static final Set<String> PARAMETERS=Set.of("sourceAccount","warehouseCode","endDate","baseWindowDays",
"groupBy","customerCode","sku","focus","coverageDaysThreshold","turnoverStatus","sortBy","current","size","includeOverview","snapshotContext");
public static void validateParameters(Set<String> keys) {
if(!PARAMETERS.containsAll(keys)) throw new BizException("库存周转不接受其他视图的查询参数");
}
public int days(){return baseWindowDays==null?30:baseWindowDays;}
public String group(){return groupBy==null?"SKU":groupBy;}
public String attention(){return focus==null?"ALL":focus;}
public String sort(){return sortBy==null?"PHYSICAL_DESC":sortBy;}
public int page(){return current==null?1:current;}
public int pageSize(){return size==null?20:size;}
public long offset(){return (long)(page()-1)*pageSize();}
public boolean overview(){return snapshotContext==null||!Boolean.FALSE.equals(includeOverview);}
public LocalDate end(LocalDate today){return endDate==null?today.minusDays(1):endDate;}
public void validate(LocalDate today) {
AnalyticsQuery.text(sourceAccount,64,"来源账号"); AnalyticsQuery.text(warehouseCode,64,"仓库");
if(customerCode!=null) AnalyticsQuery.text(customerCode,64,"货主");
if(sku!=null) AnalyticsQuery.text(sku,128,"SKU");
if(!Set.of(7,15,30).contains(days())) throw new BizException("基准窗口仅支持7、15、30日");
if(!end(today).isBefore(today)) throw new BizException("截止日必须是北京时间已结束的日期");
if(!Set.of("SKU","OWNER").contains(group())) throw new BizException("分组仅支持SKU或OWNER");
if("OWNER".equals(group())&&sku!=null) throw new BizException("货主汇总不接受SKU筛选");
if(!Set.of("ALL","NO_OUTBOUND_30D","COVERAGE_GT","COVERAGE_LE","FROZEN","DEFECTIVE","UNASSESSABLE").contains(attention()))
throw new BizException("库存关注条件无效");
if(coverageDaysThreshold!=null&&coverageDaysThreshold<=0 || attention().startsWith("COVERAGE_")&&coverageDaysThreshold==null)
throw new BizException("可售天数阈值须为正整数");
if(turnoverStatus!=null&&!"INSUFFICIENT".equals(turnoverStatus)) throw new BizException("周转状态无效");
if(!Set.of("PHYSICAL_DESC","AVAILABLE_DESC","OUTBOUND_7_DESC","OUTBOUND_15_DESC","OUTBOUND_30_DESC",
"COVERAGE_ASC","COVERAGE_DESC","TURNOVER_DESC","NO_OUTBOUND_PHYSICAL_DESC").contains(sort())
|| "OWNER".equals(group())&&sort().startsWith("COVERAGE_")
|| "SKU".equals(group())&&"NO_OUTBOUND_PHYSICAL_DESC".equals(sort())) throw new BizException("排序不适用于当前分组");
if(page()<1||pageSize()<1||pageSize()>100) throw new BizException("分页范围为current≥1,size为1至100");
if(snapshotContext==null&&page()!=1) throw new BizException("翻页必须携带首屏上下文");
if(snapshotContext!=null&&(snapshotContext.isBlank()||snapshotContext.length()>16384)) throw new BizException("查询上下文无效,请整体刷新");
}
}
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
public final class InventoryTurnoverResponses {
private InventoryTurnoverResponses() {}
public record Window(int days, LocalDate startDate, LocalDate endDateExclusive, BigDecimal outboundQuantity,
BigDecimal observedQuantity, BigDecimal averageDailyOutboundQuantity, int completeDayCount,
int partialDayCount, int unknownDayCount, String dataStatus, List<String> reasonCodes) {}
public record Total(BigDecimal value, BigDecimal knownSubtotal, int unknownSkuCount, List<String> reasonCodes) {}
public record Risk(BigDecimal matchedSkuCount, BigDecimal knownPhysicalQuantity, int unknownSkuCount, BigDecimal physicalQuantityRatio) {}
public record Row(String customerCode,String customerName,String sku,String productName,
Total physicalQuantity,Total availableQuantity,Total frozenQuantity,Total defectiveQuantity,
BigDecimal physicalSkuCount,List<Window> windows,BigDecimal averageDailyOutboundQuantity,
BigDecimal inventoryCoverageDays,String coverageStatus,BigDecimal averagePhysicalQuantity,
BigDecimal quantityTurnoverCount,String turnoverStatus,int qualifiedSampleDays,int requiredSampleDays,
List<LocalDate> missingSampleDates,List<String> sampleReasonCodes,LocalDate lastObservedOutboundDate,
List<String> attentionReasons,Risk noOutbound30d,Risk slowCoverage,int unassessableSkuCount) {}
public record Sample(LocalDate date,Long runId) {}
public record Context(String snapshotContext,String sourceAccount,String warehouseCode,LocalDate endDate,
int baseWindowDays,Instant queryAsOf,Instant queryExpiresAt,AnalyticsResponses.Context analyticsContext,
Instant inventoryObservedFrom,Instant inventoryObservedUntil,String sampleRuleVersion,
List<Sample> dailySamples,List<LocalDate> missingSampleDates,LocalDate accumulationStartedOn) {}
public record Overview(Total physicalQuantity,Total availableQuantity,Total noOutboundSkuCount,Total unassessableSkuCount) {}
public record OwnerPhysical(String customerCode,String customerName,BigDecimal physicalQuantity) {}
public record Charts(List<OwnerPhysical> ownerPhysicalTop,int unknownOwnerCount,List<Window> averageDaily) {}
public record Response(Context context,Overview summary,Charts charts,List<Row> rows,long total) {}
}
@@ -0,0 +1,158 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import java.time.*;
import java.util.*;
/** Every value is bound. Only validated enums select SQL fragments; no source order/remote API reads. */
public final class InventoryTurnoverSql {
private InventoryTurnoverSql() {}
public record Day(LocalDate date,LocalDateTime startUtc,int coverage,Long sampleRun) {}
public record Input(InventoryTurnoverRequest query,long salesRun,long inventoryRun,boolean stockFresh,
boolean salesKnown,List<Day> days) {
public LocalDate until(){return days.get(29).date().plusDays(1);}
public LocalDate from(){return days.get(0).date();}
public LocalDate baseFrom(){return until().minusDays(query.days());}
public LocalDate from7(){return until().minusDays(7);}
public LocalDate from15(){return until().minusDays(15);}
public LocalDate from30(){return from();}
public LocalDateTime baseFromUtc(){return baseFrom().atStartOfDay().minusHours(8);}
public LocalDateTime fromUtc(){return from().atStartOfDay().minusHours(8);}
}
static Input input(InventoryTurnoverRequest q,InventoryTurnoverContext.Frozen frozen) {
var coverage=SalesQueryCoverage.intervals(frozen.snapshot().sales());
boolean trusted=frozen.snapshot().sourceKnown()&&!frozen.response().analyticsContext().rebuildRequired();
var days=frozen.response().dailySamples().stream().map(s->new Day(s.date(),s.date().atStartOfDay().minusHours(8),
!trusted?0:SalesQueryCoverage.complete(coverage,s.date(),s.date().plusDays(1))?1:SalesQueryCoverage.at(coverage,s.date())==null?0:2,s.runId())).toList();
return new Input(q,frozen.response().analyticsContext().salesRunId(),frozen.response().analyticsContext().inventoryRunId(),
"VALID".equals(frozen.response().analyticsContext().inventoryStatus()),frozen.snapshot().sourceKnown(),days);
}
private static String scope(String alias) {
return alias+"source_account=#{p.query.sourceAccount} AND "+alias+"warehouse_code=#{p.query.warehouseCode}"
+" AND (#{p.query.customerCode,jdbcType=VARCHAR} IS NULL OR "+alias+"customer_code=#{p.query.customerCode,jdbcType=VARCHAR})";
}
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 amount(String field){return "CASE WHEN MIN(CASE WHEN "+valid(field)+" THEN 1 ELSE 0 END)=1 THEN SUM("+field+") END";}
private static String calendar(Input p) {
var parts=new ArrayList<String>();
for(int i=0;i<p.days().size();i++) parts.add("SELECT CAST(#{p.days["+i+"].date} AS DATE) business_date,CAST(#{p.days["+i+"].startUtc} AS DATETIME) start_utc,"
+"CAST(#{p.days["+i+"].coverage} AS SIGNED) coverage,CAST(#{p.days["+i+"].sampleRun,jdbcType=BIGINT} AS SIGNED) sample_run");
return String.join(" UNION ALL ",parts);
}
private static String cte(Input p) {
String stocks="SELECT snapshot_run_id,customer_code,sku_code,"+amount("physical_quantity")+" physical,"
+amount("frozen_quantity")+" frozen,MAX(CASE WHEN product_quality=0 AND "+valid("available_quantity")+" THEN available_quantity END) available,"
+"CASE WHEN MIN(CASE WHEN product_quality NOT IN (0,1) OR product_quality IS NULL THEN 0 WHEN product_quality=1 AND NOT ("+valid("physical_quantity")+") THEN 0 ELSE 1 END)=1 "
+"THEN SUM(CASE WHEN product_quality=1 THEN physical_quantity ELSE 0 END) END defective "
+"FROM ll_selfop_inventory_snapshot WHERE "+scope("")+" AND (snapshot_run_id=#{p.inventoryRun} OR snapshot_run_id IN (SELECT sample_run FROM calendar)) "
+"GROUP BY snapshot_run_id,customer_code,sku_code";
String profiles="SELECT * FROM ll_selfop_sku_profile WHERE "+scope("")+" AND (profile_run_id IN (#{p.salesRun},#{p.inventoryRun}) OR profile_run_id IN (SELECT sample_run FROM calendar))";
StringBuilder sql=new StringBuilder("WITH calendar AS (").append(calendar(p)).append("), fixed_profiles AS (").append(profiles)
.append("), stocks AS (").append(stocks).append("), sales_days AS (SELECT customer_code,sku_code,business_date,SUM(outbound_quantity) quantity,MIN(CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END) unit_valid FROM ll_selfop_sku_daily_sales WHERE ")
.append(scope("")).append(" AND sales_run_id=#{p.salesRun} AND business_date>=CAST(#{p.from} AS DATE) AND business_date<CAST(#{p.until} AS DATE) GROUP BY customer_code,sku_code,business_date), candidates AS (")
.append("SELECT customer_code,sku_code FROM fixed_profiles UNION SELECT customer_code,sku_code FROM stocks UNION SELECT customer_code,sku_code FROM sales_days), profiles AS (")
.append("SELECT c.customer_code,c.sku_code,MAX(f.product_name) product_name,MIN(CASE WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END) unit_valid,")
.append("MIN(CASE WHEN f.evidence_run_id IS NOT NULL AND f.evidence_type IN ('VALID_OUTBOUND','INVENTORY_OBSERVATION') THEN f.earliest_evidence_at_utc END) evidence ")
.append("FROM candidates c LEFT JOIN fixed_profiles f ON f.customer_code=c.customer_code AND f.sku_code=c.sku_code GROUP BY c.customer_code,c.sku_code), daily_values AS (")
.append("SELECT c.customer_code,c.sku_code,d.business_date,d.coverage,c.unit_valid,c.evidence,sd.quantity,sd.unit_valid sales_unit,")
.append("CASE WHEN ip.quantity_unit='PCS' THEN hs.physical END sample_physical ")
.append("FROM profiles c CROSS JOIN calendar d LEFT JOIN sales_days sd ON sd.customer_code=c.customer_code AND sd.sku_code=c.sku_code AND sd.business_date=d.business_date ")
.append("LEFT JOIN stocks hs ON hs.snapshot_run_id=d.sample_run AND hs.customer_code=c.customer_code AND hs.sku_code=c.sku_code ")
.append("LEFT JOIN fixed_profiles ip ON ip.profile_run_id=d.sample_run AND ip.customer_code=c.customer_code AND ip.sku_code=c.sku_code), day_result AS (")
.append("SELECT v.*,CASE WHEN #{p.salesKnown}=1 AND unit_valid=1 AND COALESCE(sales_unit,1)=1 THEN CASE WHEN quantity IS NOT NULL THEN quantity WHEN coverage>0 AND evidence<=CONVERT_TZ(CAST(business_date AS DATETIME),'+08:00','+00:00') THEN 0 END END observed,")
.append("CASE WHEN coverage=1 AND unit_valid=1 AND COALESCE(sales_unit,1)=1 AND (quantity IS NOT NULL OR evidence<=CONVERT_TZ(CAST(business_date AS DATETIME),'+08:00','+00:00')) THEN 1 ELSE 0 END complete,")
.append("CASE WHEN coverage=1 AND unit_valid=1 AND COALESCE(sales_unit,1)=1 THEN 1 ELSE 0 END owner_complete FROM daily_values v), sku_windows AS (")
.append("SELECT customer_code,sku_code").append(windowAggregates(false)).append(",COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) sample_count,")
.append("CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)/#{p.query.days} END average_physical,")
.append("GROUP_CONCAT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) AND sample_physical IS NULL THEN CAST(business_date AS CHAR) END ORDER BY business_date SEPARATOR ',') missing_dates ")
.append("FROM day_result 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 ")
.append(scope("")).append(" AND sales_run_id=#{p.salesRun} AND business_date<CAST(#{p.until} AS DATE) AND outbound_quantity>0 AND quantity_unit='PCS' GROUP BY customer_code,sku_code)");
sql.append(", sku_base AS (SELECT c.customer_code,c.sku_code,c.product_name,c.evidence,c.unit_valid");
for(int n:List.of(7,15,30)) for(String suffix:List.of("outbound","observed","complete","partial")) sql.append(",w.w").append(n).append('_').append(suffix);
sql.append(",w.sample_count,w.average_physical,w.missing_dates,ls.last_outbound");
for(String field:List.of("physical","available","frozen","defective")) sql.append(",CASE WHEN #{p.stockFresh}=1 AND ip.quantity_unit='PCS' THEN st.").append(field).append(" END ").append(field);
sql.append(" FROM profiles c JOIN sku_windows w ON w.customer_code=c.customer_code AND w.sku_code=c.sku_code LEFT JOIN last_sales ls ON ls.customer_code=c.customer_code AND ls.sku_code=c.sku_code LEFT JOIN stocks st ON st.snapshot_run_id=#{p.inventoryRun} AND st.customer_code=c.customer_code AND st.sku_code=c.sku_code ")
.append("LEFT JOIN fixed_profiles ip ON ip.profile_run_id=#{p.inventoryRun} AND ip.customer_code=c.customer_code AND ip.sku_code=c.sku_code), sku_calculated AS (SELECT b.*,")
.append("CASE WHEN available=0 THEN 0 WHEN available>0 AND w").append(p.query.days()).append("_outbound>0 AND evidence<=#{p.baseFromUtc} THEN available*#{p.query.days}/w").append(p.query.days()).append("_outbound END coverage_days,")
.append("CASE WHEN average_physical>0 AND evidence<=#{p.baseFromUtc} THEN w").append(p.query.days()).append("_outbound/average_physical END turnover,")
.append("CASE WHEN physical=0 THEN 0 WHEN physical>0 AND w30_outbound IS NOT NULL AND evidence<=#{p.fromUtc} THEN CASE WHEN w30_outbound=0 THEN 1 ELSE 0 END END no_outbound,")
.append("CASE WHEN physical IS NULL OR available IS NULL OR w").append(p.query.days()).append("_outbound IS NULL OR evidence IS NULL OR evidence>#{p.baseFromUtc} THEN 1 ELSE 0 END unassessable FROM sku_base b), sku_result AS (")
.append("SELECT b.*,CASE WHEN #{p.query.coverageDaysThreshold,jdbcType=INTEGER} IS NULL THEN NULL WHEN physical=0 OR available=0 THEN 0 WHEN coverage_days IS NOT NULL AND available>0 THEN CASE WHEN coverage_days>#{p.query.coverageDaysThreshold,jdbcType=INTEGER} THEN 1 ELSE 0 END WHEN available>0 AND w")
.append(p.query.days()).append("_outbound=0 AND unassessable=0 THEN 0 END slow FROM sku_calculated b) ");
return sql.toString();
}
private static String windowAggregates(boolean owner) {
StringBuilder sql=new StringBuilder();
for(int n:List.of(7,15,30)) {
String range="business_date>=CAST(#{p.from"+n+"} AS DATE)";
sql.append(",CASE WHEN SUM(CASE WHEN ").append(range).append(" THEN complete ELSE 0 END)=").append(n)
.append(" THEN SUM(CASE WHEN ").append(range).append(" THEN COALESCE(observed,0) END) END w").append(n).append("_outbound")
.append(",SUM(CASE WHEN ").append(range).append(" THEN observed END) w").append(n).append("_observed")
.append(",SUM(CASE WHEN ").append(range).append(" THEN complete ELSE 0 END) w").append(n).append("_complete")
.append(",SUM(CASE WHEN ").append(range).append(" AND complete=0 AND observed IS NOT NULL THEN 1 ELSE 0 END) w").append(n).append("_partial");
}
return sql.toString();
}
private static String ownerCte(Input p) {
return ", owner_days AS (SELECT customer_code,business_date,MIN(owner_complete) complete,"
+"CASE WHEN MIN(owner_complete)=1 THEN SUM(COALESCE(quantity,0)) ELSE SUM(observed) END observed,"
+"CASE WHEN COUNT(sample_physical)=COUNT(*) THEN SUM(sample_physical) END sample_physical FROM day_result GROUP BY customer_code,business_date), owner_windows AS (SELECT customer_code"
+windowAggregates(true)+",COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) sample_count,"
+"CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)/#{p.query.days} END average_physical,"
+"GROUP_CONCAT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) AND sample_physical IS NULL THEN CAST(business_date AS CHAR) END ORDER BY business_date SEPARATOR ',') missing_dates FROM owner_days GROUP BY customer_code), owner_totals AS ("
+totals("customer_code",true)+" FROM sku_result GROUP BY customer_code), owner_result AS (SELECT t.*,w.sample_count,w.average_physical,w.missing_dates,"
+"CASE WHEN w.average_physical>0 THEN w.w"+p.query.days()+"_outbound/w.average_physical END turnover,"
+String.join(",",List.of(7,15,30).stream().flatMap(n->List.of("outbound","observed","complete","partial").stream().map(s->"w.w"+n+"_"+s)).toList())
+" FROM owner_totals t JOIN owner_windows w ON w.customer_code=t.customer_code) ";
}
private static String totals(String key,boolean withFocus) {
var sql=new StringBuilder("SELECT ").append(key==null?"COUNT(*) sku_count":key);
for(String field:List.of("physical","available","frozen","defective")) sql.append(",CASE WHEN COUNT(").append(field).append(")=COUNT(*) THEN COALESCE(SUM(").append(field).append("),0) END ").append(field)
.append(",SUM(").append(field).append(") ").append(field).append("_known,COUNT(*)-COUNT(").append(field).append(") ").append(field).append("_unknown");
sql.append(",MAX(last_outbound) last_outbound,CASE WHEN COUNT(physical)=COUNT(*) THEN SUM(CASE WHEN physical>0 THEN 1 ELSE 0 END) END physical_skus,COALESCE(SUM(unassessable),0) unassessable");
for(String risk:List.of("no_outbound","slow")) 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 ELSE 0 END) ").append(risk).append("_physical,COUNT(*)-COUNT(").append(risk).append(") ").append(risk).append("_unknown");
if(withFocus)sql.append(",MAX(CASE WHEN available>0 AND coverage_days IS NOT NULL AND coverage_days<=#{p.query.coverageDaysThreshold,jdbcType=INTEGER} THEN 1 ELSE 0 END) fast_count,MAX(CASE WHEN frozen>0 THEN 1 ELSE 0 END) frozen_count,MAX(CASE WHEN defective>0 THEN 1 ELSE 0 END) defective_count");
return sql.toString();
}
private static String filter(Input p,boolean owner) {
String predicate=switch(p.query.attention()) {
case "NO_OUTBOUND_30D" -> owner?"no_outbound_count>0":"no_outbound=1";
case "COVERAGE_GT" -> owner?"slow_count>0":"slow=1";
case "COVERAGE_LE" -> owner?"fast_count>0":"available>0 AND coverage_days IS NOT NULL AND coverage_days<=#{p.query.coverageDaysThreshold}";
case "FROZEN" -> owner?"frozen_count>0":"frozen>0";
case "DEFECTIVE" -> owner?"defective_count>0":"defective>0";
case "UNASSESSABLE" -> "unassessable>0";
default -> "1=1";
};
if(p.query.turnoverStatus()!=null) predicate+=" AND sample_count<#{p.query.days}";
if(!owner&&p.query.sku()!=null) predicate+=" AND sku_code=#{p.query.sku}";
return " WHERE "+predicate;
}
private static String order(Input p,boolean owner) {
String field=switch(p.query.sort()) {
case "AVAILABLE_DESC" -> "available";case "OUTBOUND_7_DESC" -> "w7_outbound";
case "OUTBOUND_15_DESC" -> "w15_outbound";case "OUTBOUND_30_DESC" -> "w30_outbound";
case "COVERAGE_ASC","COVERAGE_DESC" -> "coverage_days";case "TURNOVER_DESC" -> "turnover";
case "NO_OUTBOUND_PHYSICAL_DESC" -> "no_outbound_physical";default -> "physical";
};
return " ORDER BY "+field+" IS NULL,"+field+("COVERAGE_ASC".equals(p.query.sort())?" ASC":" DESC")+(owner?",customer_code":",sku_code,customer_code");
}
public static String page(Map<String,Object> args) {
Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group());
return cte(p)+(owner?ownerCte(p):"")+"SELECT * FROM "+(owner?"owner_result":"sku_result")+filter(p,owner)+order(p,owner)+" LIMIT #{p.query.pageSize} OFFSET #{p.query.offset}";
}
public static String count(Map<String,Object> args) {
Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group());
return cte(p)+(owner?ownerCte(p):"")+"SELECT COUNT(*) FROM "+(owner?"owner_result":"sku_result")+filter(p,owner);
}
public static String overview(Map<String,Object> args) {
Input p=(Input)args.get("p");return cte(p)+totals(null,false)+" FROM sku_result";
}
public static String owners(Map<String,Object> args) {
Input p=(Input)args.get("p");return cte(p)+ownerCte(p)
+", ranked_owners AS (SELECT customer_code,physical FROM owner_result WHERE physical IS NOT NULL ORDER BY physical DESC,customer_code LIMIT 10) "
+"SELECT 'OWNER' kind,customer_code,physical,0 unknown_count FROM ranked_owners UNION ALL SELECT 'META' kind,NULL customer_code,NULL physical,COUNT(*) unknown_count FROM owner_result WHERE physical IS NULL";
}
public static String chartWindows(Map<String,Object> args) {
Input p=(Input)args.get("p");return cte(p)+", warehouse_days AS (SELECT business_date,MIN(owner_complete) complete,CASE WHEN MIN(owner_complete)=1 THEN SUM(COALESCE(quantity,0)) ELSE SUM(observed) END observed FROM day_result GROUP BY business_date) SELECT COUNT(*) day_count"+windowAggregates(true)+" FROM warehouse_days";
}
}
@@ -0,0 +1,115 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import java.math.BigDecimal;
import java.sql.*;
import java.time.*;
import java.util.*;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.reflection.SystemMetaObject;
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
/** Synthetic local SQL semantics only. Does not assert MySQL performance or deployed availability. */
class InventoryTurnoverSqlTest {
private Connection db;
private final Configuration config=new Configuration();
static final LocalDate END=LocalDate.of(2026,10,12);
@BeforeEach void setup() throws Exception {
db=DriverManager.getConnection("jdbc:h2:mem:turnover_"+UUID.randomUUID()+";MODE=MySQL","sa","");
config.addMapper(InventoryTurnoverReadMapper.class);
execute("CREATE ALIAS CONVERT_TZ FOR 'com.baiyue.lastleg.selfoperated.analytics.query.AnalyticsSqlSemanticTest.convertZone'");
execute("CREATE TABLE ll_selfop_sku_profile(profile_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,product_name VARCHAR,quantity_unit VARCHAR,evidence_type VARCHAR,evidence_run_id BIGINT,earliest_evidence_at_utc TIMESTAMP)");
execute("CREATE TABLE ll_selfop_sku_daily_sales(sales_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,business_date DATE,outbound_quantity DECIMAL(20,6),quantity_unit VARCHAR)");
execute("CREATE TABLE ll_selfop_inventory_snapshot(snapshot_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,product_quality INT,physical_quantity DECIMAL(20,6),available_quantity DECIMAL(20,6),frozen_quantity DECIMAL(20,6),quality_status VARCHAR,quality_reason VARCHAR)");
}
@AfterEach void close() throws Exception {db.close();}
static InventoryTurnoverRequest request(String group,String focus,String sort,String sku,Integer threshold,int page,int size) {
return new InventoryTurnoverRequest("demo","W",END,30,group,null,sku,focus,threshold,null,sort,page,size,true,null);
}
static InventoryTurnoverSql.Input input(InventoryTurnoverRequest q,int covered,int samples,boolean fresh) {
List<InventoryTurnoverSql.Day> days=new ArrayList<>();
for(int i=0;i<30;i++){LocalDate date=END.minusDays(29-i);days.add(new InventoryTurnoverSql.Day(date,date.atStartOfDay().minusHours(8),i>=30-covered?1:0,i<samples?100L+i:null));}
return new InventoryTurnoverSql.Input(q,11,22,fresh,true,List.copyOf(days));
}
@Test void threeWindowsAndDailyQualitiesAreNotMultiplied() throws Exception {
seed("A","SKU",30);
var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true);
var row=query("page",p).get(0);
decimal("70",row.get("w7_outbound"));decimal("150",row.get("w15_outbound"));decimal("300",row.get("w30_outbound"));
decimal("600",row.get("available"));decimal("60",row.get("coverage_days"));
decimal("500",row.get("average_physical"));decimal("0.6",row.get("turnover"));
assertEquals(30,((Number)row.get("sample_count")).intValue());
decimal("1",query("count",p).get(0).values().iterator().next());
}
@Test void gapsKeepSevenDaysAndInvalidateThirtyWithoutShrinking() throws Exception {
seed("A","SKU",2);
var row=query("page",input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),7,2,true)).get(0);
decimal("70",row.get("w7_outbound"));assertNull(row.get("w15_outbound"));assertNull(row.get("w30_outbound"));
decimal("300",row.get("w30_observed"));assertNull(row.get("coverage_days"));assertNull(row.get("turnover"));
assertEquals(2,((Number)row.get("sample_count")).intValue());
}
@Test void historicalTurnoverSurvivesStaleCurrentAndMissingCurrentIsNotZero() throws Exception {
seed("A","SKU",30);
var row=query("page",input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,30,false)).get(0);
assertNull(row.get("physical"));assertNull(row.get("coverage_days"));decimal("0.6",row.get("turnover"));
execute("DELETE FROM ll_selfop_inventory_snapshot WHERE snapshot_run_id=22");
row=query("page",input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true)).get(0);
assertNull(row.get("physical"));decimal("300",row.get("w30_outbound"));
}
@Test void ownerAggregationAndUnknownPropagationUseFullCandidates() throws Exception {
seed("A","SKU",30);seed("B","SKU",30);
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=NULL,quality_status='UNKNOWN',quality_reason='FIELD_MISSING' WHERE customer_code='A' AND snapshot_run_id=22");
var rows=query("page",input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true));
assertEquals(2,rows.size());assertEquals("B",rows.get(0).get("customer_code"));
assertNull(rows.get(1).get("physical"));decimal("300",rows.get(1).get("w30_outbound"));
decimal("0.6",rows.get(1).get("turnover"));
}
@Test void zeroAvailableNeverMatchesFastAndOverviewIgnoresSkuFocus() throws Exception {
seed("A","SKU",0);
execute("UPDATE ll_selfop_inventory_snapshot SET available_quantity=0 WHERE snapshot_run_id=22");
var q=request("SKU","COVERAGE_LE","PHYSICAL_DESC","does-not-exist",60,1,20);
var p=input(q,7,0,true);
assertTrue(query("page",p).isEmpty());
decimal("1",query("overview",p).get(0).get("sku_count"));
var row=query("page",input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),7,0,true)).get(0);
decimal("0",row.get("coverage_days"));decimal("1",row.get("unassessable"));
}
private void seed(String owner,String sku,int samples) throws Exception {
profile(11,owner,sku);profile(22,owner,sku);stock(22,owner,sku,0,600,600);stock(22,owner,sku,1,10,0);
for(int i=0;i<30;i++) {
execute("INSERT INTO ll_selfop_sku_daily_sales VALUES(11,'demo','W','"+owner+"','"+sku+"',DATE '"+END.minusDays(i)+"',10,'PCS')");
if(i<samples){profile(100+i,owner,sku);stock(100+i,owner,sku,0,450,450);stock(100+i,owner,sku,1,50,0);}
}
execute("INSERT INTO ll_selfop_sku_daily_sales VALUES(11,'demo','OTHER','"+owner+"','"+sku+"',DATE '"+END+"',999,'PCS')");
}
private void profile(long run,String owner,String sku) throws Exception {
execute("INSERT INTO ll_selfop_sku_profile VALUES("+run+",'demo','W','"+owner+"','"+sku+"','Fixture','PCS','INVENTORY_OBSERVATION',1,TIMESTAMP '2026-01-01 00:00:00')");
}
private void stock(long run,String owner,String sku,int quality,int physical,int available) throws Exception {
execute("INSERT INTO ll_selfop_inventory_snapshot VALUES("+run+",'demo','W','"+owner+"','"+sku+"',"+quality+","+physical+","+available+",0,'VALID',NULL)");
}
private void execute(String sql) throws Exception {try(var s=db.createStatement()){s.execute(sql);}}
private List<Map<String,Object>> query(String method,InventoryTurnoverSql.Input p) throws Exception {
var args=Map.of("p",p);var bound=config.getMappedStatement(InventoryTurnoverReadMapper.class.getName()+"."+method).getBoundSql(args);
var meta=SystemMetaObject.forObject(args);String sql=bound.getSql();
for(var parameter:bound.getParameterMappings()) {
Object value=meta.getValue(parameter.getProperty());String literal=value==null?"NULL":value instanceof Boolean b?b?"1":"0":value instanceof Number?value.toString():"'"+value.toString().replace("'","''")+"'";
sql=sql.replaceFirst("\\?",java.util.regex.Matcher.quoteReplacement(literal));
}
// H2 inlines repeatedly referenced CTEs exponentially. Materialize each unchanged
// production relation in this local fixture; MySQL execution plans remain unverified.
List<String> relations=new ArrayList<>();int cursor=5;
while(true) {
int as=sql.indexOf(" AS (",cursor);String name=sql.substring(cursor,as).trim();int start=as+5,end=start,depth=1;boolean quoted=false;
while(depth>0){char ch=sql.charAt(end++);if(ch=='\''){if(quoted&&end<sql.length()&&sql.charAt(end)=='\''){end++;continue;}quoted=!quoted;}else if(!quoted){if(ch=='(')depth++;if(ch==')')depth--;}}
execute("CREATE LOCAL TEMPORARY TABLE "+name+" AS "+sql.substring(start,end-1));relations.add(name);
cursor=end;while(cursor<sql.length()&&Character.isWhitespace(sql.charAt(cursor)))cursor++;
if(sql.charAt(cursor)!=',')break;cursor++;while(Character.isWhitespace(sql.charAt(cursor)))cursor++;
}
try(var s=db.createStatement();var rs=s.executeQuery(sql.substring(cursor))) {
List<Map<String,Object>> rows=new ArrayList<>();
while(rs.next()){Map<String,Object> row=new HashMap<>();for(int i=1;i<=rs.getMetaData().getColumnCount();i++)row.put(rs.getMetaData().getColumnLabel(i).toLowerCase(Locale.ROOT),rs.getObject(i));rows.add(row);}return rows;
} finally {Collections.reverse(relations);for(String name:relations)execute("DROP TABLE "+name);}
}
private static void decimal(String expected,Object actual){assertNotNull(actual);assertEquals(0,new BigDecimal(expected).compareTo(new BigDecimal(actual.toString())));}
}
+1 -1
View File
@@ -10,7 +10,7 @@
| 类别 | 正式入口 | 管理边界 | | 类别 | 正式入口 | 管理边界 |
|---|---|---| |---|---|---|
| 生产单票发布验收 | `prod-release-acceptance.mjs`、`prod-release-delivery-stage.mjs`、内部 `prod-release-order-stage.mjs` | 同一票义乌到洛杉矶、前置同时核对东莞;订单创建/提交/审核与尾程签收/国内回传分阶段续跑,检查点独立;不调用测试造数模块,不创建账号/主档或删除数据;配置和边界见 README | | 测试/灰度单票验收 | `prod-release-acceptance.mjs`、`prod-release-delivery-stage.mjs`、内部 `prod-release-order-stage.mjs` | 显式 test/gray、统一 Gateway/accessToken;同一票义乌到洛杉矶、前置同时核对东莞;full-flow 串行覆盖买单FBA正常链至签收回传及车次资源释放,保留分阶段入口与独立检查点;不调用测试造数模块,不创建账号/主档或删除数据;配置和边界见 README |
| Admin 全量 HTTP 验证 | `admin-test-env-acceptance.mjs` | 通过显式环境文件运行统一 TEST Gateway/会话,拒绝旧海外网关参数;默认模块覆盖平台全链路,定向验证使用 `--modules`,模块注册和依赖以总入口及 `admin-test-env-modules/README.md` 为准 | | Admin 全量 HTTP 验证 | `admin-test-env-acceptance.mjs` | 通过显式环境文件运行统一 TEST Gateway/会话,拒绝旧海外网关参数;默认模块覆盖平台全链路,定向验证使用 `--modules`,模块注册和依赖以总入口及 `admin-test-env-modules/README.md` 为准 |
| Client 全量 HTTP 验证 | `client-test-env-acceptance.mjs` | 通过显式环境文件运行统一 TEST Gateway/会话,拒绝旧海外网关参数;覆盖客户工作台完整链路,`client-phase2-test-env-acceptance.mjs` 只是入库测量与计费专项,不替代全量入口 | | Client 全量 HTTP 验证 | `client-test-env-acceptance.mjs` | 通过显式环境文件运行统一 TEST Gateway/会话,拒绝旧海外网关参数;覆盖客户工作台完整链路,`client-phase2-test-env-acceptance.mjs` 只是入库测量与计费专项,不替代全量入口 |
| Client 快速下单定向验证 | `client-quick-order-test-env-acceptance.mjs` | 使用既有测试客户通过批量草稿接口验证 FBA、海外仓、私人地址、逐行失败和幂等重放;只创建草稿,不提交或审核,不替代 Client 全量入口 | | Client 快速下单定向验证 | `client-quick-order-test-env-acceptance.mjs` | 使用既有测试客户通过批量草稿接口验证 FBA、海外仓、私人地址、逐行失败和幂等重放;只创建草稿,不提交或审核,不替代 Client 全量入口 |
+17 -14
View File
@@ -83,7 +83,7 @@ F40 专项可用 `--signature-mode ROOT_FIRST|CHILD_FIRST|ROOT_SIGNED_REMAINDER_
`fleet-cross-customer-test-env.mjs` 是最小两客户共享车次的串行 TEST 专项入口,默认 `--plan` 零网络。显式运行参数为 `--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外收货仓ID> --destination-warehouse-id <FBA目的仓ID>`,凭据仅从显式 `node --env-file="<TEST配置>" --env-file="<Jenkins配置>"` 与 `FBA_CORE_JENKINS_AGENT` 提供。通过普通转运来货、PDA实收和客户确认创建两个新客户各一托一箱,分别预排并关联同车;一单未拣齐时验证全车拒绝及完整库存快照不变,再统一发车、原键重放、逐客户签收,最后正式结案并分别释放车辆/司机,核对结案前后库存零变化。完整库存由只读取证器获取,不写业务SQL,不部署或改共享配置。报告位于 `reports/fleet-cross-<新批次>/`,每次请求发送前记录意图;失败保留原现场,不自动重跑或续跑,已存在批次拒绝覆盖。finally停用本轮PDA并退出独立会话;业务样本保留。前置拒绝不代表事务中途回滚,后者另验。对应入口保护合同为 `fleet-cross-customer-test-env.test.mjs`;内部生成与验收函数见模块README。 `fleet-cross-customer-test-env.mjs` 是最小两客户共享车次的串行 TEST 专项入口,默认 `--plan` 零网络。显式运行参数为 `--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外收货仓ID> --destination-warehouse-id <FBA目的仓ID>`,凭据仅从显式 `node --env-file="<TEST配置>" --env-file="<Jenkins配置>"` 与 `FBA_CORE_JENKINS_AGENT` 提供。通过普通转运来货、PDA实收和客户确认创建两个新客户各一托一箱,分别预排并关联同车;一单未拣齐时验证全车拒绝及完整库存快照不变,再统一发车、原键重放、逐客户签收,最后正式结案并分别释放车辆/司机,核对结案前后库存零变化。完整库存由只读取证器获取,不写业务SQL,不部署或改共享配置。报告位于 `reports/fleet-cross-<新批次>/`,每次请求发送前记录意图;失败保留原现场,不自动重跑或续跑,已存在批次拒绝覆盖。finally停用本轮PDA并退出独立会话;业务样本保留。前置拒绝不代表事务中途回滚,后者另验。对应入口保护合同为 `fleet-cross-customer-test-env.test.mjs`;内部生成与验收函数见模块README。
头程订单提交使用首次原始详情的 `expectedVersion` 与原 `Idempotency-Key`。生产单票的 `SUBMIT_INTENT` 必须先持久化 `submitCommand` 再发送,显式续跑不得使用当前版本替换原命令,缺少原命令的提交意图及后续检查点停止并等待人工核对。源 Excel 造数同样在提交前保存订单条目的 `submitCommand`;中断或失败条目保留原检查点且不自动重跑,不重建订单或换版本继续。 头程订单提交使用首次原始详情的 `expectedVersion` 与原 `Idempotency-Key`。单票验收的 `SUBMIT_INTENT` 必须先持久化 `submitCommand` 再发送,显式续跑不得使用当前版本替换原命令,缺少原命令的提交意图及后续检查点停止并等待人工核对。源 Excel 造数同样在提交前保存订单条目的 `submitCommand`;中断或失败条目保留原检查点且不自动重跑,不重建订单或换版本继续。
## 验收证据校验 ## 验收证据校验
@@ -157,34 +157,37 @@ node --env-file="<外部生产数据库.env>" scripts/prod-database-schema-accep
## 验收环境变量文件 ## 验收环境变量文件
### 生产单票发布验收(独立入口) ### 测试/灰度单票验收(独立入口)
称重计算内部模块 `prod-release-measurement-plan.mjs` 统一采用每箱含纸箱、不含托盘 7.5 kg、40×30×30 cm(0.036 m³)。`order-stage` 在载荷绑定及报价前自动按箱数统一申报箱规、货重及总体积;不推算商品净重,已填商品净重若超过含箱货重则阻断。改口径后的已有检查点载荷不一致会停止,不能以换批次方式盲目重跑。 称重计算内部模块 `prod-release-measurement-plan.mjs` 统一采用每箱含纸箱、不含托盘 7.5 kg、40×30×30 cm(0.036 m³)。`order-stage` 在载荷绑定及报价前自动按箱数统一申报箱规、货重及总体积;不推算商品净重,已填商品净重若超过含箱货重则阻断。改口径后的已有检查点载荷不一致会停止,不能以换批次方式盲目重跑。
只读计算命令:`node --env-file="<外部生产配置.env>" scripts/prod-release-acceptance.mjs measurement-plan 2`(箱数只允许 1 或 2;执行前仍需生产 HTTP 授权)。外部配置新增 `CHONGCHONG_ACCEPTANCE_PALLET_CODES`,逗号分隔明确允许复用的现有托盘编码;检查所有编码唯一存在于义乌仓,稳定选择列表第一个,读取其实际 `capacityCbm` 和 `weightKg`,不写死容量或皮重。托数按总体积/选中托盘容量向上取整,每托分配整数箱,称重毛重=该托箱数×7.5+皮重;容量不足以容纳完整箱或参数异常时阻断。不创建托盘/库位、不上传称重图片、不确认称重,只向终端输出 `MEASUREMENT_PLAN_ONLY` 计算结果。服务端库位槽数还依赖其默认托容量配置,与此处实际称重托数不是同一字段;后续由服务端分配并回查既有普通位或 A0,不在脚本伪造库位数。 只读计算命令:`node --env-file="<明确目标环境配置.env>" scripts/prod-release-acceptance.mjs measurement-plan 2`(箱数只允许 1 或 2;执行前仍需目标环境 HTTP 授权)。外部配置新增 `CHONGCHONG_ACCEPTANCE_PALLET_CODES`,逗号分隔明确允许复用的现有托盘编码;检查所有编码唯一存在于义乌仓,稳定选择列表第一个,读取其实际 `capacityCbm` 和 `weightKg`,不写死容量或皮重。托数按总体积/选中托盘容量向上取整,每托分配整数箱,称重毛重=该托箱数×7.5+皮重;容量不足以容纳完整箱或参数异常时阻断。不创建托盘/库位、不上传称重图片、不确认称重,只向终端输出 `MEASUREMENT_PLAN_ONLY` 计算结果。服务端库位槽数还依赖其默认托容量配置,与此处实际称重托数不是同一字段;后续由服务端分配并回查既有普通位或 A0,不在脚本伪造库位数。
离线验证:`node --test scripts/prod-release-measurement-plan.test.mjs`,覆盖小数容量边界、皮重、箱数/重量守恒、错误仓库和托盘白名单;不证明真实称重写入已实现。 离线验证:`node --test scripts/prod-release-measurement-plan.test.mjs`,覆盖小数容量边界、皮重、箱数/重量守恒、错误仓库和托盘白名单;不证明真实称重写入已实现。
`prod-release-acceptance.mjs` 目标固定为同一票从义乌 `CN-YIWU-01` 到洛杉矶 `US-LAX-01`,并在前置检查中同时核对东莞 `CN-DONGGUAN-01`;客户身份由外部配置精确限定,不调用开发/测试全量造数模块。订单阶段负责单票创建、提交和审核;`prod-release-delivery-stage.mjs` 负责既有本批次的派送中、签收文件、派送完成和国内签收事件回查,两个阶段分别使用独立检查点。签收脚本不会借用历史单据或删除数据。 `prod-release-acceptance.mjs` 目标固定为同一票从义乌 `CN-YIWU-01` 到洛杉矶 `US-LAX-01`,并在前置检查中同时核对东莞 `CN-DONGGUAN-01`;客户身份由外部配置精确限定,不调用开发/测试全量造数模块。订单阶段负责单票创建、提交和审核;`prod-release-delivery-stage.mjs` 负责既有本批次的派送中、签收文件、派送完成和国内签收事件回查,两个阶段分别使用独立检查点。签收脚本不会借用历史单据或删除数据。
- `node scripts/prod-release-acceptance.mjs plan`:不加载凭据,不访问 HTTP,列出主链阶段与不覆盖范围。 - `node scripts/prod-release-acceptance.mjs plan`:不加载凭据,不访问 HTTP,列出主链阶段与不覆盖范围。
- `node --env-file="$env:USERPROFILE\.codex\env\chongchong-global\prod-acceptance.env" scripts/prod-release-acceptance.mjs preflight`:获得当次生产 HTTP 授权后运行;登录既有四个账号并查询客户、义乌/东莞/洛杉矶仓、PDA 绑定及义乌资源。不创建业务数据、主档或账号,不删除数据;登录本身会创建会话及审计记录。 - `node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/prod-release-acceptance.mjs preflight`:获得当次目标环境 HTTP 授权后运行;登录既有四个账号并查询客户、义乌/东莞/洛杉矶仓、PDA 绑定及义乌资源。不创建业务数据、主档或账号,不删除数据;登录本身会创建会话及审计记录。
- 必填 `CHONGCHONG_ENV=prod`;`CHONGCHONG_ADMIN_API_BASE_URL`、`CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL`、`CHONGCHONG_MERCHANT_API_BASE_URL` 分别为同一 HTTPS 前端代理的 `/api`、`/overseas-api`、`/api`。 - 必填 `CHONGCHONG_ENV=test` 或 `gray`;`CHONGCHONG_ADMIN_API_BASE_URL` 与 `CHONGCHONG_MERCHANT_API_BASE_URL` 必须完全指向同一 HTTPS `/api` 统一入口。拒绝 `prod/dev` 及旧 `CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL`、`CHONGCHONG_MERCHANT_OVERSEAS_API_BASE_URL` 配置(即使值与统一入口相同也必须移除)。所有业务及文件上传只使用登录返回的 `accessToken`,不读取海外 Token。灰度由运行者显式选择外部灰度配置,不自动读取、修改或回退环境文件。
- 客户锁定配置:`CHONGCHONG_ACCEPTANCE_CUSTOMER_ID`、`CHONGCHONG_ACCEPTANCE_CUSTOMER_CODE`、`CHONGCHONG_ACCEPTANCE_CUSTOMER_NAME`。四组账号分别使用 `CHONGCHONG_ADMIN_*`、`CHONGCHONG_MERCHANT_*`、`CHONGCHONG_CN_PDA_*`、`CHONGCHONG_OVERSEAS_LAX_PDA_*` 的 `USERNAME` / `PASSWORD`;另需 `CHONGCHONG_OVERSEAS_LAX_PDA_WAREHOUSE_CODE=US-LAX-01`。不修改环境文件,不读取其他环境作为回退。 - 客户必须为 `ACTIVE` 且箱规初始化为 `READY`;`PROFILE_REQUIRED` 等状态阻断,不自动完善资料或审核客户。客户锁定配置:`CHONGCHONG_ACCEPTANCE_CUSTOMER_ID`、`CHONGCHONG_ACCEPTANCE_CUSTOMER_CODE`、`CHONGCHONG_ACCEPTANCE_CUSTOMER_NAME`。四组账号分别使用 `CHONGCHONG_ADMIN_*`、`CHONGCHONG_MERCHANT_*`、`CHONGCHONG_CN_PDA_*`、`CHONGCHONG_OVERSEAS_LAX_PDA_*` 的 `USERNAME` / `PASSWORD`;另需 `CHONGCHONG_OVERSEAS_LAX_PDA_WAREHOUSE_CODE=US-LAX-01`。不修改环境文件,不读取其他环境作为回退。
- 报告位于 `reports/prod-release-preflight-<时间戳>.json`,不包含账号、Token、真实客户名称或原始响应。网络异常不自动重试;修正问题后可重新执行只读检查,产生新报告,不清理旧报告或业务数据。 - 报告位于 `reports/prod-release-preflight-<时间戳>.json`,不包含账号、Token、真实客户名称或原始响应。网络异常不自动重试;修正问题后可重新执行只读检查,产生新报告,不清理旧报告或业务数据。
- 单票入口 `order-stage <外部订单输入.json> <本批次检查点.json> --confirm-production-order [--resume]`:须另获当次生产写入授权。自动执行基本前置检查,额外校验指定客户可选的唯一渠道/包税维度与 1~2 箱实际重量、体积报价,然后创建一票、提交、审核;不创建图片、地址、托盘或库位。仅支持 AUTO 入仓码、普通海运 FBA 自送散货、单 SKU 非木制非危险品;目的 FBA 仓显式指定,洛杉矶是后续接柜仓,不是第二票。输入与检查点放仓库外,同一批次始终使用同一检查点文件,禁止并发另开路径或换批次重试。 - 单票入口 `order-stage <外部订单输入.json> <本批次检查点.json> --confirm-single-order [--resume]`:须另获当次目标环境写入授权。自动执行基本前置检查,额外校验指定客户可选的唯一渠道/包税维度与 1~2 箱实际重量、体积报价,然后创建一票、提交、审核;不创建图片、地址、托盘或库位。仅支持 AUTO 入仓码、普通海运 FBA 自送散货、单 SKU 非木制非危险品;目的 FBA 仓显式指定,洛杉矶是后续接柜仓,不是第二票。输入与检查点放仓库外,同一批次始终使用同一检查点文件,禁止并发另开路径或换批次重试。
- 尾程签收入口 `delivery-stage <尾程事实输入.json> <独立签收检查点.json> --confirm-production-delivery`:须另获当次生产写入授权。脚本只接受本批次已核对的排车、出库、派送和尾程订单身份;按状态版本推进 `DELIVERING`,上传验收用签收文件并保存资料,回查 `DELIVERED`、国内入仓 `DELIVERED` 和头程订单 `COMPLETED`。上传或状态写入结果不确定时停止;托盘明细或尾程子状态未同步会记录为阻断项。 - 尾程签收入口 `delivery-stage <尾程事实输入.json> <独立签收检查点.json> --confirm-single-delivery`:须另获当次目标环境写入授权。脚本只接受本批次已核对的排车、出库、派送和尾程订单身份;按状态版本推进 `DELIVERING`,上传验收用签收文件并保存资料,回查 `DELIVERED`、国内入仓 `DELIVERED` 和头程订单 `COMPLETED`。上传或状态写入结果不确定时停止;托盘明细或尾程子状态未同步会记录为阻断项。
- `prod-release-order-stage.mjs` 是上述入口使用的内部订单阶段与检查点实现,不可直接执行。先落盘并同步意图,再调用 HTTP;检查点绑定环境、客户、操作者、批次和载荷摘要,不保存密码/Token/完整申报。创建与提交仅在显式续跑时复用原幂等键;审核无幂等键,响应丢失后只回查,若仍不能确认已审核则停止并要求人工核对,不自动再次审核。崩溃残留 `.lock` 时先确认无运行进程、检查业务状态,再人工解除锁;不得删除业务检查点。 - `prod-release-order-stage.mjs` 是上述入口使用的内部订单阶段与检查点实现,不可直接执行。先落盘并同步意图,再调用 HTTP;检查点绑定环境模式及统一 API 地址、客户、操作者、批次和载荷摘要,不保存密码/Token/完整申报。旧双网关检查点不兼容,遇到绑定不符应先人工核对原票状态,禁止删除检查点后重新造票。创建与提交仅在显式续跑时复用原幂等键;审核无幂等键,响应丢失后只回查,若仍不能确认已审核则停止并要求人工核对,不自动再次审核。崩溃残留 `.lock` 时先确认无运行进程、检查业务状态,再人工解除锁;不得删除业务检查点。
- 订单输入字段:`batch`(8~48 位字母数字/连字符)、`origin: {id, code: "CN-YIWU-01"}`、`destination: {id, code, type: "FBA"}`、`payload`(`order` 服务 `OrderRequests.SaveRequest` 完整请求)。客户、环境和操作者由外部 env 强制绑定;`payload.customerName` 必须匹配配置,`customerRemark` 必须为 `RELEASE-ACCEPTANCE:<batch>`。显式填 `fulfillmentType=STANDARD`、`transportMode=SEA`、`pickupMethod=SELF_EXPRESS`、`containerMode=LCL`,`cargoes` 一行 1~2 箱;已上传的产品图片 ID 和商品申报字段由本次验收资料提供,箱规/含箱总重/总体积由上述固定口径统一计算,不生成资质或物流签收证明。箱数×每箱数量必须等于总件数,箱规×箱数必须等于总体积。 - 订单输入字段:`batch`(8~48 位字母数字/连字符)、`origin: {id, code: "CN-YIWU-01"}`、`destination: {id, code, type: "FBA"}`、`payload`(`order` 服务 `OrderRequests.SaveRequest` 完整请求)。客户、环境和操作者由外部 env 强制绑定;`payload.customerName` 必须匹配配置,`customerRemark` 必须为 `RELEASE-ACCEPTANCE:<batch>`。显式填 `fulfillmentType=STANDARD`、`transportMode=SEA`、`pickupMethod=SELF_EXPRESS`、`containerMode=LCL`,`cargoes` 一行 1~2 箱;已上传的产品图片 ID 和商品申报字段由本次验收资料提供,箱规/含箱总重/总体积由上述固定口径统一计算,不生成资质或物流签收证明。箱数×每箱数量必须等于总件数,箱规×箱数必须等于总体积。
- 待实现:供应商及海外资源门禁、测量至签收写入、后续阶段归属核验、跨区等待和检查点、所有阶段真实 HTTP 验证。全链 `execute` / `resume` 命令仍拒绝;订单续跑仅使用上述 `order-stage ... --resume`。不得改用测试全量脚本来代替。 - 完整正常链:`full-flow <外部订单输入.json> <独立全流程检查点.json> --confirm-single-full-flow [--resume]`。仅支持 `BUY_DECLARATION` 买单报关、单 SKU、1~2 箱、义乌起运及洛杉矶接柜的 FBA 正常链。先用现有客户下单审核,再测量/费用确认、订舱/分柜、国内 PDA 装柜、买单报关、人工船节点、目的港提柜预约、海外 PDA 收货/还空柜、排车/派送、车次调度/拣货/发车、签收回传及车次结案释放资源。任何HTTP或归属/数量门禁失败都停止,保留同票检查点,不新建订单代替失败。
- 离线验证:`node --test scripts/prod-release-acceptance.test.mjs`。一票主链不覆盖多客户混柜、退货异常、真实付款、外部下单、邮件发送或页面交互。 - 全流程内部模块:`prod-release-full-flow.mjs` 负责单票阶段编排和独立子检查点;`prod-release-http.mjs` 负责统一 Gateway、写前请求摘要落盘、失败诊断、合成文件上传及独立登录会话退出;`prod-release-domestic-stage.mjs` 负责国内正常链;`prod-release-overseas-stage.mjs` 负责海外正常链和签收后车次结案。这些文件没有独立CLI,不运行全量fixture,不创建账号、客户、车辆、司机、供应商等主档。正常业务会创建本票货柜、订舱、作业托盘、排车、车次、派送及费用记录。
- 完整输入还需 `domestic: {palletId, assigneeId, bookingAgent:{id,name}, carrier:{id,name,carrierCode}, terminalFleet:{id,name}, estimatedSailingAt, estimatedArrivalAt}` 和 `resources:{vehicleId,driverId,carrierName}`,均引用既有启用且有资格的资源。订单级及货物行都必须明确提供 `shipmentId/referenceId`;不能只填货物行。报告/输入/检查点使用仓库外私有路径;公开交付仅保留脱敏结果、业务号及诊断编号。
- 全流程报告只有全部正常链及签收回传、资源释放核对成功才输出 `SINGLE_ORDER_HTTP_COMPLETED` 与 `fullFlowVerified:true`,其范围仅为 `ONE_ORDER_NORMAL_HTTP_PATH`。测量、资质、装柜、卸柜和签收文件均明确为合成验收材料,不代表真实物流证据;不覆盖退税报关、多客户/拆单/异常、故障注入、真实支付、第三方下单、邮件、浏览器及设备验收。阶段检查点已完成命令复用原结果,未知写入意图必须人工核对,不能自动重发。
- 离线验证:`node --test scripts/prod-release-acceptance.test.mjs scripts/prod-release-order-stage.test.mjs scripts/prod-release-delivery-stage.test.mjs scripts/prod-release-measurement-plan.test.mjs scripts/prod-release-http.test.mjs scripts/prod-release-domestic-stage.test.mjs scripts/prod-release-overseas-stage.test.mjs scripts/script-documentation-contract.test.mjs`。一票主链不覆盖多客户混柜、退货异常、真实付款、外部下单、邮件发送或页面交互。最终输出单独报告 `sessionCleanup`,会话退出不受业务请求次数上限影响。
订单阶段与恢复验证:`node --test scripts/prod-release-order-stage.test.mjs`,包括断网响应丢失、原幂等键复用、未知审核结果停机、账号/客户/环境/载荷漂移、并发锁、损坏检查点与入口 HTTP mock。生产运行示例(只到已审核,执行前确认授权,不代表全链完成): 订单阶段与恢复验证:`node --test scripts/prod-release-order-stage.test.mjs`,包括断网响应丢失、原幂等键复用、未知审核结果停机、账号/客户/环境/载荷漂移、并发锁、损坏检查点与入口 HTTP mock。单票运行示例(只到已审核,执行前确认授权,不代表全链完成):
```powershell ```powershell
node --env-file="<外部生产配置.env>" scripts/prod-release-acceptance.mjs order-stage "<外部订单输入.json>" "<本批次检查点.json>" --confirm-production-order node --env-file="<明确目标环境配置.env>" scripts/prod-release-acceptance.mjs order-stage "<外部订单输入.json>" "<本批次检查点.json>" --confirm-single-order
# 中断后保持相同输入、账号和检查点;不要删除检查点或换批次。 # 中断后保持相同输入、账号和检查点;不要删除检查点或换批次。
node --env-file="<外部生产配置.env>" scripts/prod-release-acceptance.mjs order-stage "<外部订单输入.json>" "<本批次检查点.json>" --confirm-production-order --resume node --env-file="<明确目标环境配置.env>" scripts/prod-release-acceptance.mjs order-stage "<外部订单输入.json>" "<本批次检查点.json>" --confirm-single-order --resume
``` ```
统一 TEST 验收账号、密码和 Gateway 由仓库外配置文件显式提供,例如 `$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env`,不写入仓库。文件提供以下变量: 统一 TEST 验收账号、密码和 Gateway 由仓库外配置文件显式提供,例如 `$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env`,不写入仓库。文件提供以下变量:
+2 -1
View File
@@ -201,7 +201,8 @@ Assert-Contains $docsIndex '[测试环境独立任务协作登记](独立任务
Assert-Contains $docsIndex '[开发环境独立任务协作登记](开发环境独立任务协作登记.md)' '文档开发登记入口' Assert-Contains $docsIndex '[开发环境独立任务协作登记](开发环境独立任务协作登记.md)' '文档开发登记入口'
# BEGIN CURRENT TEST REGISTRY CONTRACT # BEGIN CURRENT TEST REGISTRY CONTRACT
function Assert-TestTaskRegistry([string]$registry) { function Assert-TestTaskRegistry([string]$registry) {
$current = ($registry -split '(?m)^## 2026-10-04 全流程唯一持续目标与车辆交接(当前)')[0] # Count this authorized group only; newer independently authorized groups must not be mixed into it.
$current = [regex]::Match($registry, '(?ms)^## 2026-10-04 用户确认的三个独立执行对话(当前派工)\r?\n.*?(?=^## |\z)').Value
Assert-Contains $current '## 2026-10-04 用户确认的三个独立执行对话(当前派工)' '当前全流程协作入口' Assert-Contains $current '## 2026-10-04 用户确认的三个独立执行对话(当前派工)' '当前全流程协作入口'
Assert-Contains $current '用户明确选择“按这 3 个独立对话创建”' '测试任务精确数量授权' Assert-Contains $current '用户明确选择“按这 3 个独立对话创建”' '测试任务精确数量授权'
Assert-Contains $current '所属唯一总控仍为 `退税报关` / `01a0d0da-130f-7d43-97b5-9c76abc1b5f1`' '测试所属总控' Assert-Contains $current '所属唯一总控仍为 `退税报关` / `01a0d0da-130f-7d43-97b5-9c76abc1b5f1`' '测试所属总控'
@@ -5,6 +5,8 @@ import test from 'node:test'
const source = readFileSync(new URL('./agents-index-contract.ps1', import.meta.url), 'utf8').replaceAll('\r\n', '\n') const source = readFileSync(new URL('./agents-index-contract.ps1', import.meta.url), 'utf8').replaceAll('\r\n', '\n')
const registry = readFileSync(new URL('../../docs/独立任务协作登记.md', import.meta.url), 'utf8') const registry = readFileSync(new URL('../../docs/独立任务协作登记.md', import.meta.url), 'utf8')
const currentGroup = registry.match(/^## 2026-10-04 用户确认的三个独立执行对话(当前派工)\r?\n[\s\S]*?(?=^## |$(?![\s\S]))/m)?.[0]
assert.ok(currentGroup, 'mutations must target their own authorized group')
const contract = source.split('# BEGIN CURRENT TEST REGISTRY CONTRACT\n')[1]?.split('# END CURRENT TEST REGISTRY CONTRACT')[0] const contract = source.split('# BEGIN CURRENT TEST REGISTRY CONTRACT\n')[1]?.split('# END CURRENT TEST REGISTRY CONTRACT')[0]
assert.ok(contract, 'test exercises the production PowerShell registry validator') assert.ok(contract, 'test exercises the production PowerShell registry validator')
function failures(text) { function failures(text) {
@@ -17,7 +19,10 @@ function Assert-NotContains([string]$text,[string]$needle,[string]$scope){if($te
${contract} ${contract}
Assert-TestTaskRegistry ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}'))) Assert-TestTaskRegistry ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}')))
ConvertTo-Json -InputObject @($script:failures) -Compress` ConvertTo-Json -InputObject @($script:failures) -Compress`
return JSON.parse(execFileSync('pwsh', ['-NoProfile', '-NonInteractive', '-Command', command], { encoding: 'utf8' })) // Large registries exceed Windows command-line limits; the generated harness is passed over stdin.
return JSON.parse(execFileSync('pwsh', ['-NoProfile', '-NonInteractive', '-Command',
'$registryHarness = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String([Console]::In.ReadToEnd())); & ([ScriptBlock]::Create($registryHarness))'],
{ encoding: 'utf8', input: Buffer.from(command).toString('base64') }))
} }
test('actual explicitly authorized TEST registry passes the production validator', () => { test('actual explicitly authorized TEST registry passes the production validator', () => {
assert.deepEqual(failures(registry), []) assert.deepEqual(failures(registry), [])
@@ -32,4 +37,5 @@ for (const [name, mutate] of [
['obsolete empty status', s => s + '\n当前无登记任务'], ['obsolete empty status', s => s + '\n当前无登记任务'],
['foreign project identity', s => s + '\n6f874ec3-d3b2-49bd-8625-327f4d685f98'], ['foreign project identity', s => s + '\n6f874ec3-d3b2-49bd-8625-327f4d685f98'],
['retired controller', s => s + '\n01a0194a-259c-7171-8967-074731a27618'], ['retired controller', s => s + '\n01a0194a-259c-7171-8967-074731a27618'],
]) test(`production validator rejects ${name}`, () => assert.ok(failures(mutate(registry)).length > 0)) ]) test(`production validator rejects ${name}`, () =>
assert.ok(failures(registry.replace(currentGroup, mutate(currentGroup))).length > 0))