优化
This commit is contained in:
@@ -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/` |
|
||||||
|
|
||||||
|
|||||||
@@ -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` 不得丢失中文备注。
|
||||||
|
|||||||
@@ -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` | 海外仓报价、月度汇率及车辆档案;不保存车辆派送履约历史 |
|
||||||
|
|||||||
+30
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -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("库存周转上下文无效或已过期,请整体刷新");}
|
||||||
|
}
|
||||||
+78
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -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);
|
||||||
|
}
|
||||||
+49
@@ -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("查询上下文无效,请整体刷新");
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
@@ -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) {}
|
||||||
|
}
|
||||||
+158
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
+115
@@ -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
@@ -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
@@ -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`,不写入仓库。文件提供以下变量:
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user