二期内容提交

This commit is contained in:
2026-08-04 10:07:45 +08:00
parent b2b4ff429d
commit 57b54a5236
184 changed files with 13195 additions and 5 deletions
@@ -3,11 +3,13 @@ package com.baiyue.firstleg;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
/**
* 头程履约微服务启动入口,负责装配履约任务组件并扫描 MyBatis-Plus Mapper。
*/
@MapperScan("com.baiyue.firstleg.mapper")
@EnableFeignClients(basePackages = "com.baiyue.firstleg.client")
@SpringBootApplication(scanBasePackages = "com.baiyue")
public class BaiyueFirstlegApplication {
public static void main(String[] args) { SpringApplication.run(BaiyueFirstlegApplication.class, args); }
@@ -0,0 +1,42 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
@FeignClient(name = "warehouse-service", contextId = "warehousePalletLocationClient",
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse/v1/pallet-locations")
public interface WarehousePalletLocationClient {
@PostMapping("/reservations")
ApiResponse<BatchResult> reserve(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ReserveRequest request);
@PostMapping("/confirm")
ApiResponse<BatchResult> confirm(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ConfirmRequest request);
@PostMapping("/release")
ApiResponse<List<ActiveResource>> release(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ReleaseRequest request);
record ReserveRequest(long tenantId, long warehouseId, String sourceService, long businessId,
String inboundCode, String idempotencyKey, long operatorId,
BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm, String palletType,
LocalDateTime expiresAt, boolean measurementCompleted) {}
record ConfirmRequest(long tenantId, long warehouseId, String sourceService, long businessId,
long operatorId, BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm) {}
record ReleaseRequest(long tenantId, String sourceService, long businessId, long operatorId,
String reason) {}
record AllocationResult(long reservationId, String reservationNo, int allocationNo,
long palletId, String palletCode, long locationId, String locationCode,
BigDecimal allocatedVolumeCbm, String state) {}
record BatchResult(int palletCount, List<AllocationResult> allocations) {}
record ActiveResource(long reservationId, long warehouseId, long palletId, String palletCode,
long locationId, String locationCode, String state) {}
}
@@ -0,0 +1,21 @@
package com.baiyue.firstleg.config;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 头程入库动态配置。代码默认值必须保持关闭,避免 Nacos 缺失时异常放行。
*/
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "firstleg.inbound")
public class InboundProperties {
private boolean allowRejectedMeasurement = false;
private BigDecimal defaultPalletCapacityCbm = new BigDecimal("1.200000");
private Duration reservationTtl = Duration.ofMinutes(30);
}
@@ -0,0 +1,29 @@
package com.baiyue.firstleg.config;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/** 第三方测量接口独立 Swagger 分组和 HMAC 请求头定义。 */
@Configuration
@SecurityScheme(name = "measurementAppKey", type = SecuritySchemeType.APIKEY,
in = SecuritySchemeIn.HEADER, paramName = "X-App-Key", description = "分配给测量服务商的应用标识")
@SecurityScheme(name = "measurementTimestamp", type = SecuritySchemeType.APIKEY,
in = SecuritySchemeIn.HEADER, paramName = "X-Timestamp", description = "Unix 毫秒时间戳")
@SecurityScheme(name = "measurementNonce", type = SecuritySchemeType.APIKEY,
in = SecuritySchemeIn.HEADER, paramName = "X-Nonce", description = "每次请求唯一的随机串")
@SecurityScheme(name = "measurementSignature", type = SecuritySchemeType.APIKEY,
in = SecuritySchemeIn.HEADER, paramName = "X-Signature", description = "签名串的 HMAC-SHA256 小写十六进制值")
public class MeasurementOpenApiDocumentationConfiguration {
@Bean
GroupedOpenApi measurementExternalApi() {
return GroupedOpenApi.builder()
.group("measurement-openapi")
.displayName("Third-party Measurement OpenAPI / 第三方测量开放接口")
.pathsToMatch("/openapi/v1/inbound-measurements/**")
.build();
}
}
@@ -0,0 +1,34 @@
package com.baiyue.firstleg.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 第三方测量开放接口配置。密钥只能由环境变量或配置中心密文注入。
*/
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "firstleg.measurement-openapi")
public class MeasurementOpenApiProperties {
private boolean enabled = false;
private Duration maxClockSkew = Duration.ofMinutes(5);
private Duration nonceTtl = Duration.ofMinutes(10);
private Map<String, AppCredential> apps = new LinkedHashMap<>();
@Getter
@Setter
public static class AppCredential {
private boolean enabled = true;
private String secret;
private Long tenantId;
private Long subjectId;
private Map<String, Long> warehouseBindings = new LinkedHashMap<>();
}
}
@@ -0,0 +1,39 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.MerchantInboundBillingResponse;
import com.baiyue.firstleg.service.MerchantInboundBillingService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "客户端入库结果与费用")
@RestController
@RequestMapping("/api/merchant/v1/first-leg/orders")
public class MerchantInboundBillingController {
private final MerchantInboundBillingService service;
public MerchantInboundBillingController(MerchantInboundBillingService service) {
this.service = service;
}
@GetMapping("/{orderId}/inbound-billing")
@PreAuthorize("hasAuthority('merchant:order:view')")
@Operation(summary = "查询订单入库结果和最新费用",
description = "客户只读查看客服审核通过后发布的费用,不需要确认费用。")
public ApiResponse<MerchantInboundBillingResponse> find(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long orderId) {
if (user == null || !"MERCHANT".equals(user.appCode()) || user.tenantId() == null) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许客户端访问");
}
return ApiResponse.success(service.find(user.tenantId(), orderId));
}
}
@@ -0,0 +1,75 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.EventResponse;
import com.baiyue.firstleg.security.MeasurementRequestHeaders;
import com.baiyue.firstleg.service.InboundMeasurementOpenApiService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.Pattern;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 第三方设备长宽高测量开放接口。 */
@Validated
@RestController
@RequestMapping("/openapi/v1/inbound-measurements")
@Tag(name = "第三方入库测量 OpenAPI", description = "按入仓码接收长宽高结果,只落测量数据,不审核、计费或入库")
@SecurityRequirement(name = "measurementAppKey")
@SecurityRequirement(name = "measurementTimestamp")
@SecurityRequirement(name = "measurementNonce")
@SecurityRequirement(name = "measurementSignature")
public class OpenApiInboundMeasurementController {
private final InboundMeasurementOpenApiService service;
public OpenApiInboundMeasurementController(InboundMeasurementOpenApiService service) {
this.service = service;
}
@Operation(summary = "提交第三方长宽高测量结果",
description = "签名正文为 METHOD、PATH、时间戳、Nonce、Idempotency-Key、原始 body SHA-256 六行;只保存调用日志、测量批次和明细")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "成功或业务失败,按响应体 code 判断"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "413", description = "请求体超过网关限制"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "415", description = "Content-Type 不支持")
})
@PostMapping
public ApiResponse<EventResponse> receive(
@RequestHeader("X-App-Key") String appKey,
@RequestHeader("X-Timestamp") String timestamp,
@RequestHeader("X-Nonce") String nonce,
@RequestHeader("X-Signature") String signature,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@io.swagger.v3.oas.annotations.parameters.RequestBody(required = true,
description = "原始 UTF-8 JSON;任何空白差异都会改变签名")
@RequestBody String rawBody) {
return ApiResponse.success(service.receive(
new MeasurementRequestHeaders(appKey, timestamp, nonce, signature, idempotencyKey), rawBody));
}
@Operation(summary = "查询第三方测量事件状态",
description = "GET 签名的 Idempotency-Key 行和 body SHA-256 分别为空字符串、空字节 SHA-256")
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200", description = "查询成功或业务失败,按响应体 code 判断"))
@GetMapping("/events/{eventNo}")
public ApiResponse<EventResponse> query(
@Parameter(description = "第三方业务事件号", required = true)
@PathVariable @Pattern(regexp = "[A-Za-z0-9._:-]{1,128}") String eventNo,
@RequestHeader("X-App-Key") String appKey,
@RequestHeader("X-Timestamp") String timestamp,
@RequestHeader("X-Nonce") String nonce,
@RequestHeader("X-Signature") String signature) {
return ApiResponse.success(service.query(eventNo,
new MeasurementRequestHeaders(appKey, timestamp, nonce, signature, null)));
}
}
@@ -0,0 +1,108 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.FbaPricingDtos.CarrierRateVersionRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.CustomerRuleVersionRequest;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.service.FbaMasterDataService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** 平台端客户计费规则和船司 FBA 价格版本管理。 */
@Tag(name = "FBA计费基础资料")
@RestController
@RequestMapping("/api/platform/v1/fba-pricing")
public class PlatformFbaMasterDataController {
private final FbaMasterDataService service;
public PlatformFbaMasterDataController(FbaMasterDataService service) { this.service = service; }
@GetMapping("/customer-rules")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rule:view')")
public ApiResponse<List<CustomerChargeRuleEntity>> rules(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@RequestParam(required = false) String ruleCode) {
return ApiResponse.success(service.listRules(FirstlegScopeGuard.platform(user, tenantId), ruleCode));
}
@GetMapping("/customer-rules/{id}")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rule:view')")
public ApiResponse<CustomerChargeRuleEntity> rule(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long id) {
return ApiResponse.success(service.rule(FirstlegScopeGuard.platform(user, tenantId), id));
}
@PostMapping("/customer-rules/versions")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rule:edit')")
@BusinessAudit(event = "FBA_RULE_VERSION_CREATE", resourceType = "CUSTOMER_CHARGE_RULE",
description = "创建客户FBA计费规则版本")
public ApiResponse<CustomerChargeRuleEntity> createRule(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@Valid @RequestBody CustomerRuleVersionRequest request) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.createRuleVersion(scope, user.userId(), request));
}
@DeleteMapping("/customer-rules/{id}")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rule:edit')")
@BusinessAudit(event = "FBA_RULE_RETIRE", resourceType = "CUSTOMER_CHARGE_RULE", resourceId = "#id",
description = "停用客户FBA计费规则版本")
public ApiResponse<Void> retireRule(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId, @PathVariable long id) {
service.retireRule(FirstlegScopeGuard.platform(user, tenantId), id, user.userId());
return ApiResponse.success(null);
}
@GetMapping("/carrier-rates")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:view')")
public ApiResponse<List<CarrierFbaRateEntity>> rates(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@RequestParam(required = false) String rateCode) {
return ApiResponse.success(service.listRates(FirstlegScopeGuard.platform(user, tenantId), rateCode));
}
@GetMapping("/carrier-rates/{id}")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:view')")
public ApiResponse<CarrierFbaRateEntity> rate(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long id) {
return ApiResponse.success(service.rate(FirstlegScopeGuard.platform(user, tenantId), id));
}
@PostMapping("/carrier-rates/versions")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:edit')")
@BusinessAudit(event = "FBA_RATE_VERSION_CREATE", resourceType = "CARRIER_FBA_RATE",
description = "创建船司FBA价格版本")
public ApiResponse<CarrierFbaRateEntity> createRate(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@Valid @RequestBody CarrierRateVersionRequest request) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.createRateVersion(scope, user.userId(), request));
}
@DeleteMapping("/carrier-rates/{id}")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:edit')")
@BusinessAudit(event = "FBA_RATE_RETIRE", resourceType = "CARRIER_FBA_RATE", resourceId = "#id",
description = "停用船司FBA价格版本")
public ApiResponse<Void> retireRate(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId, @PathVariable long id) {
service.retireRate(FirstlegScopeGuard.platform(user, tenantId), id, user.userId());
return ApiResponse.success(null);
}
}
@@ -0,0 +1,90 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.FbaPricingDtos.CalculateRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest;
import com.baiyue.firstleg.service.FbaPricingService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RestController;
/** 平台端入库单 FBA 费用试算、确认和发送接口。 */
@Tag(name = "入库FBA计费")
@RestController
@RequestMapping("/api/platform/v1/inbounds/{inboundId}/fees")
public class PlatformFbaPricingController {
private final FbaPricingService service;
public PlatformFbaPricingController(FbaPricingService service) { this.service = service; }
@PostMapping("/calculate")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:calculate')")
@BusinessAudit(event = "FBA_FEE_CALCULATE", resourceType = "INBOUND", resourceId = "#inboundId",
description = "试算FBA费用")
public ApiResponse<FeeSnapshotResponse> calculate(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody(required = false) CalculateRequest request) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.calculate(scope, user.userId(), inboundId,
request == null ? null : request.pricingAt(), idempotencyKey));
}
@PostMapping("/confirm")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:confirm')")
@BusinessAudit(event = "FBA_FEE_CONFIRM", resourceType = "INBOUND", resourceId = "#inboundId",
description = "确认FBA费用快照")
public ApiResponse<FeeSnapshotResponse> confirm(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.confirm(scope, user.userId(), inboundId, idempotencyKey));
}
@PostMapping("/send")
@PreAuthorize("hasAuthority('firstleg:inbound:send-fee')")
@BusinessAudit(event = "FBA_FEE_SEND", resourceType = "INBOUND", resourceId = "#inboundId",
description = "发送FBA费用快照并进入待托盘状态")
public ApiResponse<FeeSnapshotResponse> send(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.send(scope, user.userId(), inboundId, idempotencyKey));
}
@GetMapping("/latest")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:view')")
public ApiResponse<FeeSnapshotResponse> latest(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId) {
return ApiResponse.success(service.latest(FirstlegScopeGuard.platform(user, tenantId), inboundId));
}
@PutMapping("/published")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:adjust')")
@BusinessAudit(event = "FBA_FEE_ADJUST", resourceType = "INBOUND", resourceId = "#inboundId",
description = "调整已发布入库费用,不回退入库状态")
public ApiResponse<FeeSnapshotResponse> adjustPublished(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody AdjustFeeRequest request) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.adjustPublished(
scope, user.userId(), inboundId, request, idempotencyKey));
}
}
@@ -0,0 +1,134 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
import com.baiyue.firstleg.service.InboundMeasurementService;
import com.baiyue.firstleg.service.MeasurementSubmissionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** 平台后台入库测量录入、提交和 OCR 重量结果维护接口。 */
@Tag(name = "平台入库测量", description = "维护测量草稿、箱规明细和可追溯 OCR 重量结果。")
@RestController
@RequestMapping("/api/platform/v1/first-leg/inbounds/{inboundId}")
public class PlatformInboundMeasurementController {
private final InboundMeasurementService service;
private final MeasurementSubmissionService submission;
public PlatformInboundMeasurementController(InboundMeasurementService service,
MeasurementSubmissionService submission) {
this.service = service;
this.submission = submission;
}
@Operation(summary = "查询入库测量详情", description = "返回原单、实测箱规、审核快照和 OCR 重量结果。")
@GetMapping("/measurement")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:view')")
public ApiResponse<InboundMeasurementResponses.Detail> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(service.detail(tenantId, inboundId));
}
@BusinessAudit(event = "INBOUND_MEASUREMENT_DRAFT_SAVE", resourceType = "INBOUND",
resourceId = "#inboundId", description = "保存入库测量草稿")
@Operation(summary = "保存测量草稿", description = "保存人工测量批次和服务端计算明细,不推进入库状态。")
@PutMapping("/measurement/draft")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:edit')")
public ApiResponse<InboundMeasurementResponses.DraftSaved> saveDraft(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@Valid @RequestBody InboundMeasurementRequests.SaveDraftRequest request) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(service.saveDraft(tenantId, inboundId, user.userId(), request));
}
@BusinessAudit(event = "INBOUND_MEASUREMENT_SUBMIT", resourceType = "INBOUND",
resourceId = "#inboundId", description = "确认测量并提交审核")
@Operation(summary = "确认测量并提交审核",
description = "校验测量图片和全部 OCR 重量后,最多推进到 MEASURE_PENDING。")
@PostMapping("/measurement/submit")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:submit')")
public ApiResponse<InboundMeasurementResponses.MeasurementSubmitted> submit(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@Valid @RequestBody InboundMeasurementRequests.SubmitRequest request) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(submission.submit(tenantId, inboundId, user.userId(), request));
}
@BusinessAudit(event = "INBOUND_WEIGHT_OCR_RECORD", resourceType = "INBOUND",
resourceId = "#inboundId", description = "登记称重图片 OCR 原值")
@Operation(summary = "登记 OCR 重量结果", description = "按图片文件 ID 保存不可覆盖的 OCR 原值。")
@PostMapping("/weight-results")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:edit')")
public ApiResponse<InboundMeasurementResponses.WeightResult> createWeightResult(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@Valid @RequestBody InboundMeasurementRequests.CreateWeightResultRequest request) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(service.createWeightResult(tenantId, inboundId, user.userId(), request));
}
@BusinessAudit(event = "INBOUND_WEIGHT_OCR_CORRECT", resourceType = "INBOUND_WEIGHT_RESULT",
resourceId = "#resultId", description = "人工修正 OCR 重量")
@Operation(summary = "人工修正 OCR 重量", description = "保留 OCR 原值并新增不可变修正版本。")
@PutMapping("/weight-results/{resultId}")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:edit')")
public ApiResponse<InboundMeasurementResponses.WeightResult> correctWeightResult(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@PathVariable long resultId,
@Valid @RequestBody InboundMeasurementRequests.CorrectWeightResultRequest request) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(service.correctWeightResult(
tenantId, inboundId, resultId, user.userId(), user.username(), request));
}
@BusinessAudit(event = "INBOUND_WEIGHT_OCR_CONFIRM", resourceType = "INBOUND_WEIGHT_RESULT",
resourceId = "#resultId", description = "确认 OCR 重量并累加实际总毛重")
@Operation(summary = "确认 OCR 重量", description = "统一换算为 KG 并重算所有已确认图片的总重量。")
@PostMapping("/weight-results/{resultId}/confirm")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:confirm')")
public ApiResponse<InboundMeasurementResponses.WeightResult> confirmWeightResult(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@PathVariable long resultId,
@Valid @RequestBody InboundMeasurementRequests.ConfirmWeightResultRequest request) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(service.confirmWeightResult(
tenantId, inboundId, resultId, user.userId(), user.username(), request));
}
private static void requirePlatformTenant(AuthenticatedUser user, long tenantId) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
if (tenantId <= 0 || user.userId() == null || user.userId() <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户和操作人");
}
}
}
@@ -0,0 +1,122 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
import com.baiyue.firstleg.dto.MeasurementReviewRequest;
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
import com.baiyue.firstleg.service.MeasurementReviewService;
import com.baiyue.firstleg.service.AutomaticInboundCompletionService;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** 平台端入库测量审核、异常放行和审核历史接口。 */
@Tag(name = "平台入库测量审核")
@RestController
@RequestMapping("/api/platform/v1/inbounds")
public class PlatformInboundMeasurementReviewController {
private final MeasurementReviewService service;
private final AutomaticInboundCompletionService automaticCompletion;
public PlatformInboundMeasurementReviewController(MeasurementReviewService service,
AutomaticInboundCompletionService automaticCompletion) {
this.service = service;
this.automaticCompletion = automaticCompletion;
}
@BusinessAudit(event = "INBOUND_MEASURE_APPROVE", resourceType = "INBOUND",
resourceId = "#inboundId", description = "测量审核通过")
@Operation(summary = "测量审核通过",
description = "客服审核通过后自动计费、发布费用给客户并完成入库;客户无需确认费用。")
@PostMapping("/{inboundId}/measurements/approve")
@PreAuthorize("hasAuthority('firstleg:inbound:measurement-review')")
public ApiResponse<FeeSnapshotResponse> approve(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@Parameter(required = true, description = "同键同请求重试返回首次成功结果")
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody MeasurementReviewRequest request) {
validateScope(user, tenantId);
service.approve(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request);
return ApiResponse.success(automaticCompletion.completeApproved(
tenantId, inboundId, user.userId(), user.username(), request.measurementId(),
request.expectedVersion() + 1));
}
@BusinessAudit(event = "INBOUND_MEASURE_REJECT", resourceType = "INBOUND",
resourceId = "#inboundId", description = "测量审核驳回")
@Operation(summary = "测量审核驳回",
description = "驳回原因必填,只退回测量数据修改;已确认的托盘和库位占用保持不动。")
@PostMapping("/{inboundId}/measurements/reject")
@PreAuthorize("hasAuthority('firstleg:inbound:measurement-review')")
public ApiResponse<Void> reject(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody MeasurementReviewRequest request) {
validateScope(user, tenantId);
service.reject(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request);
return ApiResponse.success(null);
}
@BusinessAudit(event = "INBOUND_MEASURE_EXCEPTION_RELEASE", resourceType = "INBOUND",
resourceId = "#inboundId", description = "测量驳回异常放行")
@Operation(summary = "测量驳回异常放行",
description = "Nacos 开关开启、有异常放行权限且原因非空时,才可从 MEASURE_REJECTED 推进至 WAIT_PRICING。")
@PostMapping("/{inboundId}/measurements/exception-release")
@PreAuthorize("hasAuthority('firstleg:inbound:exception-release')")
public ApiResponse<Void> exceptionRelease(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody ExceptionReleaseRequest request) {
validateScope(user, tenantId);
service.exceptionRelease(
tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request);
return ApiResponse.success(null);
}
@Operation(summary = "查询测量审核历史")
@GetMapping("/{inboundId}/measurement-review-history")
@PreAuthorize("hasAuthority('firstleg:inbound:measurement-review-history')")
public ApiResponse<PageResult<MeasureReviewRecordEntity>> history(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@RequestParam(defaultValue = "1") @Min(1) long page,
@RequestParam(defaultValue = "20") @Min(1) @Max(200) long size) {
validateScope(user, tenantId);
return ApiResponse.success(service.history(tenantId, inboundId, page, size));
}
private static void validateScope(AuthenticatedUser user, long tenantId) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
if (tenantId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户");
}
}
}
@@ -0,0 +1,67 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.InboundOrderDetail;
import com.baiyue.firstleg.dto.InboundOrderQuery;
import com.baiyue.firstleg.dto.InboundOrderSummary;
import com.baiyue.firstleg.service.InboundOrderQueryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** 平台端入库单列表和详情只读接口。 */
@Tag(name = "平台入库单", description = "二期入库单列表、页签、筛选和只读聚合详情")
@RestController
@RequestMapping("/api/platform/inbounds")
public class PlatformInboundOrderController {
private final InboundOrderQueryService service;
public PlatformInboundOrderController(InboundOrderQueryService service) {
this.service = service;
}
@Operation(summary = "分页查询入库单", description = "按目标租户和二期状态页签分页查询入库单快照。")
@GetMapping
@PreAuthorize("hasAuthority('platform:firstleg:inbound:list')")
public ApiResponse<PageResult<InboundOrderSummary>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@Valid InboundOrderQuery query) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(service.page(tenantId, query));
}
@Operation(summary = "查询入库单详情", description = "查询入库单、运输、托盘库位、文件、备注和状态历史快照。")
@GetMapping("/{inboundId}")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:view')")
public ApiResponse<InboundOrderDetail> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId) {
requirePlatformTenant(user, tenantId);
if (inboundId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "入库单ID必须大于零");
}
return ApiResponse.success(service.detail(tenantId, inboundId));
}
private static void requirePlatformTenant(AuthenticatedUser user, long tenantId) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
if (tenantId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户");
}
}
}
@@ -0,0 +1,21 @@
package com.baiyue.firstleg.domain;
import java.util.List;
/** 入库列表页签及其流程状态映射。 */
public enum InboundListTab {
WAIT_MEASURE(List.of("WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED")),
WAIT_REVIEW(List.of("MEASURE_PENDING")),
WAIT_FEE_SEND(List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING")),
INBOUNDED(List.of("INBOUNDED"));
private final List<String> processStatuses;
InboundListTab(List<String> processStatuses) {
this.processStatuses = processStatuses;
}
public List<String> processStatuses() {
return processStatuses;
}
}
@@ -0,0 +1,14 @@
package com.baiyue.firstleg.domain;
import java.util.Set;
/** 二期入库主流程状态,不包含原型中的“待客户确认”。 */
public final class InboundProcessStatus {
public static final Set<String> VALUES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
"WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED", "VOIDED"
);
private InboundProcessStatus() {
}
}
@@ -0,0 +1,15 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
/** 测量驳回后的异常放行请求。 */
public record ExceptionReleaseRequest(
@NotNull @Positive Long measurementId,
@NotNull @PositiveOrZero Integer expectedVersion,
@NotBlank @Size(max = 1000) String reason
) {
}
@@ -0,0 +1,77 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 二期 FBA 计费请求与只读快照响应。 */
public final class FbaPricingDtos {
private FbaPricingDtos() {}
public record CustomerRuleVersionRequest(
@NotBlank @Size(max = 50) String ruleCode,
@NotNull @Min(1) Long customerId,
@NotBlank @Size(max = 128) String customerName,
@NotBlank @Size(max = 32) String businessType,
@NotBlank @Pattern(regexp = "KG|CBM") String chargeMode,
@NotBlank @Pattern(regexp = "TAX_INCLUDED|TAX_EXCLUDED") String taxMode,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal conversionFactor,
@NotNull @Min(0) @Max(4) Integer roundingScale,
@NotBlank @Pattern(regexp = "UP|DOWN|CEILING|FLOOR|HALF_UP|HALF_DOWN|HALF_EVEN") String roundingMode,
@NotBlank @Pattern(regexp = "[A-Z]{3}") String currency,
@NotNull LocalDateTime effectiveFrom,
LocalDateTime effectiveTo,
@Size(max = 500) String remark
) {}
public record CarrierRateVersionRequest(
@NotBlank @Size(max = 50) String rateCode,
@NotNull @Min(1) Long originWarehouseId,
@NotBlank @Size(max = 128) String originWarehouseName,
@NotBlank @Size(max = 32) String businessType,
@NotBlank @Size(max = 64) String channelCode,
@NotNull @Min(1) Long carrierId,
@NotBlank @Size(max = 128) String carrierName,
@NotBlank @Size(max = 32) String destinationCode,
@NotBlank @Pattern(regexp = "KG|CBM") String chargeUnit,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal unitPrice,
@NotBlank @Pattern(regexp = "[A-Z]{3}") String currency,
@NotNull @DecimalMin("0") BigDecimal additionalFee,
@NotNull LocalDateTime effectiveFrom,
LocalDateTime effectiveTo,
@Size(max = 500) String remark
) {}
public record CalculateRequest(LocalDateTime pricingAt) {}
public record AdjustFeeRequest(
@NotNull @DecimalMin("0.00") BigDecimal subtotalAmount,
@NotNull @DecimalMin("0.00") BigDecimal additionalAmount,
@NotBlank @Size(max = 500) String reason
) {}
public record FeeSnapshotResponse(
Long feeId, String feeNo, Integer feeVersion, String feeStatus,
Long inboundId, String inboundCode,
Long chargeRuleId, String ruleCode, Integer chargeRuleVersion,
Long carrierRateId, String rateCode, Integer carrierRateVersion,
Long customerId, String customerName, Long originWarehouseId, String originWarehouseName,
String businessType, String channelCode, Long carrierId, String carrierName,
String destinationCode, String taxMode, String chargeMode,
BigDecimal conversionFactor, Integer roundingScale, String roundingMode,
BigDecimal actualWeightKg, BigDecimal actualVolumeCbm,
BigDecimal convertedQuantity, BigDecimal chargeableQuantity, String chargeUnit,
BigDecimal unitPrice, BigDecimal subtotalAmount, BigDecimal additionalAmount,
BigDecimal totalAmount, String currency,
LocalDateTime rateEffectiveFrom, LocalDateTime rateEffectiveTo,
LocalDateTime calculatedAt, LocalDateTime confirmedAt, LocalDateTime sentAt,
String nextProcessStatus
) {}
}
@@ -0,0 +1,82 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.util.List;
/**
* 平台端入库测量写请求。计算字段只由服务端生成,避免前端篡改汇总结果。
*/
public final class InboundMeasurementRequests {
private InboundMeasurementRequests() {
}
public record SaveDraftRequest(
Long measurementId,
@NotNull @PositiveOrZero Integer version,
@Size(max = 64) String originalDocumentNo,
@DecimalMin("0.000") @Digits(integer = 15, fraction = 3) BigDecimal originalGrossWeightKg,
@DecimalMin("0.0000") @Digits(integer = 14, fraction = 4) BigDecimal originalVolumeCbm,
@Size(max = 255) String actualGoodsName,
@NotEmpty @Size(max = 500) List<@Valid ItemRequest> items
) {
}
public record ItemRequest(
@NotBlank @Size(max = 32) String itemNo,
@NotBlank @Size(max = 64) String boxSpecification,
@NotNull @Positive @Max(1000000) Integer packageCount,
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal lengthCm,
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal widthCm,
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal heightCm,
@NotNull @DecimalMin("0.001") @Digits(integer = 15, fraction = 3) BigDecimal unitWeightKg,
@Positive Long intelligentImageFileId,
@Size(max = 500) String overLimitWarning,
@DecimalMin("0.000") @Digits(integer = 9, fraction = 3) BigDecimal outerLengthCm,
@DecimalMin("0.000") @Digits(integer = 9, fraction = 3) BigDecimal outerWidthCm,
@DecimalMin("0.000") @Digits(integer = 9, fraction = 3) BigDecimal outerHeightCm,
@Size(max = 500) String remark
) {
}
public record SubmitRequest(
@NotNull @Positive Long measurementId,
@NotNull @PositiveOrZero Integer version
) {
}
public record CreateWeightResultRequest(
@NotNull @Positive Long fileId,
@Size(max = 64) String ocrProvider,
@Size(max = 1000) String ocrRawText,
@DecimalMin("0.000") @Digits(integer = 15, fraction = 3) BigDecimal ocrWeightValue,
@Size(max = 8) String ocrWeightUnit,
@DecimalMin("0.00000") @DecimalMax("1.00000")
@Digits(integer = 1, fraction = 5) BigDecimal confidence
) {
}
public record CorrectWeightResultRequest(
@NotNull @PositiveOrZero Integer version,
@NotNull @DecimalMin("0.001") @Digits(integer = 15, fraction = 3) BigDecimal adjustedWeightValue,
@NotBlank @Size(max = 8) String adjustedWeightUnit,
@NotBlank @Size(max = 500) String adjustmentReason
) {
}
public record ConfirmWeightResultRequest(
@NotNull @PositiveOrZero Integer version
) {
}
}
@@ -0,0 +1,101 @@
package com.baiyue.firstleg.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 入库测量详情响应,覆盖原单、实测明细、审核快照和 OCR 重量维护字段。
*/
public final class InboundMeasurementResponses {
private InboundMeasurementResponses() {
}
public record Detail(
Long inboundId,
String inboundCode,
String companyName,
String channelCode,
String channelName,
String processStatus,
String measureReviewStatus,
@Schema(description = "测量完成后的推荐托盘类型,仅为建议,不代表已分配托盘",
allowableValues = "STANDARD", example = "STANDARD")
String recommendedPalletType,
String reviewReason,
Long reviewerId,
String reviewerName,
Long measurementId,
String sourceType,
String measurementStatus,
Integer version,
String originalDocumentNo,
BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm,
Integer actualPackageCount,
BigDecimal actualGrossWeightKg,
BigDecimal actualVolumeCbm,
BigDecimal volumeWeightKg,
String actualGoodsName,
List<Item> items,
List<WeightResult> weightResults
) {
}
public record Item(
Long id,
String itemNo,
String boxSpecification,
Integer packageCount,
BigDecimal lengthCm,
BigDecimal widthCm,
BigDecimal heightCm,
BigDecimal girthCm,
BigDecimal unitWeightKg,
BigDecimal unitVolumeCbm,
BigDecimal totalWeightKg,
BigDecimal totalVolumeCbm,
BigDecimal volumeWeightKg,
Long intelligentImageFileId,
String overLimitWarning,
BigDecimal outerLengthCm,
BigDecimal outerWidthCm,
BigDecimal outerHeightCm,
String remark
) {
}
public record WeightResult(
Long id,
Long fileId,
String ocrProvider,
String ocrRawText,
BigDecimal ocrWeightValue,
String ocrWeightUnit,
BigDecimal confidence,
BigDecimal adjustedWeightValue,
String adjustedWeightUnit,
BigDecimal finalWeightKg,
String recognitionStatus,
String adjustmentReason,
Long adjustedBy,
LocalDateTime adjustedAt,
Long confirmedBy,
LocalDateTime confirmedAt,
Integer version
) {
}
public record DraftSaved(Long measurementId, Integer version, String status) {
}
public record MeasurementSubmitted(Long measurementId, Integer version, String processStatus,
String measureReviewStatus,
@Schema(description = "测量完成后的推荐托盘类型,仅为建议,不代表已分配托盘",
allowableValues = "STANDARD", example = "STANDARD")
String recommendedPalletType,
Long feeId, String feeNo, String feeStatus) {
}
}
@@ -0,0 +1,35 @@
package com.baiyue.firstleg.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/** 入库单只读聚合详情。 */
public record InboundOrderDetail(
InboundOrderSummary summary,
String publicRemark,
List<PalletLocationSummary> palletLocations,
List<FileSummary> files,
List<InternalNoteSummary> internalNotes,
List<StatusHistorySummary> statusHistory
) {
public record PalletLocationSummary(
Integer allocationNo, Long warehouseId, Long palletId, String palletCode,
BigDecimal palletCapacityCbm, BigDecimal allocatedVolumeCbm, Integer allocatedPackageCount,
Long locationId, String locationCode, String reservationNo, String allocationStatus) {
}
public record FileSummary(
Long fileId, String fileType, String fileNameSnapshot, String visibility,
Integer fileVersion, Long uploadedBy, LocalDateTime uploadedAt) {
}
public record InternalNoteSummary(
Long id, String content, Long createdBy, String createdByName, LocalDateTime createdAt) {
}
public record StatusHistorySummary(
String fromStatus, String toStatus, String action, String reason,
Long operatorId, String operatorName, LocalDateTime occurredAt) {
}
}
@@ -0,0 +1,42 @@
package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.domain.InboundListTab;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
/** 入库单分页筛选条件。 */
@Schema(name = "InboundOrderQuery", description = "入库单列表筛选和页签条件")
public record InboundOrderQuery(
@Size(max = 40) String inboundCode,
@Size(max = 32) String orderNo,
@Size(max = 24) String processStatus,
@Size(max = 24) String orderStatus,
@Size(max = 24) String measureReviewStatus,
@Size(max = 24) String feeStatus,
@Size(max = 24) String feeReviewStatus,
InboundListTab tab,
LocalDateTime expectedArrivalFrom,
LocalDateTime expectedArrivalTo,
Long customerId,
@Size(max = 128) String customerName,
Long companyId,
Long warehouseId,
Long deliveryWarehouseId,
@Size(max = 32) String destinationCode,
Boolean holdFlag,
@Min(1) long page,
@Min(1) @Max(200) long size
) {
public InboundOrderQuery {
if (page == 0) {
page = 1;
}
if (size == 0) {
size = 20;
}
}
}
@@ -0,0 +1,60 @@
package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 入库列表行,所有名称和运输信息均为业务快照。 */
public record InboundOrderSummary(
Long id, Long orderId, String orderNo, String inboundCode,
String processStatus, String orderStatusSnapshot, String measureReviewStatus,
String feeStatus, String feeReviewStatus, String notificationStatus,
LocalDateTime expectedArrivalAt,
Long customerId, String customerName, Long companyId, String companyName,
Long warehouseId, String warehouseName,
Long deliveryWarehouseId, String deliveryWarehouseName,
String businessType, String channelCode, String channelName,
String taxMode, String containerMode, Boolean customsInspection,
String destinationType, Long destinationWarehouseId, String destinationCode, String destinationName,
Boolean holdFlag, String currentHoldReason,
Integer totalPackageCount, BigDecimal actualGrossWeightKg, BigDecimal actualNetWeightKg,
BigDecimal actualVolumeCbm, BigDecimal volumeWeightKg,
BigDecimal chargeableWeightKg, BigDecimal chargeableVolumeCbm, Integer palletCount,
String locationCodes,
LocalDateTime measureCompletedAt, LocalDateTime inboundedAt,
String expressCompanyCode, String expressCompanyName, String expressTrackingNo,
String loadingStatus, String containerNo, String cabinetNo,
String vesselName, String voyageNo, LocalDateTime sailingAt, LocalDateTime portArrivalAt,
LocalDateTime createdAt, LocalDateTime updatedAt
) {
public static InboundOrderSummary from(
InboundOrderEntity order, InboundTransportSnapshotEntity transport, String locationCodes) {
return new InboundOrderSummary(
order.getId(), order.getOrderId(), order.getOrderNo(), order.getInboundCode(),
order.getProcessStatus(), order.getOrderStatusSnapshot(), order.getMeasureReviewStatus(),
order.getFeeStatus(), order.getFeeReviewStatus(), order.getNotificationStatus(),
order.getExpectedArrivalAt(), order.getCustomerId(), order.getCustomerName(),
order.getCompanyId(), order.getCompanyName(), order.getWarehouseId(), order.getWarehouseName(),
order.getDeliveryWarehouseId(), order.getDeliveryWarehouseName(), order.getBusinessType(),
order.getChannelCode(), order.getChannelName(), order.getTaxMode(), order.getContainerMode(),
order.getCustomsInspection(), order.getDestinationType(), order.getDestinationWarehouseId(),
order.getDestinationCode(), order.getDestinationName(), order.getHoldFlag(),
order.getCurrentHoldReason(), order.getTotalPackageCount(), order.getActualGrossWeightKg(),
order.getActualNetWeightKg(), order.getActualVolumeCbm(), order.getVolumeWeightKg(),
order.getChargeableWeightKg(), order.getChargeableVolumeCbm(), order.getPalletCount(),
locationCodes,
order.getMeasureCompletedAt(), order.getInboundedAt(), order.getExpressCompanyCode(),
order.getExpressCompanyName(), order.getExpressTrackingNo(),
transport == null ? null : transport.getLoadingStatus(),
transport == null ? null : transport.getContainerNo(),
transport == null ? null : transport.getCabinetNo(),
transport == null ? null : transport.getVesselName(),
transport == null ? null : transport.getVoyageNo(),
transport == null ? null : transport.getSailingAt(),
transport == null ? null : transport.getPortArrivalAt(),
order.getCreatedAt(), order.getUpdatedAt()
);
}
}
@@ -0,0 +1,46 @@
package com.baiyue.firstleg.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 测量领域统一写命令。后台 DTO 与第三方开放 API DTO 都应先适配为该命令。
* 该命令的持久化过程不改变入库单流程状态。
*/
public record MeasurementBatchCommand(
long tenantId,
long inboundId,
Long measurementId,
int expectedVersion,
String sourceType,
String sourceCode,
String externalEventNo,
String deviceCode,
String warehouseCode,
LocalDateTime measuredAt,
String rawPayloadJson,
String originalDocumentNo,
BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm,
String actualGoodsName,
long operatorId,
List<Item> items
) {
public record Item(
String itemNo,
String boxSpecification,
int packageCount,
BigDecimal lengthCm,
BigDecimal widthCm,
BigDecimal heightCm,
BigDecimal unitWeightKg,
Long intelligentImageFileId,
String overLimitWarning,
BigDecimal outerLengthCm,
BigDecimal outerWidthCm,
BigDecimal outerHeightCm,
String remark
) {
}
}
@@ -0,0 +1,14 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
/** 测量审核请求,expectedVersion 用于拒绝过期页面提交。 */
public record MeasurementReviewRequest(
@NotNull @Positive Long measurementId,
@NotNull @PositiveOrZero Integer expectedVersion,
@Size(max = 1000) String reason
) {
}
@@ -0,0 +1,7 @@
package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
public record MerchantInboundBillingResponse(InboundOrderDetail inbound,
FeeSnapshotResponse latestFee) {
}
@@ -0,0 +1,65 @@
package com.baiyue.firstleg.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
/** 第三方长宽高测量开放接口 DTO。 */
public final class OpenApiMeasurementDtos {
private OpenApiMeasurementDtos() {
}
@Schema(name = "InboundMeasurementCallbackRequest", description = "按入仓码提交的第三方长宽高测量结果")
public record CallbackRequest(
@NotBlank @Size(max = 128)
@Pattern(regexp = "[A-Za-z0-9._:-]+")
@Schema(description = "第三方业务事件号,同一 appKey 下唯一", example = "M202608010001")
String eventNo,
@NotBlank @Size(max = 50)
@Schema(description = "已绑定到应用和租户的仓库编码", example = "CN-YW-01")
String warehouseCode,
@NotBlank @Size(max = 40)
@Schema(description = "入仓码,不接受内部数据库主键", example = "RK202608010001")
String inboundCode,
@NotBlank @Size(max = 64)
@Schema(description = "测量设备编码", example = "DEVICE-01")
String deviceCode,
@NotNull
@Schema(description = "设备测量时间,必须包含时区", example = "2026-08-01T10:30:00+08:00")
OffsetDateTime measuredAt,
@NotEmpty @Size(max = 500) List<@Valid Item> items) {
}
@Schema(name = "InboundMeasurementItem", description = "相同箱规的测量明细")
public record Item(
@NotBlank @Size(max = 32)
@Pattern(regexp = "[A-Za-z0-9._:-]+") String itemNo,
@NotNull @DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal lengthCm,
@NotNull @DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal widthCm,
@NotNull @DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal heightCm,
@NotNull @Min(1) @Max(1000000) Integer quantity,
@Size(max = 2048) String sourceImageUrl,
@NotBlank @Size(max = 64) String boxSpecification) {
}
@Schema(name = "InboundMeasurementEventResponse", description = "第三方测量事件处理结果")
public record EventResponse(
String eventNo,
String inboundCode,
boolean accepted,
String status,
OffsetDateTime receivedAt) {
}
}
@@ -0,0 +1,38 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 船司 FBA 目的仓价格的不可变版本。 */
@Getter
@Setter
@TableName("fl_carrier_fba_rate")
public class CarrierFbaRateEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String rateCode;
private Integer rateVersion;
private Long originWarehouseId;
private String originWarehouseName;
private String businessType;
private String channelCode;
private Long carrierId;
private String carrierName;
private String destinationCode;
private String chargeUnit;
private BigDecimal unitPrice;
private String currency;
private BigDecimal additionalFee;
private LocalDateTime effectiveFrom;
private LocalDateTime effectiveTo;
private String status;
private String remark;
}
@@ -0,0 +1,36 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 客户 FBA 计费规则的不可变版本。 */
@Getter
@Setter
@TableName("fl_customer_charge_rule")
public class CustomerChargeRuleEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String ruleCode;
private Integer ruleVersion;
private Long customerId;
private String customerName;
private String businessType;
private String chargeMode;
private String taxMode;
private BigDecimal conversionFactor;
private Integer roundingScale;
private String roundingMode;
private String currency;
private LocalDateTime effectiveFrom;
private LocalDateTime effectiveTo;
private String status;
private String remark;
}
@@ -0,0 +1,67 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 已保存全部计算输入的入库费用快照。 */
@Getter
@Setter
@TableName("fl_inbound_fee")
public class InboundFeeEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private String feeNo;
private Integer feeVersion;
private Long chargeRuleId;
private Integer chargeRuleVersion;
private Long carrierRateId;
private Integer carrierRateVersion;
private String ruleCode;
private Long customerId;
private String customerName;
private String businessType;
private String taxMode;
private Integer roundingScale;
private String roundingMode;
private LocalDateTime ruleEffectiveFrom;
private LocalDateTime ruleEffectiveTo;
private String rateCode;
private Long originWarehouseId;
private String originWarehouseName;
private String channelCode;
private Long carrierId;
private String carrierName;
private String destinationCode;
private LocalDateTime rateEffectiveFrom;
private LocalDateTime rateEffectiveTo;
private String chargeMode;
private BigDecimal conversionFactor;
private BigDecimal actualWeightKg;
private BigDecimal actualVolumeCbm;
private BigDecimal convertedQuantity;
private BigDecimal chargeableQuantity;
private String chargeUnit;
private String currency;
private BigDecimal subtotalAmount;
private BigDecimal additionalAmount;
private BigDecimal totalAmount;
private String feeStatus;
private String reviewStatus;
private Boolean exceptionRelease;
private Long calculatedBy;
private LocalDateTime calculatedAt;
private Long confirmedBy;
private LocalDateTime confirmedAt;
private Long sentBy;
private LocalDateTime sentAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,32 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 费用快照明细。 */
@Getter
@Setter
@TableName("fl_inbound_fee_item")
public class InboundFeeItemEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long feeId;
private Integer itemNo;
private String feeCode;
private String feeName;
private BigDecimal quantity;
private String unit;
private BigDecimal unitPrice;
private BigDecimal amount;
private String currency;
private Boolean automaticFlag;
private String remark;
private LocalDateTime createdAt;
}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 入库单与文件中心对象的分类关系。 */
@Getter
@Setter
@TableName("fl_inbound_file")
public class InboundFileEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private Long fileId;
private String fileType;
private String fileNameSnapshot;
private String visibility;
private Integer fileVersion;
private Long uploadedBy;
private LocalDateTime uploadedAt;
@TableLogic
private Boolean deleted;
}
@@ -0,0 +1,29 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 入库写操作幂等记录。 */
@Getter
@Setter
@TableName("fl_inbound_idempotency")
public class InboundIdempotencyEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String operation;
private Long operatorId;
private Long inboundId;
private String idempotencyKey;
private String requestHash;
private String status;
private String resultJson;
private LocalDateTime completedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 客服内部备注历史,仅平台详情可见。 */
@Getter
@Setter
@TableName("fl_inbound_internal_note")
public class InboundInternalNoteEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private String content;
private Long createdBy;
private String createdByName;
private LocalDateTime createdAt;
}
@@ -0,0 +1,43 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 后台和第三方共同使用的测量批次。 */
@Getter
@Setter
@TableName("fl_inbound_measurement")
public class InboundMeasurementEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private String sourceType;
private String sourceCode;
private String externalEventNo;
private String deviceCode;
private String warehouseCode;
private LocalDateTime measuredAt;
private String status;
private String rawPayloadJson;
private String originalDocumentNo;
private BigDecimal originalGrossWeightKg;
private BigDecimal originalVolumeCbm;
private String actualGoodsName;
private Integer totalPackageCount;
private BigDecimal totalVolumeRawCbm;
private BigDecimal totalVolumeCbm;
private BigDecimal volumeWeightKg;
private Long createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@Version
private Integer version;
}
@@ -0,0 +1,43 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 测量批次箱规明细,计算字段由服务端生成。 */
@Getter
@Setter
@TableName("fl_inbound_measurement_item")
public class InboundMeasurementItemEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long measurementId;
private String itemNo;
private String boxSpecification;
private Integer packageCount;
private BigDecimal lengthCm;
private BigDecimal widthCm;
private BigDecimal heightCm;
private BigDecimal girthCm;
private BigDecimal unitWeightKg;
private BigDecimal unitVolumeCbm;
private BigDecimal volumeRawCbm;
private BigDecimal volumeCbm;
private BigDecimal totalWeightKg;
private BigDecimal totalVolumeCbm;
private BigDecimal volumeWeightKg;
private String calculationRuleVersion;
private Long sourceImageFileId;
private String overLimitWarning;
private BigDecimal outerLengthCm;
private BigDecimal outerWidthCm;
private BigDecimal outerHeightCm;
private String remark;
private LocalDateTime createdAt;
}
@@ -0,0 +1,67 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 二期入库单聚合根及列表查询快照。 */
@Getter
@Setter
@TableName("fl_inbound_order")
public class InboundOrderEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long taskId;
private Long orderId;
private String orderNo;
private String inboundCode;
private String processStatus;
private String orderStatusSnapshot;
private String measureReviewStatus;
private String feeStatus;
private String feeReviewStatus;
private String notificationStatus;
private LocalDateTime expectedArrivalAt;
private Long customerId;
private String customerName;
private Long companyId;
private String companyName;
private Long warehouseId;
private String warehouseName;
private Long deliveryWarehouseId;
private String deliveryWarehouseName;
private String businessType;
private String channelCode;
private String channelName;
private String taxMode;
private String containerMode;
private Boolean customsInspection;
private String destinationType;
private Long destinationWarehouseId;
private String destinationCode;
private String destinationName;
private Boolean holdFlag;
private String currentHoldReason;
private Integer totalPackageCount;
private BigDecimal actualGrossWeightKg;
private BigDecimal chargeableWeightKg;
private BigDecimal actualVolumeCbm;
private BigDecimal chargeableVolumeCbm;
private Integer palletCount;
private BigDecimal volumeWeightKg;
private BigDecimal actualNetWeightKg;
private LocalDateTime measureCompletedAt;
private LocalDateTime inboundedAt;
private String expressCompanyCode;
private String expressCompanyName;
private String expressTrackingNo;
private String publicRemark;
private String createdByName;
}
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
/** 开放接口只读使用的入库单最小投影,禁止通过该实体更新主单。 */
@Getter
@Setter
@TableName("fl_inbound_order")
public class InboundOrderLookupEntity {
@TableId
private Long id;
private Long tenantId;
private String inboundCode;
private Long warehouseId;
private String processStatus;
private Integer deleted;
}
@@ -0,0 +1,26 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 入库完成事件 Outbox 记录。 */
@Getter
@Setter
@TableName("fl_inbound_outbox")
public class InboundOutboxEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String aggregateType;
private Long aggregateId;
private String eventType;
private String payloadJson;
private String publishStatus;
private Integer retryCount;
private LocalDateTime createdAt;
}
@@ -0,0 +1,34 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 头程侧托盘和库位分配结果快照。 */
@Getter
@Setter
@TableName("fl_inbound_pallet_allocation")
public class InboundPalletAllocationEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private Integer allocationNo;
private Long warehouseId;
private Long palletId;
private String palletCode;
private BigDecimal palletCapacityCbm;
private BigDecimal allocatedVolumeCbm;
private Integer allocatedPackageCount;
private Long locationId;
private String locationCode;
private String reservationNo;
private String allocationStatus;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,39 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
/** 计费所需的入库单聚合根字段。 */
@Getter
@Setter
@TableName("fl_inbound_order")
public class InboundPricingEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String inboundCode;
private String processStatus;
private String measureReviewStatus;
private String feeStatus;
private Long customerId;
private String customerName;
private Long warehouseId;
private String warehouseName;
private String businessType;
private String channelCode;
private Long carrierId;
private String carrierName;
private String taxMode;
private String destinationType;
private String destinationCode;
private BigDecimal actualGrossWeightKg;
private BigDecimal actualVolumeCbm;
private BigDecimal chargeableWeightKg;
private BigDecimal chargeableVolumeCbm;
}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 费用试算、确认和发送操作的持久化幂等记录。 */
@Getter
@Setter
@TableName("fl_inbound_idempotency")
public class InboundPricingIdempotencyEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String operation;
private Long operatorId;
private Long inboundId;
private String idempotencyKey;
private String requestHash;
private String status;
private String resultJson;
private Long resultResourceId;
private LocalDateTime completedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,27 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 入库流程状态不可变历史。 */
@Getter
@Setter
@TableName("fl_inbound_status_history")
public class InboundStatusHistoryEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private String fromStatus;
private String toStatus;
private String action;
private String reason;
private Long operatorId;
private String operatorName;
private LocalDateTime occurredAt;
}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 关联运输信息只读快照。 */
@Getter
@Setter
@TableName("fl_inbound_transport_snapshot")
public class InboundTransportSnapshotEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private String loadingStatus;
private String containerNo;
private String cabinetNo;
private String vesselName;
private String voyageNo;
private LocalDateTime sailingAt;
private LocalDateTime portArrivalAt;
private LocalDateTime sourceUpdatedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,37 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 称重图片 OCR 原值、人工修正值和最终确认重量。 */
@Getter
@Setter
@TableName("fl_inbound_weight_result")
public class InboundWeightResultEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private Long fileId;
private String ocrProvider;
private String ocrRawText;
private BigDecimal ocrWeightValue;
private String ocrWeightUnit;
private BigDecimal confidence;
private BigDecimal adjustedWeightValue;
private String adjustedWeightUnit;
private BigDecimal finalWeightKg;
private String recognitionStatus;
private String adjustmentReason;
private Long adjustedBy;
private LocalDateTime adjustedAt;
private Long confirmedBy;
private LocalDateTime confirmedAt;
}
@@ -0,0 +1,33 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** OCR 重量结果每次人工修正和确认的不可变版本。 */
@Getter
@Setter
@TableName("fl_inbound_weight_revision")
public class InboundWeightRevisionEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private Long weightResultId;
private Integer revisionNo;
private String action;
private BigDecimal beforeWeightValue;
private String beforeWeightUnit;
private BigDecimal afterWeightValue;
private String afterWeightUnit;
private BigDecimal finalWeightKg;
private String reason;
private Long operatorId;
private String operatorName;
private LocalDateTime operatedAt;
}
@@ -0,0 +1,29 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 测量审核、驳回和异常放行不可变历史。 */
@Getter
@Setter
@TableName("fl_measure_review_record")
public class MeasureReviewRecordEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private Long measurementId;
private String reviewResult;
private String reviewReason;
private Boolean exceptionRelease;
private String releaseReason;
private Boolean allowRejectedConfig;
private Long reviewerId;
private String reviewerName;
private LocalDateTime reviewedAt;
}
@@ -0,0 +1,36 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 第三方测量 API 每次调用的原始报文和处理结果留档。 */
@Getter
@Setter
@TableName("fl_third_party_call_log")
public class ThirdPartyCallLogEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String appKey;
private String signatureSubject;
private String requestMethod;
private String requestPath;
private String requestTimestamp;
private String nonce;
private String idempotencyKey;
private Integer idempotencyAnchor;
private String signatureValue;
private String requestHash;
private String rawPayload;
private String processingStatus;
private String resultCode;
private String resultMessage;
private String resultJson;
private LocalDateTime requestedAt;
private LocalDateTime processedAt;
}
@@ -0,0 +1,4 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
public interface CarrierFbaRateMapper extends BaseMapper<CarrierFbaRateEntity> {}
@@ -0,0 +1,4 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
public interface CustomerChargeRuleMapper extends BaseMapper<CustomerChargeRuleEntity> {}
@@ -0,0 +1,16 @@
package com.baiyue.firstleg.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/** 只检查任务5已准备好的托盘/库位引用,不在头程服务实现仓库 CRUD。 */
@Mapper
public interface InboundAllocationQueryMapper {
@Select("""
SELECT COUNT(*) FROM fl_inbound_pallet_allocation
WHERE tenant_id = #{tenantId} AND inbound_id = #{inboundId}
AND allocation_status IN ('RESERVED', 'CONFIRMED')
""")
long countPrepared(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
}
@@ -0,0 +1,4 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundFeeItemEntity;
public interface InboundFeeItemMapper extends BaseMapper<InboundFeeItemEntity> {}
@@ -0,0 +1,19 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface InboundFeeMapper extends BaseMapper<InboundFeeEntity> {
@Update("""
UPDATE fl_inbound_fee
SET fee_status = 'SENT', sent_by = #{operatorId}, sent_at = CURRENT_TIMESTAMP(3)
WHERE tenant_id = #{tenantId} AND inbound_id = #{inboundId} AND id = #{feeId}
AND fee_status = 'CONFIRMED'
""")
int markSent(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("feeId") long feeId, @Param("operatorId") long operatorId);
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundFileEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundFileMapper extends BaseMapper<InboundFileEntity> {
}
@@ -0,0 +1,17 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundIdempotencyEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface InboundIdempotencyMapper extends BaseMapper<InboundIdempotencyEntity> {
@Update("""
UPDATE fl_inbound_idempotency
SET status = 'SUCCESS', result_json = #{resultJson}, completed_at = CURRENT_TIMESTAMP(3)
WHERE id = #{id} AND status = 'PROCESSING'
""")
int complete(@Param("id") long id, @Param("resultJson") String resultJson);
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundInternalNoteEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundInternalNoteMapper extends BaseMapper<InboundInternalNoteEntity> {
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundMeasurementItemMapper extends BaseMapper<InboundMeasurementItemEntity> {
}
@@ -0,0 +1,19 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
/** 测量批次 Mapper。 */
@Mapper
public interface InboundMeasurementMapper extends BaseMapper<InboundMeasurementEntity> {
@Update("""
UPDATE fl_inbound_measurement SET status = #{toStatus}, updated_at = CURRENT_TIMESTAMP(3)
WHERE tenant_id = #{tenantId} AND inbound_id = #{inboundId} AND id = #{measurementId}
AND status = 'SUBMITTED'
""")
int reviewSubmitted(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("measurementId") long measurementId, @Param("toStatus") String toStatus);
}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundOrderLookupEntity;
/** 入库单只读 Mapper,开放接口不得在此增加 update 方法。 */
public interface InboundOrderLookupMapper extends BaseMapper<InboundOrderLookupEntity> {
}
@@ -0,0 +1,80 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
/** 入库聚合根条件更新 Mapper,所有推进均同时校验租户、前置状态和版本。 */
@Mapper
public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> {
@Update("""
UPDATE fl_inbound_order
SET process_status = 'WAIT_PRICING', measure_review_status = 'APPROVED',
updated_by = #{operatorId}, version = version + 1
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
AND process_status = 'MEASURE_PENDING' AND measure_review_status = 'PENDING'
AND version = #{expectedVersion}
""")
int approveMeasurement(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId,
@Param("expectedVersion") int expectedVersion);
@Update("""
UPDATE fl_inbound_order
SET process_status = 'MEASURE_REJECTED', measure_review_status = 'REJECTED',
updated_by = #{operatorId}, version = version + 1
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
AND process_status = 'MEASURE_PENDING' AND measure_review_status = 'PENDING'
AND version = #{expectedVersion}
""")
int rejectMeasurement(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId,
@Param("expectedVersion") int expectedVersion);
@Update("""
UPDATE fl_inbound_order
SET process_status = 'WAIT_PRICING', updated_by = #{operatorId}, version = version + 1
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
AND process_status = 'MEASURE_REJECTED' AND measure_review_status = 'REJECTED'
AND version = #{expectedVersion}
""")
int releaseRejectedMeasurement(@Param("tenantId") long tenantId,
@Param("inboundId") long inboundId,
@Param("operatorId") long operatorId,
@Param("expectedVersion") int expectedVersion);
@Update("""
UPDATE fl_inbound_order
SET process_status = 'INBOUNDING', updated_by = #{operatorId}, version = version + 1
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
AND process_status = 'FEE_CONFIRMING' AND fee_status = 'CONFIRMED'
AND version = #{expectedVersion}
""")
int beginInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId,
@Param("expectedVersion") int expectedVersion);
@Update("""
UPDATE fl_inbound_order
SET process_status = 'INBOUNDED', fee_status = 'SENT', notification_status = 'PENDING',
inbounded_at = CURRENT_TIMESTAMP(3), updated_by = #{operatorId}, version = version + 1
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
AND process_status = 'INBOUNDING' AND fee_status = 'CONFIRMED'
AND version = #{expectedVersion}
""")
int completeInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId,
@Param("expectedVersion") int expectedVersion);
@Update("""
UPDATE fl_inbound_order
SET process_status = 'FEE_CONFIRMING', updated_by = #{operatorId}, version = version + 1
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
AND process_status = 'INBOUNDING' AND version = #{expectedVersion}
""")
int rollbackInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId,
@Param("expectedVersion") int expectedVersion);
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundOutboxMapper extends BaseMapper<InboundOutboxEntity> {
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundPalletAllocationMapper extends BaseMapper<InboundPalletAllocationEntity> {
}
@@ -0,0 +1,4 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
public interface InboundPricingIdempotencyMapper extends BaseMapper<InboundPricingIdempotencyEntity> {}
@@ -0,0 +1,4 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundPricingEntity;
public interface InboundPricingMapper extends BaseMapper<InboundPricingEntity> {}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundStatusHistoryMapper extends BaseMapper<InboundStatusHistoryEntity> {
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundTransportSnapshotMapper extends BaseMapper<InboundTransportSnapshotEntity> {
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundWeightResultMapper extends BaseMapper<InboundWeightResultEntity> {
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundWeightRevisionEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundWeightRevisionMapper extends BaseMapper<InboundWeightRevisionEntity> {
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface MeasureReviewRecordMapper extends BaseMapper<MeasureReviewRecordEntity> {
}
@@ -0,0 +1,7 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.ThirdPartyCallLogEntity;
public interface ThirdPartyCallLogMapper extends BaseMapper<ThirdPartyCallLogEntity> {
}
@@ -0,0 +1,266 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundFeeItemEntity;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.entity.InboundPricingEntity;
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
import com.baiyue.firstleg.mapper.CarrierFbaRateMapper;
import com.baiyue.firstleg.mapper.CustomerChargeRuleMapper;
import com.baiyue.firstleg.mapper.InboundFeeItemMapper;
import com.baiyue.firstleg.mapper.InboundFeeMapper;
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
import com.baiyue.firstleg.mapper.InboundPricingIdempotencyMapper;
import com.baiyue.firstleg.mapper.InboundPricingMapper;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
/** FBA 计费仓储,所有业务查询都强制带租户条件。 */
@Repository
public class FbaPricingRepository {
private final CustomerChargeRuleMapper rules;
private final CarrierFbaRateMapper rates;
private final InboundPricingMapper inbounds;
private final InboundFeeMapper fees;
private final InboundFeeItemMapper items;
private final InboundPricingIdempotencyMapper idempotencies;
private final InboundOutboxMapper outbox;
public FbaPricingRepository(CustomerChargeRuleMapper rules, CarrierFbaRateMapper rates,
InboundPricingMapper inbounds, InboundFeeMapper fees,
InboundFeeItemMapper items,
InboundPricingIdempotencyMapper idempotencies,
InboundOutboxMapper outbox) {
this.rules = rules;
this.rates = rates;
this.inbounds = inbounds;
this.fees = fees;
this.items = items;
this.idempotencies = idempotencies;
this.outbox = outbox;
}
public void insertRule(CustomerChargeRuleEntity rule) { rules.insert(rule); }
public void insertRate(CarrierFbaRateEntity rate) { rates.insert(rate); }
public CustomerChargeRuleEntity findRule(long tenantId, long id) {
return rules.selectOne(new LambdaQueryWrapper<CustomerChargeRuleEntity>()
.eq(CustomerChargeRuleEntity::getTenantId, tenantId)
.eq(CustomerChargeRuleEntity::getId, id));
}
public CarrierFbaRateEntity findRate(long tenantId, long id) {
return rates.selectOne(new LambdaQueryWrapper<CarrierFbaRateEntity>()
.eq(CarrierFbaRateEntity::getTenantId, tenantId)
.eq(CarrierFbaRateEntity::getId, id));
}
public List<CustomerChargeRuleEntity> listRules(long tenantId, String ruleCode) {
return rules.selectList(new LambdaQueryWrapper<CustomerChargeRuleEntity>()
.eq(CustomerChargeRuleEntity::getTenantId, tenantId)
.eq(ruleCode != null && !ruleCode.isBlank(), CustomerChargeRuleEntity::getRuleCode, ruleCode)
.orderByAsc(CustomerChargeRuleEntity::getRuleCode)
.orderByDesc(CustomerChargeRuleEntity::getRuleVersion));
}
public List<CarrierFbaRateEntity> listRates(long tenantId, String rateCode) {
return rates.selectList(new LambdaQueryWrapper<CarrierFbaRateEntity>()
.eq(CarrierFbaRateEntity::getTenantId, tenantId)
.eq(rateCode != null && !rateCode.isBlank(), CarrierFbaRateEntity::getRateCode, rateCode)
.orderByAsc(CarrierFbaRateEntity::getRateCode)
.orderByDesc(CarrierFbaRateEntity::getRateVersion));
}
public int nextRuleVersion(long tenantId, String ruleCode) {
List<CustomerChargeRuleEntity> values = listRules(tenantId, ruleCode);
return values.isEmpty() ? 1 : values.get(0).getRuleVersion() + 1;
}
public int nextRateVersion(long tenantId, String rateCode) {
List<CarrierFbaRateEntity> values = listRates(tenantId, rateCode);
return values.isEmpty() ? 1 : values.get(0).getRateVersion() + 1;
}
public int retireRule(long tenantId, long id, long operatorId) {
return rules.update(null, new LambdaUpdateWrapper<CustomerChargeRuleEntity>()
.eq(CustomerChargeRuleEntity::getTenantId, tenantId)
.eq(CustomerChargeRuleEntity::getId, id)
.eq(CustomerChargeRuleEntity::getStatus, "ACTIVE")
.set(CustomerChargeRuleEntity::getStatus, "INACTIVE")
.set(CustomerChargeRuleEntity::getUpdatedBy, operatorId));
}
public int retireRate(long tenantId, long id, long operatorId) {
return rates.update(null, new LambdaUpdateWrapper<CarrierFbaRateEntity>()
.eq(CarrierFbaRateEntity::getTenantId, tenantId)
.eq(CarrierFbaRateEntity::getId, id)
.eq(CarrierFbaRateEntity::getStatus, "ACTIVE")
.set(CarrierFbaRateEntity::getStatus, "INACTIVE")
.set(CarrierFbaRateEntity::getUpdatedBy, operatorId));
}
public InboundPricingEntity findInbound(long tenantId, long inboundId) {
return inbounds.selectOne(new LambdaQueryWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId)
.eq(InboundPricingEntity::getId, inboundId));
}
public List<CustomerChargeRuleEntity> matchRules(InboundPricingEntity inbound, LocalDateTime at) {
return rules.selectList(new LambdaQueryWrapper<CustomerChargeRuleEntity>()
.eq(CustomerChargeRuleEntity::getTenantId, inbound.getTenantId())
.eq(CustomerChargeRuleEntity::getCustomerId, inbound.getCustomerId())
.eq(CustomerChargeRuleEntity::getBusinessType, inbound.getBusinessType())
.eq(CustomerChargeRuleEntity::getTaxMode, inbound.getTaxMode())
.eq(CustomerChargeRuleEntity::getStatus, "ACTIVE")
.le(CustomerChargeRuleEntity::getEffectiveFrom, at)
.and(w -> w.isNull(CustomerChargeRuleEntity::getEffectiveTo)
.or().gt(CustomerChargeRuleEntity::getEffectiveTo, at)));
}
public List<CarrierFbaRateEntity> matchRates(InboundPricingEntity inbound, String chargeUnit,
LocalDateTime at) {
return rates.selectList(new LambdaQueryWrapper<CarrierFbaRateEntity>()
.eq(CarrierFbaRateEntity::getTenantId, inbound.getTenantId())
.eq(CarrierFbaRateEntity::getOriginWarehouseId, inbound.getWarehouseId())
.eq(CarrierFbaRateEntity::getBusinessType, inbound.getBusinessType())
.eq(CarrierFbaRateEntity::getChannelCode, inbound.getChannelCode())
.eq(CarrierFbaRateEntity::getCarrierId, inbound.getCarrierId())
.eq(CarrierFbaRateEntity::getDestinationCode, inbound.getDestinationCode())
.eq(CarrierFbaRateEntity::getChargeUnit, chargeUnit)
.eq(CarrierFbaRateEntity::getStatus, "ACTIVE")
.le(CarrierFbaRateEntity::getEffectiveFrom, at)
.and(w -> w.isNull(CarrierFbaRateEntity::getEffectiveTo)
.or().gt(CarrierFbaRateEntity::getEffectiveTo, at)));
}
public int nextFeeVersion(long tenantId, long inboundId) {
InboundFeeEntity latest = fees.selectOne(new LambdaQueryWrapper<InboundFeeEntity>()
.eq(InboundFeeEntity::getTenantId, tenantId)
.eq(InboundFeeEntity::getInboundId, inboundId)
.orderByDesc(InboundFeeEntity::getFeeVersion).last("LIMIT 1"));
return latest == null ? 1 : latest.getFeeVersion() + 1;
}
public void supersedeCalculated(long tenantId, long inboundId) {
fees.update(null, new LambdaUpdateWrapper<InboundFeeEntity>()
.eq(InboundFeeEntity::getTenantId, tenantId)
.eq(InboundFeeEntity::getInboundId, inboundId)
.eq(InboundFeeEntity::getFeeStatus, "CALCULATED")
.set(InboundFeeEntity::getFeeStatus, "SUPERSEDED"));
}
public void insertFee(InboundFeeEntity fee) { fees.insert(fee); }
public void insertItem(InboundFeeItemEntity item) { items.insert(item); }
public InboundFeeEntity findFee(long tenantId, long feeId) {
return fees.selectOne(new LambdaQueryWrapper<InboundFeeEntity>()
.eq(InboundFeeEntity::getTenantId, tenantId).eq(InboundFeeEntity::getId, feeId));
}
public InboundFeeEntity findLatestFee(long tenantId, long inboundId) {
return fees.selectOne(new LambdaQueryWrapper<InboundFeeEntity>()
.eq(InboundFeeEntity::getTenantId, tenantId)
.eq(InboundFeeEntity::getInboundId, inboundId)
.orderByDesc(InboundFeeEntity::getFeeVersion).last("LIMIT 1"));
}
public InboundFeeItemEntity findBaseItem(long tenantId, long feeId) {
return items.selectOne(new LambdaQueryWrapper<InboundFeeItemEntity>()
.eq(InboundFeeItemEntity::getTenantId, tenantId)
.eq(InboundFeeItemEntity::getFeeId, feeId)
.eq(InboundFeeItemEntity::getFeeCode, "FBA_BASE"));
}
public List<InboundFeeItemEntity> listItems(long tenantId, long feeId) {
return items.selectList(new LambdaQueryWrapper<InboundFeeItemEntity>()
.eq(InboundFeeItemEntity::getTenantId, tenantId)
.eq(InboundFeeItemEntity::getFeeId, feeId)
.orderByAsc(InboundFeeItemEntity::getItemNo));
}
public int supersedeSent(long tenantId, long feeId) {
return fees.update(null, new LambdaUpdateWrapper<InboundFeeEntity>()
.eq(InboundFeeEntity::getTenantId, tenantId)
.eq(InboundFeeEntity::getId, feeId)
.eq(InboundFeeEntity::getFeeStatus, "SENT")
.set(InboundFeeEntity::getFeeStatus, "SUPERSEDED")
.set(InboundFeeEntity::getUpdatedAt, LocalDateTime.now()));
}
public int markInboundCalculated(long tenantId, long inboundId, String chargeMode,
java.math.BigDecimal chargeable) {
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId)
.eq(InboundPricingEntity::getId, inboundId)
.in(InboundPricingEntity::getProcessStatus, List.of("WAIT_PRICING", "FEE_CONFIRMING"))
.in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED"))
.set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING")
.set(InboundPricingEntity::getFeeStatus, "CALCULATED");
if ("KG".equals(chargeMode)) update.set(InboundPricingEntity::getChargeableWeightKg, chargeable);
else update.set(InboundPricingEntity::getChargeableVolumeCbm, chargeable);
return inbounds.update(null, update);
}
public int confirmFee(long tenantId, long feeId, long operatorId, LocalDateTime now) {
return fees.update(null, new LambdaUpdateWrapper<InboundFeeEntity>()
.eq(InboundFeeEntity::getTenantId, tenantId).eq(InboundFeeEntity::getId, feeId)
.eq(InboundFeeEntity::getFeeStatus, "CALCULATED")
.set(InboundFeeEntity::getFeeStatus, "CONFIRMED")
.set(InboundFeeEntity::getConfirmedBy, operatorId)
.set(InboundFeeEntity::getConfirmedAt, now));
}
public int markInboundConfirmed(long tenantId, long inboundId) {
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
.eq(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING")
.eq(InboundPricingEntity::getFeeStatus, "CALCULATED")
.set(InboundPricingEntity::getFeeStatus, "CONFIRMED"));
}
public int sendFee(long tenantId, long feeId, long operatorId, LocalDateTime now) {
return fees.update(null, new LambdaUpdateWrapper<InboundFeeEntity>()
.eq(InboundFeeEntity::getTenantId, tenantId).eq(InboundFeeEntity::getId, feeId)
.eq(InboundFeeEntity::getFeeStatus, "CONFIRMED")
.set(InboundFeeEntity::getFeeStatus, "SENT")
.set(InboundFeeEntity::getSentBy, operatorId)
.set(InboundFeeEntity::getSentAt, now));
}
public int markInboundWaitingPallet(long tenantId, long inboundId) {
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
.eq(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING")
.eq(InboundPricingEntity::getFeeStatus, "CONFIRMED")
.set(InboundPricingEntity::getProcessStatus, "WAIT_PALLET")
.set(InboundPricingEntity::getFeeStatus, "SENT"));
}
public InboundPricingIdempotencyEntity findIdempotency(long tenantId, long operatorId,
String operation, String key) {
return idempotencies.selectOne(new LambdaQueryWrapper<InboundPricingIdempotencyEntity>()
.eq(InboundPricingIdempotencyEntity::getTenantId, tenantId)
.eq(InboundPricingIdempotencyEntity::getOperatorId, operatorId)
.eq(InboundPricingIdempotencyEntity::getOperation, operation)
.eq(InboundPricingIdempotencyEntity::getIdempotencyKey, key));
}
public void insertIdempotency(InboundPricingIdempotencyEntity value) { idempotencies.insert(value); }
public int completeIdempotency(InboundPricingIdempotencyEntity value, long resourceId, LocalDateTime now) {
return idempotencies.update(null, new LambdaUpdateWrapper<InboundPricingIdempotencyEntity>()
.eq(InboundPricingIdempotencyEntity::getId, value.getId())
.eq(InboundPricingIdempotencyEntity::getStatus, "PROCESSING")
.set(InboundPricingIdempotencyEntity::getStatus, "COMPLETED")
.set(InboundPricingIdempotencyEntity::getResultResourceId, resourceId)
.set(InboundPricingIdempotencyEntity::getCompletedAt, now));
}
public void insertOutbox(InboundOutboxEntity event) { outbox.insert(event); }
}
@@ -0,0 +1,56 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.InboundOrderLookupEntity;
import com.baiyue.firstleg.mapper.InboundMeasurementItemMapper;
import com.baiyue.firstleg.mapper.InboundMeasurementMapper;
import com.baiyue.firstleg.mapper.InboundOrderLookupMapper;
import org.springframework.stereotype.Repository;
/** 第三方测量开放接口仓储,仅允许读取主单并写测量、明细和幂等记录。 */
@Repository
public class InboundMeasurementOpenApiRepository {
private final InboundOrderLookupMapper inboundOrders;
private final InboundMeasurementMapper measurements;
private final InboundMeasurementItemMapper items;
public InboundMeasurementOpenApiRepository(InboundOrderLookupMapper inboundOrders,
InboundMeasurementMapper measurements,
InboundMeasurementItemMapper items) {
this.inboundOrders = inboundOrders;
this.measurements = measurements;
this.items = items;
}
public InboundOrderLookupEntity findInbound(long tenantId, String inboundCode) {
return inboundOrders.selectOne(new LambdaQueryWrapper<InboundOrderLookupEntity>()
.eq(InboundOrderLookupEntity::getTenantId, tenantId)
.eq(InboundOrderLookupEntity::getInboundCode, inboundCode)
.eq(InboundOrderLookupEntity::getDeleted, 0));
}
public InboundOrderLookupEntity findInbound(long tenantId, long inboundId) {
return inboundOrders.selectOne(new LambdaQueryWrapper<InboundOrderLookupEntity>()
.eq(InboundOrderLookupEntity::getTenantId, tenantId)
.eq(InboundOrderLookupEntity::getId, inboundId)
.eq(InboundOrderLookupEntity::getDeleted, 0));
}
public InboundMeasurementEntity findEvent(long tenantId, String appKey, String eventNo) {
return measurements.selectOne(new LambdaQueryWrapper<InboundMeasurementEntity>()
.eq(InboundMeasurementEntity::getTenantId, tenantId)
.eq(InboundMeasurementEntity::getSourceCode, appKey)
.eq(InboundMeasurementEntity::getExternalEventNo, eventNo));
}
public void insertMeasurement(InboundMeasurementEntity measurement) {
measurements.insert(measurement);
}
public void insertItem(InboundMeasurementItemEntity item) {
items.insert(item);
}
}
@@ -0,0 +1,133 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import com.baiyue.firstleg.entity.InboundWeightRevisionEntity;
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
import com.baiyue.firstleg.mapper.InboundMeasurementItemMapper;
import com.baiyue.firstleg.mapper.InboundMeasurementMapper;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import com.baiyue.firstleg.mapper.InboundWeightResultMapper;
import com.baiyue.firstleg.mapper.InboundWeightRevisionMapper;
import com.baiyue.firstleg.mapper.MeasureReviewRecordMapper;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 入库测量仓储,所有读取都同时带租户和入库单边界。 */
@Repository
public class InboundMeasurementRepository {
private final InboundOrderMapper inboundOrders;
private final InboundMeasurementMapper measurements;
private final InboundMeasurementItemMapper items;
private final InboundWeightResultMapper weights;
private final InboundWeightRevisionMapper revisions;
private final MeasureReviewRecordMapper reviews;
public InboundMeasurementRepository(InboundOrderMapper inboundOrders,
InboundMeasurementMapper measurements,
InboundMeasurementItemMapper items,
InboundWeightResultMapper weights,
InboundWeightRevisionMapper revisions,
MeasureReviewRecordMapper reviews) {
this.inboundOrders = inboundOrders;
this.measurements = measurements;
this.items = items;
this.weights = weights;
this.revisions = revisions;
this.reviews = reviews;
}
public InboundOrderEntity findInbound(long tenantId, long inboundId) {
return inboundOrders.selectOne(new LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
.eq(InboundOrderEntity::getId, inboundId));
}
public boolean updateInbound(InboundOrderEntity inbound) {
return inboundOrders.updateById(inbound) == 1;
}
public InboundMeasurementEntity findMeasurement(long tenantId, long inboundId, long measurementId) {
return measurements.selectOne(new LambdaQueryWrapper<InboundMeasurementEntity>()
.eq(InboundMeasurementEntity::getTenantId, tenantId)
.eq(InboundMeasurementEntity::getInboundId, inboundId)
.eq(InboundMeasurementEntity::getId, measurementId));
}
public InboundMeasurementEntity findLatestMeasurement(long tenantId, long inboundId) {
return measurements.selectOne(new LambdaQueryWrapper<InboundMeasurementEntity>()
.eq(InboundMeasurementEntity::getTenantId, tenantId)
.eq(InboundMeasurementEntity::getInboundId, inboundId)
.orderByDesc(InboundMeasurementEntity::getCreatedAt)
.last("LIMIT 1"));
}
public List<InboundMeasurementItemEntity> listItems(long tenantId, long measurementId) {
return items.selectList(new LambdaQueryWrapper<InboundMeasurementItemEntity>()
.eq(InboundMeasurementItemEntity::getTenantId, tenantId)
.eq(InboundMeasurementItemEntity::getMeasurementId, measurementId)
.orderByAsc(InboundMeasurementItemEntity::getItemNo));
}
public void insertMeasurement(InboundMeasurementEntity measurement) {
measurements.insert(measurement);
}
public boolean updateMeasurement(InboundMeasurementEntity measurement) {
return measurements.updateById(measurement) == 1;
}
public void replaceItems(long tenantId, long measurementId,
List<InboundMeasurementItemEntity> replacement) {
items.delete(new LambdaQueryWrapper<InboundMeasurementItemEntity>()
.eq(InboundMeasurementItemEntity::getTenantId, tenantId)
.eq(InboundMeasurementItemEntity::getMeasurementId, measurementId));
replacement.forEach(items::insert);
}
public List<InboundWeightResultEntity> listWeights(long tenantId, long inboundId) {
return weights.selectList(new LambdaQueryWrapper<InboundWeightResultEntity>()
.eq(InboundWeightResultEntity::getTenantId, tenantId)
.eq(InboundWeightResultEntity::getInboundId, inboundId)
.orderByAsc(InboundWeightResultEntity::getCreatedAt));
}
public InboundWeightResultEntity findWeight(long tenantId, long inboundId, long resultId) {
return weights.selectOne(new LambdaQueryWrapper<InboundWeightResultEntity>()
.eq(InboundWeightResultEntity::getTenantId, tenantId)
.eq(InboundWeightResultEntity::getInboundId, inboundId)
.eq(InboundWeightResultEntity::getId, resultId));
}
public InboundWeightResultEntity findWeightByFile(long tenantId, long inboundId, long fileId) {
return weights.selectOne(new LambdaQueryWrapper<InboundWeightResultEntity>()
.eq(InboundWeightResultEntity::getTenantId, tenantId)
.eq(InboundWeightResultEntity::getInboundId, inboundId)
.eq(InboundWeightResultEntity::getFileId, fileId));
}
public void insertWeight(InboundWeightResultEntity weight) {
weights.insert(weight);
}
public boolean updateWeight(InboundWeightResultEntity weight) {
return weights.updateById(weight) == 1;
}
public void insertRevision(InboundWeightRevisionEntity revision) {
revisions.insert(revision);
}
public MeasureReviewRecordEntity findLatestReview(long tenantId, long inboundId) {
return reviews.selectOne(new LambdaQueryWrapper<MeasureReviewRecordEntity>()
.eq(MeasureReviewRecordEntity::getTenantId, tenantId)
.eq(MeasureReviewRecordEntity::getInboundId, inboundId)
.orderByDesc(MeasureReviewRecordEntity::getReviewedAt)
.last("LIMIT 1"));
}
}
@@ -0,0 +1,194 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.dto.InboundOrderDetail;
import com.baiyue.firstleg.dto.InboundOrderQuery;
import com.baiyue.firstleg.dto.InboundOrderSummary;
import com.baiyue.firstleg.entity.InboundFileEntity;
import com.baiyue.firstleg.entity.InboundInternalNoteEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity;
import com.baiyue.firstleg.mapper.InboundFileMapper;
import com.baiyue.firstleg.mapper.InboundInternalNoteMapper;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.InboundTransportSnapshotMapper;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/** 入库单只读仓储,所有查询均以租户作为首要隔离条件。 */
@Repository
public class InboundOrderRepository {
private final InboundOrderMapper orders;
private final InboundTransportSnapshotMapper transports;
private final InboundPalletAllocationMapper pallets;
private final InboundFileMapper files;
private final InboundInternalNoteMapper notes;
private final InboundStatusHistoryMapper histories;
public InboundOrderRepository(
InboundOrderMapper orders,
InboundTransportSnapshotMapper transports,
InboundPalletAllocationMapper pallets,
InboundFileMapper files,
InboundInternalNoteMapper notes,
InboundStatusHistoryMapper histories) {
this.orders = orders;
this.transports = transports;
this.pallets = pallets;
this.files = files;
this.notes = notes;
this.histories = histories;
}
public PageResult<InboundOrderSummary> page(long tenantId, InboundOrderQuery query) {
LambdaQueryWrapper<InboundOrderEntity> wrapper = new LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
.likeRight(StringUtils.hasText(query.inboundCode()), InboundOrderEntity::getInboundCode,
query.inboundCode())
.likeRight(StringUtils.hasText(query.orderNo()), InboundOrderEntity::getOrderNo, query.orderNo())
.eq(StringUtils.hasText(query.processStatus()), InboundOrderEntity::getProcessStatus,
query.processStatus())
.eq(StringUtils.hasText(query.orderStatus()), InboundOrderEntity::getOrderStatusSnapshot,
query.orderStatus())
.eq(StringUtils.hasText(query.measureReviewStatus()), InboundOrderEntity::getMeasureReviewStatus,
query.measureReviewStatus())
.eq(StringUtils.hasText(query.feeStatus()), InboundOrderEntity::getFeeStatus, query.feeStatus())
.eq(StringUtils.hasText(query.feeReviewStatus()), InboundOrderEntity::getFeeReviewStatus,
query.feeReviewStatus())
.in(query.tab() != null, InboundOrderEntity::getProcessStatus,
query.tab() == null ? List.of() : query.tab().processStatuses())
.ge(query.expectedArrivalFrom() != null, InboundOrderEntity::getExpectedArrivalAt,
query.expectedArrivalFrom())
.le(query.expectedArrivalTo() != null, InboundOrderEntity::getExpectedArrivalAt,
query.expectedArrivalTo())
.eq(query.customerId() != null, InboundOrderEntity::getCustomerId, query.customerId())
.like(StringUtils.hasText(query.customerName()), InboundOrderEntity::getCustomerName,
query.customerName())
.eq(query.companyId() != null, InboundOrderEntity::getCompanyId, query.companyId())
.eq(query.warehouseId() != null, InboundOrderEntity::getWarehouseId, query.warehouseId())
.eq(query.deliveryWarehouseId() != null, InboundOrderEntity::getDeliveryWarehouseId,
query.deliveryWarehouseId())
.eq(StringUtils.hasText(query.destinationCode()), InboundOrderEntity::getDestinationCode,
query.destinationCode())
.eq(query.holdFlag() != null, InboundOrderEntity::getHoldFlag, query.holdFlag())
.orderByDesc(InboundOrderEntity::getCreatedAt)
.orderByDesc(InboundOrderEntity::getId);
Page<InboundOrderEntity> result = orders.selectPage(new Page<>(query.page(), query.size()), wrapper);
List<Long> ids = result.getRecords().stream().map(InboundOrderEntity::getId).toList();
Map<Long, InboundTransportSnapshotEntity> transportByInbound = transportMap(tenantId, ids);
Map<Long, String> locationCodesByInbound = locationCodesMap(tenantId, ids);
List<InboundOrderSummary> records = result.getRecords().stream()
.map(order -> InboundOrderSummary.from(order, transportByInbound.get(order.getId()),
locationCodesByInbound.get(order.getId())))
.toList();
return new PageResult<>(records, result.getCurrent(), result.getSize(), result.getTotal(), result.getPages());
}
public InboundOrderEntity findOwned(long tenantId, long inboundId) {
return orders.selectOne(new LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
.eq(InboundOrderEntity::getId, inboundId));
}
public InboundOrderEntity findByOrderId(long tenantId, long orderId) {
return orders.selectOne(new LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
.eq(InboundOrderEntity::getOrderId, orderId)
.orderByDesc(InboundOrderEntity::getCreatedAt)
.last("LIMIT 1"));
}
public InboundOrderDetail detail(long tenantId, InboundOrderEntity order) {
long inboundId = order.getId();
InboundTransportSnapshotEntity transport = transports.selectOne(
owned(InboundTransportSnapshotEntity::getTenantId,
InboundTransportSnapshotEntity::getInboundId, tenantId, inboundId));
List<InboundPalletAllocationEntity> palletAllocations = pallets.selectList(
owned(InboundPalletAllocationEntity::getTenantId,
InboundPalletAllocationEntity::getInboundId, tenantId, inboundId)
.orderByAsc(InboundPalletAllocationEntity::getAllocationNo));
List<InboundOrderDetail.PalletLocationSummary> palletSummaries = palletAllocations.stream()
.map(item -> new InboundOrderDetail.PalletLocationSummary(
item.getAllocationNo(), item.getWarehouseId(), item.getPalletId(), item.getPalletCode(),
item.getPalletCapacityCbm(), item.getAllocatedVolumeCbm(), item.getAllocatedPackageCount(),
item.getLocationId(), item.getLocationCode(), item.getReservationNo(), item.getAllocationStatus()))
.toList();
List<InboundOrderDetail.FileSummary> fileSummaries = files.selectList(
owned(InboundFileEntity::getTenantId, InboundFileEntity::getInboundId, tenantId, inboundId)
.orderByAsc(InboundFileEntity::getFileType)
.orderByDesc(InboundFileEntity::getUploadedAt))
.stream().map(item -> new InboundOrderDetail.FileSummary(
item.getFileId(), item.getFileType(), item.getFileNameSnapshot(), item.getVisibility(),
item.getFileVersion(), item.getUploadedBy(), item.getUploadedAt()))
.toList();
List<InboundOrderDetail.InternalNoteSummary> noteSummaries = notes.selectList(
owned(InboundInternalNoteEntity::getTenantId,
InboundInternalNoteEntity::getInboundId, tenantId, inboundId)
.orderByDesc(InboundInternalNoteEntity::getCreatedAt))
.stream().map(item -> new InboundOrderDetail.InternalNoteSummary(
item.getId(), item.getContent(), item.getCreatedBy(), item.getCreatedByName(), item.getCreatedAt()))
.toList();
List<InboundOrderDetail.StatusHistorySummary> historySummaries = histories.selectList(
owned(InboundStatusHistoryEntity::getTenantId,
InboundStatusHistoryEntity::getInboundId, tenantId, inboundId)
.orderByDesc(InboundStatusHistoryEntity::getOccurredAt))
.stream().map(item -> new InboundOrderDetail.StatusHistorySummary(
item.getFromStatus(), item.getToStatus(), item.getAction(), item.getReason(),
item.getOperatorId(), item.getOperatorName(), item.getOccurredAt()))
.toList();
String locationCodes = palletAllocations.stream()
.map(InboundPalletAllocationEntity::getLocationCode)
.filter(StringUtils::hasText)
.collect(Collectors.joining("、"));
return new InboundOrderDetail(InboundOrderSummary.from(order, transport, locationCodes),
order.getPublicRemark(),
palletSummaries, fileSummaries, noteSummaries, historySummaries);
}
private Map<Long, InboundTransportSnapshotEntity> transportMap(long tenantId, List<Long> inboundIds) {
if (inboundIds.isEmpty()) {
return Map.of();
}
return transports.selectList(new LambdaQueryWrapper<InboundTransportSnapshotEntity>()
.eq(InboundTransportSnapshotEntity::getTenantId, tenantId)
.in(InboundTransportSnapshotEntity::getInboundId, inboundIds))
.stream().collect(Collectors.toMap(InboundTransportSnapshotEntity::getInboundId,
Function.identity(), (left, right) -> left));
}
private Map<Long, String> locationCodesMap(long tenantId, List<Long> inboundIds) {
if (inboundIds.isEmpty()) {
return Map.of();
}
return pallets.selectList(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
.in(InboundPalletAllocationEntity::getInboundId, inboundIds)
.orderByAsc(InboundPalletAllocationEntity::getInboundId)
.orderByAsc(InboundPalletAllocationEntity::getAllocationNo))
.stream()
.filter(item -> StringUtils.hasText(item.getLocationCode()))
.collect(Collectors.groupingBy(InboundPalletAllocationEntity::getInboundId,
java.util.LinkedHashMap::new,
Collectors.mapping(InboundPalletAllocationEntity::getLocationCode,
Collectors.joining("、"))));
}
private static <T> LambdaQueryWrapper<T> owned(
com.baomidou.mybatisplus.core.toolkit.support.SFunction<T, ?> tenantColumn,
com.baomidou.mybatisplus.core.toolkit.support.SFunction<T, ?> inboundColumn,
long tenantId,
long inboundId) {
return new LambdaQueryWrapper<T>().eq(tenantColumn, tenantId).eq(inboundColumn, inboundId);
}
}
@@ -0,0 +1,51 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.client.WarehousePalletLocationClient.BatchResult;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Repository
public class InboundPalletAllocationRepository {
private final InboundPalletAllocationMapper mapper;
public InboundPalletAllocationRepository(InboundPalletAllocationMapper mapper) {
this.mapper = mapper;
}
@Transactional
public void replace(long tenantId, long inboundId, long warehouseId, BigDecimal capacity,
BatchResult result) {
mapper.delete(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
.eq(InboundPalletAllocationEntity::getInboundId, inboundId));
LocalDateTime now = LocalDateTime.now();
result.allocations().forEach(source -> {
InboundPalletAllocationEntity value = new InboundPalletAllocationEntity();
value.setTenantId(tenantId);
value.setInboundId(inboundId);
value.setAllocationNo(source.allocationNo());
value.setWarehouseId(warehouseId);
value.setPalletId(source.palletId());
value.setPalletCode(source.palletCode());
value.setPalletCapacityCbm(capacity);
value.setAllocatedVolumeCbm(source.allocatedVolumeCbm());
value.setLocationId(source.locationId());
value.setLocationCode(source.locationCode());
value.setReservationNo(source.reservationNo());
value.setAllocationStatus(normalize(source.state()));
value.setCreatedAt(now);
value.setUpdatedAt(now);
mapper.insert(value);
});
}
private static String normalize(String state) {
return "OCCUPIED".equals(state) ? "CONFIRMED" : state;
}
}
@@ -0,0 +1,154 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundIdempotencyEntity;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
import com.baiyue.firstleg.mapper.InboundAllocationQueryMapper;
import com.baiyue.firstleg.mapper.InboundFeeMapper;
import com.baiyue.firstleg.mapper.InboundIdempotencyMapper;
import com.baiyue.firstleg.mapper.InboundMeasurementMapper;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.MeasureReviewRecordMapper;
import org.springframework.stereotype.Repository;
/** 入库审核和完成编排仓储;所有业务查询强制携带租户。 */
@Repository
public class InboundWorkflowRepository {
private final InboundOrderMapper orders;
private final InboundMeasurementMapper measurements;
private final MeasureReviewRecordMapper reviews;
private final InboundStatusHistoryMapper histories;
private final InboundIdempotencyMapper idempotencies;
private final InboundFeeMapper fees;
private final InboundOutboxMapper outboxes;
private final InboundAllocationQueryMapper allocations;
public InboundWorkflowRepository(InboundOrderMapper orders,
InboundMeasurementMapper measurements,
MeasureReviewRecordMapper reviews,
InboundStatusHistoryMapper histories,
InboundIdempotencyMapper idempotencies,
InboundFeeMapper fees,
InboundOutboxMapper outboxes,
InboundAllocationQueryMapper allocations) {
this.orders = orders;
this.measurements = measurements;
this.reviews = reviews;
this.histories = histories;
this.idempotencies = idempotencies;
this.fees = fees;
this.outboxes = outboxes;
this.allocations = allocations;
}
public InboundOrderEntity findOwned(long tenantId, long inboundId) {
return orders.selectOne(new LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
.eq(InboundOrderEntity::getId, inboundId));
}
public InboundMeasurementEntity findMeasurement(long tenantId, long inboundId, long measurementId) {
return measurements.selectOne(new LambdaQueryWrapper<InboundMeasurementEntity>()
.eq(InboundMeasurementEntity::getTenantId, tenantId)
.eq(InboundMeasurementEntity::getInboundId, inboundId)
.eq(InboundMeasurementEntity::getId, measurementId));
}
public int transitionReview(long tenantId, long inboundId, long operatorId, int expectedVersion,
boolean approved) {
return approved
? orders.approveMeasurement(tenantId, inboundId, operatorId, expectedVersion)
: orders.rejectMeasurement(tenantId, inboundId, operatorId, expectedVersion);
}
public int transitionMeasurement(long tenantId, long inboundId, long measurementId, String result) {
return measurements.reviewSubmitted(tenantId, inboundId, measurementId, result);
}
public int releaseRejected(long tenantId, long inboundId, long operatorId, int expectedVersion) {
return orders.releaseRejectedMeasurement(tenantId, inboundId, operatorId, expectedVersion);
}
public void insertReview(MeasureReviewRecordEntity record) {
reviews.insert(record);
}
public PageResult<MeasureReviewRecordEntity> reviewHistory(
long tenantId, long inboundId, long page, long size) {
return PageResult.from(reviews.selectPage(new Page<>(page, size),
new LambdaQueryWrapper<MeasureReviewRecordEntity>()
.eq(MeasureReviewRecordEntity::getTenantId, tenantId)
.eq(MeasureReviewRecordEntity::getInboundId, inboundId)
.orderByDesc(MeasureReviewRecordEntity::getReviewedAt)
.orderByDesc(MeasureReviewRecordEntity::getId)));
}
public boolean hasExceptionRelease(long tenantId, long inboundId) {
return reviews.selectCount(new LambdaQueryWrapper<MeasureReviewRecordEntity>()
.eq(MeasureReviewRecordEntity::getTenantId, tenantId)
.eq(MeasureReviewRecordEntity::getInboundId, inboundId)
.eq(MeasureReviewRecordEntity::getExceptionRelease, true)) > 0;
}
public void insertHistory(InboundStatusHistoryEntity history) {
histories.insert(history);
}
public void insertIdempotency(InboundIdempotencyEntity record) {
idempotencies.insert(record);
}
public InboundIdempotencyEntity findIdempotency(
long tenantId, String operation, long operatorId, String key) {
return idempotencies.selectOne(new LambdaQueryWrapper<InboundIdempotencyEntity>()
.eq(InboundIdempotencyEntity::getTenantId, tenantId)
.eq(InboundIdempotencyEntity::getOperation, operation)
.eq(InboundIdempotencyEntity::getOperatorId, operatorId)
.eq(InboundIdempotencyEntity::getIdempotencyKey, key));
}
public int completeIdempotency(long id, String resultJson) {
return idempotencies.complete(id, resultJson);
}
public InboundFeeEntity findConfirmedFee(long tenantId, long inboundId, long feeId) {
return fees.selectOne(new LambdaQueryWrapper<InboundFeeEntity>()
.eq(InboundFeeEntity::getTenantId, tenantId)
.eq(InboundFeeEntity::getInboundId, inboundId)
.eq(InboundFeeEntity::getId, feeId)
.eq(InboundFeeEntity::getFeeStatus, "CONFIRMED"));
}
public long countPreparedAllocations(long tenantId, long inboundId) {
return allocations.countPrepared(tenantId, inboundId);
}
public int beginInbound(long tenantId, long inboundId, long operatorId, int expectedVersion) {
return orders.beginInbound(tenantId, inboundId, operatorId, expectedVersion);
}
public int completeInbound(long tenantId, long inboundId, long operatorId, int expectedVersion) {
return orders.completeInbound(tenantId, inboundId, operatorId, expectedVersion);
}
public int rollbackInbound(long tenantId, long inboundId, long operatorId, int expectedVersion) {
return orders.rollbackInbound(tenantId, inboundId, operatorId, expectedVersion);
}
public int markFeeSent(long tenantId, long inboundId, long feeId, long operatorId) {
return fees.markSent(tenantId, inboundId, feeId, operatorId);
}
public void insertOutbox(InboundOutboxEntity event) {
outboxes.insert(event);
}
}
@@ -0,0 +1,11 @@
package com.baiyue.firstleg.security;
import java.util.Map;
/** 签名校验通过后的第三方应用主体。 */
public record AuthenticatedMeasurementApp(
String appKey,
long tenantId,
long subjectId,
Map<String, Long> warehouseBindings) {
}
@@ -0,0 +1,142 @@
package com.baiyue.firstleg.security;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.config.MeasurementOpenApiProperties;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Map;
/** HMAC、时间戳和 Nonce 三项请求认证。 */
@Component
public class MeasurementRequestAuthenticator {
private static final String HMAC_ALGORITHM = "HmacSHA256";
private final MeasurementOpenApiProperties properties;
private final NonceReplayGuard nonceReplayGuard;
private final Clock clock;
public MeasurementRequestAuthenticator(MeasurementOpenApiProperties properties,
NonceReplayGuard nonceReplayGuard) {
this(properties, nonceReplayGuard, Clock.systemUTC());
}
MeasurementRequestAuthenticator(MeasurementOpenApiProperties properties,
NonceReplayGuard nonceReplayGuard,
Clock clock) {
this.properties = properties;
this.nonceReplayGuard = nonceReplayGuard;
this.clock = clock;
}
public AuthenticatedMeasurementApp authenticate(String method, String path,
MeasurementRequestHeaders headers,
String rawBody, boolean requireIdempotencyKey) {
if (!properties.isEnabled()) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "第三方测量开放接口未启用");
}
requireHeader(headers.appKey(), "X-App-Key");
requireHeader(headers.timestamp(), "X-Timestamp");
requireHeader(headers.nonce(), "X-Nonce");
requireHeader(headers.signature(), "X-Signature");
if (requireIdempotencyKey) {
requireHeader(headers.idempotencyKey(), "Idempotency-Key");
}
validateTokenLength(headers.nonce(), 128, "X-Nonce");
if (requireIdempotencyKey) {
validateTokenLength(headers.idempotencyKey(), 128, "Idempotency-Key");
}
MeasurementOpenApiProperties.AppCredential credential = properties.getApps().get(headers.appKey());
if (credential == null || !credential.isEnabled() || !StringUtils.hasText(credential.getSecret())
|| credential.getTenantId() == null || credential.getTenantId() <= 0
|| credential.getSubjectId() == null || credential.getSubjectId() <= 0) {
throw unauthorized("应用凭据无效");
}
long timestamp = parseTimestamp(headers.timestamp());
Duration drift = Duration.between(Instant.ofEpochMilli(timestamp), clock.instant()).abs();
if (drift.compareTo(properties.getMaxClockSkew()) > 0) {
throw unauthorized("请求时间戳已过期");
}
String canonical = canonicalString(method, path, headers.timestamp(), headers.nonce(),
requireIdempotencyKey ? headers.idempotencyKey() : "", sha256Hex(rawBody));
String expected = hmacHex(credential.getSecret(), canonical);
if (!constantTimeEquals(expected, headers.signature())) {
throw unauthorized("请求签名无效");
}
try {
if (!nonceReplayGuard.claim(headers.appKey(), headers.nonce(), properties.getNonceTtl())) {
throw new BizException(ResultCode.CONFLICT, "Nonce 已使用,拒绝重放请求");
}
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "Nonce 防重放服务暂不可用");
}
return new AuthenticatedMeasurementApp(headers.appKey(), credential.getTenantId(),
credential.getSubjectId(), Map.copyOf(credential.getWarehouseBindings()));
}
public static String canonicalString(String method, String path, String timestamp, String nonce,
String idempotencyKey, String bodySha256) {
return String.join("\n", method.toUpperCase(java.util.Locale.ROOT), path, timestamp, nonce,
idempotencyKey == null ? "" : idempotencyKey, bodySha256);
}
public static String sha256Hex(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest((value == null ? "" : value).getBytes(StandardCharsets.UTF_8)));
} catch (Exception exception) {
throw new IllegalStateException("SHA-256 不可用", exception);
}
}
static String hmacHex(String secret, String canonical) {
try {
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM));
return HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8)));
} catch (Exception exception) {
throw new IllegalStateException("HMAC-SHA256 不可用", exception);
}
}
private long parseTimestamp(String timestamp) {
try {
return Long.parseLong(timestamp);
} catch (NumberFormatException exception) {
throw unauthorized("X-Timestamp 必须为 Unix 毫秒时间戳");
}
}
private boolean constantTimeEquals(String expected, String actual) {
return MessageDigest.isEqual(expected.getBytes(StandardCharsets.US_ASCII),
actual.toLowerCase(java.util.Locale.ROOT).getBytes(StandardCharsets.US_ASCII));
}
private void requireHeader(String value, String name) {
if (!StringUtils.hasText(value)) {
throw unauthorized("缺少请求头 " + name);
}
}
private void validateTokenLength(String value, int max, String name) {
if (value.length() > max) {
throw unauthorized(name + " 长度超过限制");
}
}
private BizException unauthorized(String message) {
return new BizException(ResultCode.UNAUTHORIZED, message);
}
}
@@ -0,0 +1,10 @@
package com.baiyue.firstleg.security;
/** 第三方测量请求的认证和幂等请求头。 */
public record MeasurementRequestHeaders(
String appKey,
String timestamp,
String nonce,
String signature,
String idempotencyKey) {
}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.security;
import java.time.Duration;
/** 原子占用 Nonce,返回 false 表示同一应用已使用过该 Nonce。 */
public interface NonceReplayGuard {
boolean claim(String appKey, String nonce, Duration ttl);
}
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.security;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
/** 使用 Redis SET NX EX 实现跨实例 Nonce 防重放。 */
@Component
public class RedisNonceReplayGuard implements NonceReplayGuard {
private static final String KEY_PREFIX = "baiyue:firstleg:measurement-openapi:nonce:";
private final StringRedisTemplate redisTemplate;
public RedisNonceReplayGuard(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public boolean claim(String appKey, String nonce, Duration ttl) {
Boolean claimed = redisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + appKey + ":" + nonce, "1", ttl);
return Boolean.TRUE.equals(claimed);
}
}
@@ -0,0 +1,69 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
import org.springframework.stereotype.Service;
@Service
public class AutomaticInboundCompletionService {
private final FbaPricingService pricing;
private final InboundWorkflowRepository workflow;
private final InboundCompletionOrchestrator completion;
public AutomaticInboundCompletionService(FbaPricingService pricing,
InboundWorkflowRepository workflow,
InboundCompletionOrchestrator completion) {
this.pricing = pricing;
this.workflow = workflow;
this.completion = completion;
}
public FeeSnapshotResponse completeApproved(long tenantId, long inboundId, long operatorId,
String operatorName, long measurementId,
int reviewedOrderVersion) {
String key = "auto-" + inboundId + "-" + measurementId + "-" + reviewedOrderVersion;
InboundOrderEntity order = requireOrder(tenantId, inboundId);
if (completed(order)) return pricing.latest(tenantId, inboundId);
if ("WAIT_PRICING".equals(order.getProcessStatus()) && "UNPRICED".equals(order.getFeeStatus())) {
pricing.calculate(tenantId, operatorId, inboundId, null, key + "-calculate");
order = requireOrder(tenantId, inboundId);
}
if ("FEE_CONFIRMING".equals(order.getProcessStatus())
&& "CALCULATED".equals(order.getFeeStatus())) {
pricing.confirm(tenantId, operatorId, inboundId, key + "-confirm");
order = requireOrder(tenantId, inboundId);
}
FeeSnapshotResponse fee = pricing.latest(tenantId, inboundId);
if ("FEE_CONFIRMING".equals(order.getProcessStatus())
&& "CONFIRMED".equals(order.getFeeStatus())) {
completion.beginSendFeeAndInbound(tenantId, operatorId, operatorName, inboundId,
fee.feeId(), order.getVersion(), key + "-begin");
order = requireOrder(tenantId, inboundId);
}
if ("INBOUNDING".equals(order.getProcessStatus())
&& "CONFIRMED".equals(order.getFeeStatus())) {
completion.completeSendFeeAndInbound(tenantId, operatorId, operatorName, inboundId,
fee.feeId(), order.getVersion(), key + "-complete");
order = requireOrder(tenantId, inboundId);
}
if (!completed(order)) {
throw new BizException(ResultCode.CONFLICT, "客服审核后的自动计费入库流程状态异常");
}
return pricing.latest(tenantId, inboundId);
}
private static boolean completed(InboundOrderEntity order) {
return "INBOUNDED".equals(order.getProcessStatus()) && "SENT".equals(order.getFeeStatus());
}
private InboundOrderEntity requireOrder(long tenantId, long inboundId) {
InboundOrderEntity order = workflow.findOwned(tenantId, inboundId);
if (order == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
return order;
}
}
@@ -0,0 +1,145 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FbaPricingDtos.CarrierRateVersionRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.CustomerRuleVersionRequest;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
/** 客户规则和船司价格版本管理。版本内容创建后不可原地修改。 */
@Service
public class FbaMasterDataService {
private final FbaPricingRepository repository;
public FbaMasterDataService(FbaPricingRepository repository) { this.repository = repository; }
@Transactional
public CustomerChargeRuleEntity createRuleVersion(long tenantId, long operatorId,
CustomerRuleVersionRequest request) {
validatePeriod(request.effectiveFrom(), request.effectiveTo());
boolean overlaps = repository.listRules(tenantId, null).stream()
.filter(v -> "ACTIVE".equals(v.getStatus()))
.filter(v -> Objects.equals(v.getCustomerId(), request.customerId()))
.filter(v -> Objects.equals(v.getBusinessType(), request.businessType()))
.filter(v -> Objects.equals(v.getTaxMode(), request.taxMode()))
.anyMatch(v -> overlaps(v.getEffectiveFrom(), v.getEffectiveTo(),
request.effectiveFrom(), request.effectiveTo()));
if (overlaps) throw conflict("客户计费规则有效期重叠,无法保证唯一匹配");
CustomerChargeRuleEntity value = new CustomerChargeRuleEntity();
value.setTenantId(tenantId);
value.setRuleCode(request.ruleCode());
value.setRuleVersion(repository.nextRuleVersion(tenantId, request.ruleCode()));
value.setCustomerId(request.customerId());
value.setCustomerName(request.customerName());
value.setBusinessType(request.businessType());
value.setChargeMode(request.chargeMode());
value.setTaxMode(request.taxMode());
value.setConversionFactor(request.conversionFactor());
value.setRoundingScale(request.roundingScale());
value.setRoundingMode(request.roundingMode());
value.setCurrency(request.currency());
value.setEffectiveFrom(request.effectiveFrom());
value.setEffectiveTo(request.effectiveTo());
value.setStatus("ACTIVE");
value.setRemark(request.remark());
value.setCreatedBy(operatorId);
value.setUpdatedBy(operatorId);
repository.insertRule(value);
return value;
}
@Transactional
public CarrierFbaRateEntity createRateVersion(long tenantId, long operatorId,
CarrierRateVersionRequest request) {
validatePeriod(request.effectiveFrom(), request.effectiveTo());
boolean overlaps = repository.listRates(tenantId, null).stream()
.filter(v -> "ACTIVE".equals(v.getStatus()))
.filter(v -> Objects.equals(v.getOriginWarehouseId(), request.originWarehouseId()))
.filter(v -> Objects.equals(v.getBusinessType(), request.businessType()))
.filter(v -> Objects.equals(v.getChannelCode(), request.channelCode()))
.filter(v -> Objects.equals(v.getCarrierId(), request.carrierId()))
.filter(v -> Objects.equals(v.getDestinationCode(), request.destinationCode()))
.filter(v -> Objects.equals(v.getChargeUnit(), request.chargeUnit()))
.anyMatch(v -> overlaps(v.getEffectiveFrom(), v.getEffectiveTo(),
request.effectiveFrom(), request.effectiveTo()));
if (overlaps) throw conflict("船司 FBA 价格有效期重叠,无法保证唯一匹配");
CarrierFbaRateEntity value = new CarrierFbaRateEntity();
value.setTenantId(tenantId);
value.setRateCode(request.rateCode());
value.setRateVersion(repository.nextRateVersion(tenantId, request.rateCode()));
value.setOriginWarehouseId(request.originWarehouseId());
value.setOriginWarehouseName(request.originWarehouseName());
value.setBusinessType(request.businessType());
value.setChannelCode(request.channelCode());
value.setCarrierId(request.carrierId());
value.setCarrierName(request.carrierName());
value.setDestinationCode(request.destinationCode());
value.setChargeUnit(request.chargeUnit());
value.setUnitPrice(request.unitPrice());
value.setCurrency(request.currency());
value.setAdditionalFee(request.additionalFee());
value.setEffectiveFrom(request.effectiveFrom());
value.setEffectiveTo(request.effectiveTo());
value.setStatus("ACTIVE");
value.setRemark(request.remark());
value.setCreatedBy(operatorId);
value.setUpdatedBy(operatorId);
repository.insertRate(value);
return value;
}
public List<CustomerChargeRuleEntity> listRules(long tenantId, String code) {
return repository.listRules(tenantId, code);
}
public List<CarrierFbaRateEntity> listRates(long tenantId, String code) {
return repository.listRates(tenantId, code);
}
public CustomerChargeRuleEntity rule(long tenantId, long id) {
CustomerChargeRuleEntity value = repository.findRule(tenantId, id);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "客户计费规则版本不存在");
return value;
}
public CarrierFbaRateEntity rate(long tenantId, long id) {
CarrierFbaRateEntity value = repository.findRate(tenantId, id);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "船司 FBA 价格版本不存在");
return value;
}
@Transactional
public void retireRule(long tenantId, long id, long operatorId) {
if (repository.retireRule(tenantId, id, operatorId) != 1) throw conflict("规则已停用或不存在");
}
@Transactional
public void retireRate(long tenantId, long id, long operatorId) {
if (repository.retireRate(tenantId, id, operatorId) != 1) throw conflict("价格已停用或不存在");
}
private static void validatePeriod(LocalDateTime from, LocalDateTime to) {
if (to != null && !to.isAfter(from)) {
throw new BizException(ResultCode.BAD_REQUEST, "有效期结束时间必须晚于开始时间");
}
}
private static boolean overlaps(LocalDateTime aFrom, LocalDateTime aTo,
LocalDateTime bFrom, LocalDateTime bTo) {
return (aTo == null || bFrom.isBefore(aTo)) && (bTo == null || aFrom.isBefore(bTo));
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
}
@@ -0,0 +1,381 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundFeeItemEntity;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.entity.InboundPricingEntity;
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.beans.BeanUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.List;
/** FBA 费用试算、确认和发送状态机。 */
@Service
public class FbaPricingService {
private static final int MONEY_SCALE = 2;
private final FbaPricingRepository repository;
private final Clock clock;
public FbaPricingService(FbaPricingRepository repository) {
this(repository, Clock.systemDefaultZone());
}
FbaPricingService(FbaPricingRepository repository, Clock clock) {
this.repository = repository;
this.clock = clock;
}
@Transactional
public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId,
LocalDateTime requestedAt, String idempotencyKey) {
LocalDateTime pricingAt = requestedAt == null ? LocalDateTime.now(clock) : requestedAt;
String hash = hash("CALCULATE", inboundId, requestedAt == null ? "CURRENT" : requestedAt.toString());
InboundPricingIdempotencyEntity token = beginIdempotency(
tenantId, operatorId, inboundId, "FEE_CALCULATE", idempotencyKey, hash);
if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), "FEE_CONFIRMING");
InboundPricingEntity inbound = requireEligibleInbound(tenantId, inboundId);
List<CustomerChargeRuleEntity> matchedRules = repository.matchRules(inbound, pricingAt);
CustomerChargeRuleEntity rule = unique(matchedRules, "客户计费规则");
List<CarrierFbaRateEntity> matchedRates = repository.matchRates(inbound, rule.getChargeMode(), pricingAt);
CarrierFbaRateEntity rate = unique(matchedRates, "船司 FBA 价格");
if (!rule.getCurrency().equals(rate.getCurrency())) {
throw conflict("客户规则币种与船司价格币种不一致");
}
RoundingMode roundingMode;
try {
roundingMode = RoundingMode.valueOf(rule.getRoundingMode());
} catch (IllegalArgumentException exception) {
throw conflict("客户规则舍入方式无效: " + rule.getRoundingMode());
}
int scale = rule.getRoundingScale();
if (scale < 0 || scale > 4 || rule.getConversionFactor().signum() <= 0) {
throw conflict("客户规则换算系数或精度无效");
}
BigDecimal weight = inbound.getActualGrossWeightKg().setScale(3, RoundingMode.HALF_UP);
BigDecimal volume = inbound.getActualVolumeCbm().setScale(4, RoundingMode.HALF_UP);
BigDecimal converted = "KG".equals(rule.getChargeMode())
? volume.multiply(rule.getConversionFactor()).setScale(scale, roundingMode)
: weight.divide(rule.getConversionFactor(), scale, roundingMode);
BigDecimal actual = "KG".equals(rule.getChargeMode()) ? weight : volume;
BigDecimal chargeable = actual.max(converted).setScale(scale, roundingMode);
BigDecimal subtotal = chargeable.multiply(rate.getUnitPrice()).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
BigDecimal additional = rate.getAdditionalFee().setScale(MONEY_SCALE, RoundingMode.HALF_UP);
BigDecimal total = subtotal.add(additional).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
LocalDateTime now = LocalDateTime.now(clock);
int version = repository.nextFeeVersion(tenantId, inboundId);
InboundFeeEntity fee = snapshot(tenantId, operatorId, inbound, rule, rate, version,
weight, volume, converted, chargeable, subtotal, additional, total, now);
repository.supersedeCalculated(tenantId, inboundId);
repository.insertFee(fee);
repository.insertItem(baseItem(tenantId, fee, rate, chargeable, subtotal, now));
if (additional.signum() > 0) repository.insertItem(additionalItem(tenantId, fee, additional, now));
if (repository.markInboundCalculated(tenantId, inboundId, rule.getChargeMode(), chargeable) != 1) {
throw conflict("入库单状态已变化,费用试算未保存");
}
complete(token, fee.getId(), now);
return response(inbound, fee, rate.getUnitPrice(), "FEE_CONFIRMING");
}
@Transactional
public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId,
String idempotencyKey) {
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
if (current == null) throw conflict("入库单尚无可确认的费用试算");
String hash = hash("CONFIRM", inboundId, current.getId().toString());
InboundPricingIdempotencyEntity token = beginIdempotency(
tenantId, operatorId, inboundId, "FEE_CONFIRM", idempotencyKey, hash);
if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), "FEE_CONFIRMING");
LocalDateTime now = LocalDateTime.now(clock);
if (repository.confirmFee(tenantId, current.getId(), operatorId, now) != 1
|| repository.markInboundConfirmed(tenantId, inboundId) != 1) {
throw conflict("费用或入库单状态已变化,不能重复确认");
}
complete(token, current.getId(), now);
return response(tenantId, current.getId(), "FEE_CONFIRMING");
}
@Transactional
public FeeSnapshotResponse send(long tenantId, long operatorId, long inboundId,
String idempotencyKey) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
if (current == null) throw conflict("入库单尚无费用快照");
String hash = hash("SEND", inboundId, current.getId().toString());
InboundPricingIdempotencyEntity token = beginIdempotency(
tenantId, operatorId, inboundId, "FEE_SEND", idempotencyKey, hash);
if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), "WAIT_PALLET");
LocalDateTime now = LocalDateTime.now(clock);
if (repository.sendFee(tenantId, current.getId(), operatorId, now) != 1
|| repository.markInboundWaitingPallet(tenantId, inboundId) != 1) {
throw conflict("费用未确认或入库单状态已变化,不能发送");
}
InboundOutboxEntity event = new InboundOutboxEntity();
event.setTenantId(tenantId);
event.setAggregateType("INBOUND");
event.setAggregateId(inboundId);
event.setEventType("FIRSTLEG_FEE_SENT");
event.setPayloadJson("{\"inboundId\":\"" + inboundId + "\",\"feeId\":\"" + current.getId() + "\"}");
event.setPublishStatus("PENDING");
event.setRetryCount(0);
event.setCreatedAt(now);
repository.insertOutbox(event);
complete(token, current.getId(), now);
return response(tenantId, current.getId(), "WAIT_PALLET");
}
public FeeSnapshotResponse latest(long tenantId, long inboundId) {
InboundFeeEntity fee = repository.findLatestFee(tenantId, inboundId);
if (fee == null) throw new BizException(ResultCode.NOT_FOUND, "费用快照不存在");
return response(tenantId, fee.getId(), null);
}
@Transactional
public FeeSnapshotResponse adjustPublished(long tenantId, long operatorId, long inboundId,
AdjustFeeRequest request, String idempotencyKey) {
InboundPricingEntity inbound = repository.findInbound(tenantId, inboundId);
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
if (!"INBOUNDED".equals(inbound.getProcessStatus()) || !"SENT".equals(inbound.getFeeStatus())) {
throw conflict("只能修改已完成入库且已发布的费用");
}
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
if (current == null || !"SENT".equals(current.getFeeStatus())) {
throw conflict("入库单没有可修改的已发布费用");
}
String discriminator = current.getId() + ":" + request.subtotalAmount() + ":"
+ request.additionalAmount() + ":" + request.reason().trim();
InboundPricingIdempotencyEntity token = beginIdempotency(tenantId, operatorId, inboundId,
"FEE_ADJUST", idempotencyKey, hash("ADJUST", inboundId, discriminator));
if ("COMPLETED".equals(token.getStatus())) {
return response(tenantId, token.getResultResourceId(), "INBOUNDED");
}
LocalDateTime now = LocalDateTime.now(clock);
int nextVersion = repository.nextFeeVersion(tenantId, inboundId);
InboundFeeEntity adjusted = new InboundFeeEntity();
BeanUtils.copyProperties(current, adjusted);
adjusted.setId(null);
adjusted.setFeeNo("FBAF-" + inboundId + "-" + nextVersion);
adjusted.setFeeVersion(nextVersion);
adjusted.setSubtotalAmount(request.subtotalAmount().setScale(MONEY_SCALE, RoundingMode.HALF_UP));
adjusted.setAdditionalAmount(request.additionalAmount().setScale(MONEY_SCALE, RoundingMode.HALF_UP));
adjusted.setTotalAmount(adjusted.getSubtotalAmount().add(adjusted.getAdditionalAmount()));
adjusted.setFeeStatus("SENT");
adjusted.setReviewStatus("NOT_REQUIRED");
adjusted.setCalculatedBy(operatorId);
adjusted.setCalculatedAt(now);
adjusted.setConfirmedBy(operatorId);
adjusted.setConfirmedAt(now);
adjusted.setSentBy(operatorId);
adjusted.setSentAt(now);
adjusted.setCreatedAt(now);
adjusted.setUpdatedAt(now);
if (repository.supersedeSent(tenantId, current.getId()) != 1) {
throw conflict("费用已被其他操作员修改,请刷新后重试");
}
repository.insertFee(adjusted);
BigDecimal quantity = current.getChargeableQuantity();
InboundFeeItemEntity base = item(tenantId, adjusted, 1, "FBA_BASE", "FBA基础运费",
adjusted.getSubtotalAmount(), now);
base.setQuantity(quantity);
base.setUnit(current.getChargeUnit());
base.setUnitPrice(quantity.signum() == 0 ? BigDecimal.ZERO
: adjusted.getSubtotalAmount().divide(quantity, 6, RoundingMode.HALF_UP));
base.setAutomaticFlag(false);
base.setRemark(request.reason().trim());
repository.insertItem(base);
if (adjusted.getAdditionalAmount().signum() > 0) {
InboundFeeItemEntity extra = additionalItem(tenantId, adjusted,
adjusted.getAdditionalAmount(), now);
extra.setAutomaticFlag(false);
extra.setRemark(request.reason().trim());
repository.insertItem(extra);
}
InboundOutboxEntity event = new InboundOutboxEntity();
event.setTenantId(tenantId);
event.setAggregateType("INBOUND");
event.setAggregateId(inboundId);
event.setEventType("FIRSTLEG_FEE_ADJUSTED");
event.setPayloadJson("{\"inboundId\":\"" + inboundId + "\",\"feeId\":\""
+ adjusted.getId() + "\",\"feeVersion\":" + nextVersion + "}");
event.setPublishStatus("PENDING");
event.setRetryCount(0);
event.setCreatedAt(now);
repository.insertOutbox(event);
complete(token, adjusted.getId(), now);
return response(tenantId, adjusted.getId(), "INBOUNDED");
}
private InboundPricingEntity requireEligibleInbound(long tenantId, long inboundId) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
if (!List.of("WAIT_PRICING", "FEE_CONFIRMING").contains(inbound.getProcessStatus())
|| !List.of("UNPRICED", "CALCULATED").contains(inbound.getFeeStatus())) {
throw conflict("当前入库单状态不允许费用试算");
}
return inbound;
}
private InboundPricingEntity requireApprovedInbound(long tenantId, long inboundId) {
InboundPricingEntity inbound = repository.findInbound(tenantId, inboundId);
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
if (!"FBA".equals(inbound.getDestinationType())) throw conflict("二期仅支持 FBA 目的仓计费");
if (!"APPROVED".equals(inbound.getMeasureReviewStatus())) throw conflict("只能消费审核通过的测量快照");
if (inbound.getActualGrossWeightKg() == null || inbound.getActualGrossWeightKg().signum() <= 0
|| inbound.getActualVolumeCbm() == null || inbound.getActualVolumeCbm().signum() <= 0) {
throw conflict("审核通过的测量快照必须包含大于零的实重和体积");
}
if (inbound.getCustomerId() == null || inbound.getWarehouseId() == null
|| inbound.getCarrierId() == null || !StringUtils.hasText(inbound.getChannelCode())
|| !StringUtils.hasText(inbound.getDestinationCode())) {
throw conflict("入库单缺少客户、起运仓、渠道、船司或 FBA 仓匹配维度");
}
return inbound;
}
private <T> T unique(List<T> values, String name) {
if (values.isEmpty()) throw conflict("未匹配到有效" + name);
if (values.size() > 1) throw conflict("匹配到多条有效" + name + ",请修正有效期配置");
return values.get(0);
}
private InboundPricingIdempotencyEntity beginIdempotency(long tenantId, long operatorId,
long inboundId, String operation,
String key, String hash) {
if (!StringUtils.hasText(key) || key.length() > 128) {
throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key 必填且不能超过128字符");
}
InboundPricingIdempotencyEntity existing = repository.findIdempotency(tenantId, operatorId, operation, key);
if (existing != null) {
if (!hash.equals(existing.getRequestHash())) throw conflict("幂等键已用于不同请求");
if (!"COMPLETED".equals(existing.getStatus())) throw conflict("相同请求正在处理中");
return existing;
}
InboundPricingIdempotencyEntity value = new InboundPricingIdempotencyEntity();
value.setTenantId(tenantId);
value.setOperation(operation);
value.setOperatorId(operatorId);
value.setInboundId(inboundId);
value.setIdempotencyKey(key);
value.setRequestHash(hash);
value.setStatus("PROCESSING");
value.setCreatedAt(LocalDateTime.now(clock));
value.setUpdatedAt(LocalDateTime.now(clock));
repository.insertIdempotency(value);
return value;
}
private void complete(InboundPricingIdempotencyEntity token, long resourceId, LocalDateTime now) {
if (repository.completeIdempotency(token, resourceId, now) != 1) throw conflict("幂等记录状态已变化");
}
private InboundFeeEntity snapshot(long tenantId, long operatorId, InboundPricingEntity inbound,
CustomerChargeRuleEntity rule, CarrierFbaRateEntity rate, int version,
BigDecimal weight, BigDecimal volume, BigDecimal converted,
BigDecimal chargeable, BigDecimal subtotal, BigDecimal additional,
BigDecimal total, LocalDateTime now) {
InboundFeeEntity fee = new InboundFeeEntity();
fee.setTenantId(tenantId); fee.setInboundId(inbound.getId());
fee.setFeeNo("FBAF-" + inbound.getId() + "-" + version); fee.setFeeVersion(version);
fee.setChargeRuleId(rule.getId()); fee.setChargeRuleVersion(rule.getRuleVersion());
fee.setCarrierRateId(rate.getId()); fee.setCarrierRateVersion(rate.getRateVersion());
fee.setRuleCode(rule.getRuleCode()); fee.setCustomerId(inbound.getCustomerId());
fee.setCustomerName(inbound.getCustomerName()); fee.setBusinessType(inbound.getBusinessType());
fee.setTaxMode(inbound.getTaxMode()); fee.setRoundingScale(rule.getRoundingScale());
fee.setRoundingMode(rule.getRoundingMode()); fee.setRuleEffectiveFrom(rule.getEffectiveFrom());
fee.setRuleEffectiveTo(rule.getEffectiveTo()); fee.setRateCode(rate.getRateCode());
fee.setOriginWarehouseId(rate.getOriginWarehouseId()); fee.setOriginWarehouseName(rate.getOriginWarehouseName());
fee.setChannelCode(rate.getChannelCode()); fee.setCarrierId(rate.getCarrierId());
fee.setCarrierName(rate.getCarrierName()); fee.setDestinationCode(rate.getDestinationCode());
fee.setRateEffectiveFrom(rate.getEffectiveFrom()); fee.setRateEffectiveTo(rate.getEffectiveTo());
fee.setChargeMode(rule.getChargeMode()); fee.setConversionFactor(rule.getConversionFactor());
fee.setActualWeightKg(weight); fee.setActualVolumeCbm(volume); fee.setConvertedQuantity(converted);
fee.setChargeableQuantity(chargeable); fee.setChargeUnit(rule.getChargeMode()); fee.setCurrency(rate.getCurrency());
fee.setSubtotalAmount(subtotal); fee.setAdditionalAmount(additional); fee.setTotalAmount(total);
fee.setFeeStatus("CALCULATED"); fee.setReviewStatus("NOT_SUBMITTED"); fee.setExceptionRelease(false);
fee.setCalculatedBy(operatorId); fee.setCalculatedAt(now); fee.setCreatedAt(now); fee.setUpdatedAt(now);
return fee;
}
private InboundFeeItemEntity baseItem(long tenantId, InboundFeeEntity fee, CarrierFbaRateEntity rate,
BigDecimal quantity, BigDecimal amount, LocalDateTime now) {
InboundFeeItemEntity item = item(tenantId, fee, 1, "FBA_BASE", "FBA基础运费", amount, now);
item.setQuantity(quantity); item.setUnit(fee.getChargeUnit()); item.setUnitPrice(rate.getUnitPrice());
return item;
}
private InboundFeeItemEntity additionalItem(long tenantId, InboundFeeEntity fee,
BigDecimal amount, LocalDateTime now) {
InboundFeeItemEntity item = item(tenantId, fee, 2, "FBA_ADDITIONAL", "FBA固定附加费", amount, now);
item.setQuantity(BigDecimal.ONE.setScale(4)); item.setUnit("ORDER"); item.setUnitPrice(amount);
return item;
}
private InboundFeeItemEntity item(long tenantId, InboundFeeEntity fee, int no, String code,
String name, BigDecimal amount, LocalDateTime now) {
InboundFeeItemEntity item = new InboundFeeItemEntity();
item.setTenantId(tenantId); item.setFeeId(fee.getId()); item.setItemNo(no);
item.setFeeCode(code); item.setFeeName(name); item.setAmount(amount); item.setCurrency(fee.getCurrency());
item.setAutomaticFlag(true); item.setCreatedAt(now); return item;
}
private FeeSnapshotResponse response(long tenantId, long feeId, String nextStatus) {
InboundFeeEntity fee = repository.findFee(tenantId, feeId);
if (fee == null) throw new BizException(ResultCode.NOT_FOUND, "费用快照不存在");
InboundPricingEntity inbound = repository.findInbound(tenantId, fee.getInboundId());
InboundFeeItemEntity base = repository.findBaseItem(tenantId, feeId);
String actualStatus = nextStatus == null && inbound != null
? inbound.getProcessStatus() : nextStatus;
return response(inbound, fee, base == null ? null : base.getUnitPrice(), actualStatus);
}
private FeeSnapshotResponse response(InboundPricingEntity inbound, InboundFeeEntity fee,
BigDecimal unitPrice, String nextStatus) {
return new FeeSnapshotResponse(fee.getId(), fee.getFeeNo(), fee.getFeeVersion(), fee.getFeeStatus(),
fee.getInboundId(), inbound == null ? null : inbound.getInboundCode(),
fee.getChargeRuleId(), fee.getRuleCode(), fee.getChargeRuleVersion(),
fee.getCarrierRateId(), fee.getRateCode(), fee.getCarrierRateVersion(),
fee.getCustomerId(), fee.getCustomerName(), fee.getOriginWarehouseId(), fee.getOriginWarehouseName(),
fee.getBusinessType(), fee.getChannelCode(), fee.getCarrierId(), fee.getCarrierName(),
fee.getDestinationCode(), fee.getTaxMode(), fee.getChargeMode(), fee.getConversionFactor(),
fee.getRoundingScale(), fee.getRoundingMode(), fee.getActualWeightKg(), fee.getActualVolumeCbm(),
fee.getConvertedQuantity(), fee.getChargeableQuantity(), fee.getChargeUnit(), unitPrice,
fee.getSubtotalAmount(), fee.getAdditionalAmount(), fee.getTotalAmount(), fee.getCurrency(),
fee.getRateEffectiveFrom(), fee.getRateEffectiveTo(), fee.getCalculatedAt(), fee.getConfirmedAt(),
fee.getSentAt(), nextStatus);
}
private static String hash(String operation, long inboundId, String discriminator) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(
(operation + ":" + inboundId + ":" + discriminator).getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("JVM不支持SHA-256", exception);
}
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
}
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
/** 头程平台接口租户边界校验。 */
public final class FirstlegScopeGuard {
private FirstlegScopeGuard() {}
public static long platform(AuthenticatedUser user, long targetTenantId) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
if (targetTenantId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户");
}
return targetTenantId;
}
}
@@ -0,0 +1,156 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
/**
* 发送费用并完成入库的状态编排骨架。
*
* <p>本服务不计算费用、不创建托盘/库位,也不调用仓库 CRUD。测量完成阶段已确认托盘和库位占用;
* 客服审核通过后的自动编排调用 {@link #beginSendFeeAndInbound} 进入 INBOUNDING,再调用
* {@link #completeSendFeeAndInbound} 发布费用并完成入库。</p>
*/
@Service
public class InboundCompletionOrchestrator {
private final InboundWorkflowRepository repository;
private final InboundIdempotencyService idempotency;
public InboundCompletionOrchestrator(InboundWorkflowRepository repository,
InboundIdempotencyService idempotency) {
this.repository = repository;
this.idempotency = idempotency;
}
/**
* 任务5对接方法一:锁定发送动作。允许前置状态仅为 FEE_CONFIRMING + CONFIRMED。
*/
@Transactional
public void beginSendFeeAndInbound(long tenantId, long operatorId, String operatorName,
long inboundId, long feeId, int expectedVersion,
String idempotencyKey) {
String canonical = inboundId + "|" + feeId + "|" + expectedVersion;
InboundIdempotencyService.Context context = idempotency.begin(
tenantId, operatorId, inboundId, "SEND_FEE_BEGIN", idempotencyKey, canonical);
if (context.replayed()) {
return;
}
InboundOrderEntity order = requireOrder(tenantId, inboundId);
if (!"FEE_CONFIRMING".equals(order.getProcessStatus()) || !"CONFIRMED".equals(order.getFeeStatus())) {
throw new BizException(ResultCode.CONFLICT, "费用未确认或入库单不在待发送阶段");
}
boolean released = measurementGatePassed(order, tenantId, inboundId);
InboundFeeEntity fee = repository.findConfirmedFee(tenantId, inboundId, feeId);
if (fee == null) {
throw new BizException(ResultCode.CONFLICT, "缺少已确认且不可变的费用快照");
}
if (Boolean.TRUE.equals(fee.getExceptionRelease()) != released) {
throw new BizException(ResultCode.CONFLICT, "费用快照的异常放行标记与审核历史不一致");
}
if (repository.countPreparedAllocations(tenantId, inboundId) <= 0) {
throw new BizException(ResultCode.CONFLICT, "尚未准备托盘和库位预占引用");
}
if (repository.beginInbound(tenantId, inboundId, operatorId, expectedVersion) != 1) {
throw concurrentConflict();
}
repository.insertHistory(MeasurementReviewService.history(tenantId, inboundId,
"FEE_CONFIRMING", "INBOUNDING", "SEND_FEE_BEGIN", null, operatorId, operatorName));
idempotency.complete(context, inboundId);
}
/**
* 任务5对接方法二:仓库确认占用成功后,原子锁定费用、完成入库并写 Outbox。
*/
@Transactional
public void completeSendFeeAndInbound(long tenantId, long operatorId, String operatorName,
long inboundId, long feeId, int expectedVersion,
String idempotencyKey) {
String canonical = inboundId + "|" + feeId + "|" + expectedVersion;
InboundIdempotencyService.Context context = idempotency.begin(
tenantId, operatorId, inboundId, "SEND_FEE_COMPLETE", idempotencyKey, canonical);
if (context.replayed()) {
return;
}
InboundOrderEntity order = requireOrder(tenantId, inboundId);
if (!"INBOUNDING".equals(order.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT, "入库单不在入库处理中");
}
if (repository.completeInbound(tenantId, inboundId, operatorId, expectedVersion) != 1) {
throw concurrentConflict();
}
if (repository.markFeeSent(tenantId, inboundId, feeId, operatorId) != 1) {
throw new BizException(ResultCode.CONFLICT, "费用快照已被并发发送或状态不正确");
}
repository.insertHistory(MeasurementReviewService.history(tenantId, inboundId,
"INBOUNDING", "INBOUNDED", "SEND_FEE_COMPLETE", null, operatorId, operatorName));
repository.insertOutbox(completedEvent(tenantId, inboundId, feeId));
idempotency.complete(context, inboundId);
}
/** 任务5对接方法三:仅在仓库预占已释放后回退,不能回退已完成实物入库。 */
@Transactional
public void rollbackSendFeeAndInbound(long tenantId, long operatorId, String operatorName,
long inboundId, int expectedVersion, String reason,
String idempotencyKey) {
if (!StringUtils.hasText(reason)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "入库回退必须记录失败原因");
}
String canonical = inboundId + "|" + expectedVersion + "|" + (reason == null ? "" : reason.trim());
InboundIdempotencyService.Context context = idempotency.begin(
tenantId, operatorId, inboundId, "SEND_FEE_ROLLBACK", idempotencyKey, canonical);
if (context.replayed()) {
return;
}
requireOrder(tenantId, inboundId);
if (repository.rollbackInbound(tenantId, inboundId, operatorId, expectedVersion) != 1) {
throw concurrentConflict();
}
repository.insertHistory(MeasurementReviewService.history(tenantId, inboundId,
"INBOUNDING", "FEE_CONFIRMING", "SEND_FEE_ROLLBACK", reason, operatorId, operatorName));
idempotency.complete(context, inboundId);
}
private boolean measurementGatePassed(InboundOrderEntity order, long tenantId, long inboundId) {
if ("APPROVED".equals(order.getMeasureReviewStatus())) {
return false;
}
if ("REJECTED".equals(order.getMeasureReviewStatus())
&& repository.hasExceptionRelease(tenantId, inboundId)) {
return true;
}
throw new BizException(ResultCode.CONFLICT, "测量未审核通过且无有效异常放行记录");
}
private InboundOrderEntity requireOrder(long tenantId, long inboundId) {
InboundOrderEntity order = repository.findOwned(tenantId, inboundId);
if (order == null) {
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
}
return order;
}
private static InboundOutboxEntity completedEvent(long tenantId, long inboundId, long feeId) {
InboundOutboxEntity event = new InboundOutboxEntity();
event.setTenantId(tenantId);
event.setAggregateType("INBOUND");
event.setAggregateId(inboundId);
event.setEventType("FIRSTLEG_INBOUND_FEE_SENT");
event.setPayloadJson("{\"inboundId\":\"" + inboundId + "\",\"feeId\":\"" + feeId + "\"}");
event.setPublishStatus("PENDING");
event.setRetryCount(0);
event.setCreatedAt(LocalDateTime.now());
return event;
}
private static BizException concurrentConflict() {
return new BizException(ResultCode.CONFLICT, "入库单已被并发更新,请刷新后重试");
}
}
@@ -0,0 +1,81 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.InboundIdempotencyEntity;
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HexFormat;
/** 入库写操作的统一持久化幂等边界。 */
@Service
public class InboundIdempotencyService {
private final InboundWorkflowRepository repository;
public InboundIdempotencyService(InboundWorkflowRepository repository) {
this.repository = repository;
}
public Context begin(long tenantId, long operatorId, long inboundId,
String operation, String idempotencyKey, String canonicalRequest) {
String key = idempotencyKey == null ? null : idempotencyKey.trim();
if (!StringUtils.hasText(key)) {
throw new BizException(ResultCode.BAD_REQUEST, "必须提供 Idempotency-Key");
}
if (key.length() > 128) {
throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key 长度不能超过128");
}
String hash = sha256(canonicalRequest);
InboundIdempotencyEntity record = new InboundIdempotencyEntity();
record.setTenantId(tenantId);
record.setOperation(operation);
record.setOperatorId(operatorId);
record.setInboundId(inboundId);
record.setIdempotencyKey(key);
record.setRequestHash(hash);
record.setStatus("PROCESSING");
record.setCreatedAt(LocalDateTime.now());
record.setUpdatedAt(record.getCreatedAt());
try {
repository.insertIdempotency(record);
return new Context(record.getId(), false);
} catch (DuplicateKeyException duplicate) {
InboundIdempotencyEntity existing = repository.findIdempotency(
tenantId, operation, operatorId, key);
if (existing == null || !hash.equals(existing.getRequestHash())) {
throw new BizException(ResultCode.CONFLICT,
"Idempotency-Key 已用于不同请求");
}
if ("SUCCESS".equals(existing.getStatus())) {
return new Context(existing.getId(), true);
}
throw new BizException(ResultCode.CONFLICT, "相同请求正在处理中,请稍后重试");
}
}
public void complete(Context context, long inboundId) {
if (!context.replayed()
&& repository.completeIdempotency(context.id(), "{\"inboundId\":\"" + inboundId + "\"}") != 1) {
throw new BizException(ResultCode.CONFLICT, "幂等记录已被并发修改");
}
}
private static String sha256(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("JVM 不支持 SHA-256", impossible);
}
}
public record Context(long id, boolean replayed) {
}
}
@@ -0,0 +1,236 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.CallbackRequest;
import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.EventResponse;
import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.Item;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.InboundOrderLookupEntity;
import com.baiyue.firstleg.repository.InboundMeasurementOpenApiRepository;
import com.baiyue.firstleg.security.AuthenticatedMeasurementApp;
import com.baiyue.firstleg.security.MeasurementRequestAuthenticator;
import com.baiyue.firstleg.security.MeasurementRequestHeaders;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validator;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Set;
/**
* 第三方测量接收编排。只写调用日志、测量批次和明细,不提交审核且不更新入库主单。
*/
@Service
public class InboundMeasurementOpenApiService {
static final String CALLBACK_PATH = "/openapi/v1/inbound-measurements";
private static final Set<String> ACCEPTABLE_INBOUND_STATUSES = Set.of("WAIT_MEASURE", "MEASURE_REJECTED");
private static final BigDecimal CUBIC_CENTIMETERS_PER_CBM = new BigDecimal("1000000");
private static final String CALCULATION_RULE_VERSION = "V1_CEILING_4";
private final InboundMeasurementOpenApiRepository repository;
private final MeasurementRequestAuthenticator authenticator;
private final ThirdPartyCallLogService callLogService;
private final ObjectMapper objectMapper;
private final Validator validator;
private final TransactionTemplate transactionTemplate;
public InboundMeasurementOpenApiService(InboundMeasurementOpenApiRepository repository,
MeasurementRequestAuthenticator authenticator,
ThirdPartyCallLogService callLogService,
ObjectMapper objectMapper, Validator validator,
TransactionTemplate transactionTemplate) {
this.repository = repository;
this.authenticator = authenticator;
this.callLogService = callLogService;
this.objectMapper = objectMapper;
this.validator = validator;
this.transactionTemplate = transactionTemplate;
}
public EventResponse receive(MeasurementRequestHeaders headers, String rawBody) {
long logId = callLogService.start("POST", CALLBACK_PATH, headers, rawBody);
try {
AuthenticatedMeasurementApp app = authenticator.authenticate(
"POST", CALLBACK_PATH, headers, rawBody, true);
CallbackRequest request = parseAndValidate(rawBody);
String requestHash = MeasurementRequestAuthenticator.sha256Hex(rawBody);
if (!callLogService.claimIdempotency(logId)) {
EventResponse replay = replayIdempotency(app, headers.idempotencyKey(), requestHash);
callLogService.complete(logId, "ACCEPTED", ResultCode.SUCCESS.code(), "duplicate", writeJson(replay));
return replay;
}
EventResponse result = transactionTemplate.execute(status ->
persist(app, request, rawBody, requestHash));
String resultJson = writeJson(result);
callLogService.complete(logId, "ACCEPTED", ResultCode.SUCCESS.code(), "success", resultJson);
return result;
} catch (RuntimeException exception) {
ResultCode code = exception instanceof BizException biz ? biz.getResultCode() : ResultCode.INTERNAL_ERROR;
callLogService.complete(logId, "REJECTED", code.code(), exception.getMessage(), null);
throw exception;
}
}
public EventResponse query(String eventNo, MeasurementRequestHeaders headers) {
String path = CALLBACK_PATH + "/events/" + eventNo;
long logId = callLogService.start("GET", path, headers, "");
try {
AuthenticatedMeasurementApp app = authenticator.authenticate("GET", path, headers, "", false);
InboundMeasurementEntity measurement = repository.findEvent(app.tenantId(), app.appKey(), eventNo);
if (measurement == null) {
throw new BizException(ResultCode.NOT_FOUND, "第三方测量事件不存在");
}
InboundOrderLookupEntity inbound = repository.findInbound(app.tenantId(), measurement.getInboundId());
if (inbound == null) {
throw new BizException(ResultCode.NOT_FOUND, "入仓码对应的入库单不存在");
}
EventResponse result = response(measurement, inbound.getInboundCode());
callLogService.complete(logId, "ACCEPTED", ResultCode.SUCCESS.code(), "success", writeJson(result));
return result;
} catch (RuntimeException exception) {
ResultCode code = exception instanceof BizException biz ? biz.getResultCode() : ResultCode.INTERNAL_ERROR;
callLogService.complete(logId, "REJECTED", code.code(), exception.getMessage(), null);
throw exception;
}
}
private EventResponse persist(AuthenticatedMeasurementApp app,
CallbackRequest request, String rawBody, String requestHash) {
InboundMeasurementEntity existingEvent = repository.findEvent(
app.tenantId(), app.appKey(), request.eventNo());
if (existingEvent != null) {
if (!MeasurementRequestAuthenticator.sha256Hex(existingEvent.getRawPayloadJson()).equals(requestHash)) {
throw new BizException(ResultCode.CONFLICT, "同一 appKey 和 eventNo 的报文内容不一致");
}
InboundOrderLookupEntity inbound = repository.findInbound(app.tenantId(), existingEvent.getInboundId());
EventResponse result = response(existingEvent, inbound == null ? request.inboundCode() : inbound.getInboundCode());
return result;
}
Long boundWarehouseId = app.warehouseBindings().get(request.warehouseCode());
if (boundWarehouseId == null) {
throw new BizException(ResultCode.FORBIDDEN, "应用未绑定请求中的仓库编码");
}
InboundOrderLookupEntity inbound = repository.findInbound(app.tenantId(), request.inboundCode());
if (inbound == null || !boundWarehouseId.equals(inbound.getWarehouseId())) {
throw new BizException(ResultCode.NOT_FOUND, "入仓码不存在或不属于绑定仓库");
}
if (!ACCEPTABLE_INBOUND_STATUSES.contains(inbound.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT, "当前入库单状态不允许接收测量结果");
}
InboundMeasurementEntity measurement = buildMeasurement(app, request, rawBody, inbound.getId());
repository.insertMeasurement(measurement);
for (Item item : request.items()) {
repository.insertItem(buildItem(app.tenantId(), measurement.getId(), item));
}
EventResponse result = response(measurement, request.inboundCode());
return result;
}
private InboundMeasurementEntity buildMeasurement(AuthenticatedMeasurementApp app,
CallbackRequest request, String rawBody, long inboundId) {
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(IdWorker.getId());
measurement.setTenantId(app.tenantId());
measurement.setInboundId(inboundId);
measurement.setSourceType("THIRD_PARTY");
measurement.setSourceCode(app.appKey());
measurement.setExternalEventNo(request.eventNo());
measurement.setDeviceCode(request.deviceCode());
measurement.setWarehouseCode(request.warehouseCode());
measurement.setMeasuredAt(request.measuredAt().withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime());
measurement.setStatus("RECEIVED");
measurement.setRawPayloadJson(rawBody);
measurement.setTotalPackageCount(request.items().stream().mapToInt(Item::quantity).sum());
BigDecimal rawTotal = request.items().stream().map(this::rawVolume).reduce(BigDecimal.ZERO, BigDecimal::add);
measurement.setTotalVolumeRawCbm(rawTotal.setScale(6, RoundingMode.HALF_UP));
measurement.setTotalVolumeCbm(rawTotal.setScale(4, RoundingMode.CEILING));
measurement.setCreatedBy(app.subjectId());
measurement.setCreatedAt(LocalDateTime.now());
measurement.setUpdatedAt(LocalDateTime.now());
return measurement;
}
private InboundMeasurementItemEntity buildItem(long tenantId, long measurementId, Item source) {
BigDecimal rawVolume = rawVolume(source);
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setId(IdWorker.getId());
item.setTenantId(tenantId);
item.setMeasurementId(measurementId);
item.setItemNo(source.itemNo());
item.setBoxSpecification(org.springframework.util.StringUtils.hasText(source.boxSpecification())
? source.boxSpecification().trim() : source.itemNo());
item.setPackageCount(source.quantity());
item.setLengthCm(source.lengthCm());
item.setWidthCm(source.widthCm());
item.setHeightCm(source.heightCm());
item.setVolumeRawCbm(rawVolume.setScale(6, RoundingMode.HALF_UP));
item.setVolumeCbm(rawVolume.setScale(4, RoundingMode.CEILING));
item.setCalculationRuleVersion(CALCULATION_RULE_VERSION);
item.setCreatedAt(LocalDateTime.now());
return item;
}
private BigDecimal rawVolume(Item item) {
return item.lengthCm().multiply(item.widthCm()).multiply(item.heightCm())
.multiply(BigDecimal.valueOf(item.quantity()))
.divide(CUBIC_CENTIMETERS_PER_CBM, 12, RoundingMode.HALF_UP);
}
private EventResponse replayIdempotency(AuthenticatedMeasurementApp app, String key, String requestHash) {
var existing = callLogService.findIdempotencyOwner(app.tenantId(), app.appKey(), key);
if (existing == null || "PROCESSING".equals(existing.getProcessingStatus())) {
throw new BizException(ResultCode.CONFLICT, "相同 Idempotency-Key 的请求正在处理");
}
if (!requestHash.equals(existing.getRequestHash())) {
throw new BizException(ResultCode.CONFLICT, "同一 Idempotency-Key 的报文内容不一致");
}
if (!"ACCEPTED".equals(existing.getProcessingStatus()) || existing.getResultJson() == null) {
throw new BizException(ResultCode.CONFLICT, "同一 Idempotency-Key 已处理但未成功");
}
try {
return objectMapper.readValue(existing.getResultJson(), EventResponse.class);
} catch (JsonProcessingException exception) {
throw new BizException(ResultCode.INTERNAL_ERROR, "幂等结果无法读取");
}
}
private CallbackRequest parseAndValidate(String rawBody) {
try {
CallbackRequest request = objectMapper.readValue(rawBody, CallbackRequest.class);
Set<ConstraintViolation<CallbackRequest>> violations = validator.validate(request);
if (!violations.isEmpty()) {
throw new BizException(ResultCode.VALIDATION_FAILED,
violations.iterator().next().getPropertyPath() + " " + violations.iterator().next().getMessage());
}
return request;
} catch (BizException exception) {
throw exception;
} catch (JsonProcessingException exception) {
throw new BizException(ResultCode.BAD_REQUEST, "请求 JSON 格式无效");
}
}
private EventResponse response(InboundMeasurementEntity measurement, String inboundCode) {
return new EventResponse(measurement.getExternalEventNo(), inboundCode, true,
measurement.getStatus(), measurement.getCreatedAt().atOffset(ZoneOffset.UTC));
}
private String writeJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException exception) {
throw new BizException(ResultCode.INTERNAL_ERROR, "处理结果无法序列化");
}
}
}
@@ -0,0 +1,395 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
import com.baiyue.firstleg.dto.MeasurementBatchCommand;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import com.baiyue.firstleg.entity.InboundWeightRevisionEntity;
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/** 平台端入库测量、OCR 修正、重量确认和提交审核应用服务。 */
@Service
public class InboundMeasurementService {
private static final String STATUS_SUBMITTED = "SUBMITTED";
private static final String WEIGHT_CONFIRMED = "CONFIRMED";
private static final String WEIGHT_PENDING_CONFIRM = "PENDING_CONFIRM";
private static final String WEIGHT_NEEDS_MANUAL = "NEEDS_MANUAL";
/** 测量完成后的默认普通托盘类型;实际拆托和库位占用由提交编排服务执行。 */
private static final String RECOMMENDED_PALLET_TYPE = "STANDARD";
private static final Set<String> EDITABLE_INBOUND_STATUSES =
Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
private static final BigDecimal LB_TO_KG = new BigDecimal("0.45359237");
private final InboundMeasurementRepository repository;
private final MeasurementBatchService batchService;
public InboundMeasurementService(InboundMeasurementRepository repository,
MeasurementBatchService batchService) {
this.repository = repository;
this.batchService = batchService;
}
public InboundMeasurementResponses.Detail detail(long tenantId, long inboundId) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
InboundMeasurementEntity measurement = repository.findLatestMeasurement(tenantId, inboundId);
List<InboundMeasurementItemEntity> items = measurement == null
? List.of() : repository.listItems(tenantId, measurement.getId());
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId);
MeasureReviewRecordEntity review = repository.findLatestReview(tenantId, inboundId);
return toDetail(inbound, measurement, items, weights, review);
}
@Transactional
public InboundMeasurementResponses.DraftSaved saveDraft(
long tenantId, long inboundId, long operatorId,
InboundMeasurementRequests.SaveDraftRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
requireEditable(inbound);
MeasurementBatchCommand command = new MeasurementBatchCommand(
tenantId, inboundId, request.measurementId(), request.version(),
MeasurementBatchService.SOURCE_MANUAL, "PLATFORM", null, null, null,
LocalDateTime.now(), null, request.originalDocumentNo(),
request.originalGrossWeightKg(), request.originalVolumeCbm(), request.actualGoodsName(),
operatorId, request.items().stream().map(item -> new MeasurementBatchCommand.Item(
item.itemNo(), item.boxSpecification(), item.packageCount(), item.lengthCm(),
item.widthCm(), item.heightCm(), item.unitWeightKg(), item.intelligentImageFileId(),
item.overLimitWarning(), item.outerLengthCm(), item.outerWidthCm(), item.outerHeightCm(),
item.remark())).toList());
InboundMeasurementEntity saved = batchService.saveBatch(command);
return new InboundMeasurementResponses.DraftSaved(saved.getId(), saved.getVersion(), saved.getStatus());
}
@Transactional
public InboundMeasurementResponses.MeasurementSubmitted submit(
long tenantId, long inboundId, long operatorId,
InboundMeasurementRequests.SubmitRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
requireEditable(inbound);
InboundMeasurementEntity measurement = requireMeasurement(tenantId, inboundId, request.measurementId());
requireVersion(request.version(), measurement.getVersion());
if (!Set.of(MeasurementBatchService.STATUS_DRAFT, MeasurementBatchService.STATUS_RECEIVED)
.contains(measurement.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "当前测量批次不能重复提交审核");
}
List<InboundMeasurementItemEntity> items = repository.listItems(tenantId, measurement.getId());
if (items.isEmpty()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细不能为空");
}
if (items.stream().noneMatch(item -> item.getSourceImageFileId() != null)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "至少需要一张测量图片");
}
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId);
BigDecimal confirmedWeight = confirmedWeightForSubmission(weights);
measurement.setStatus(STATUS_SUBMITTED);
if (!repository.updateMeasurement(measurement)) {
throw concurrentModification();
}
inbound.setProcessStatus("MEASURE_PENDING");
inbound.setMeasureReviewStatus("PENDING");
inbound.setTotalPackageCount(measurement.getTotalPackageCount());
inbound.setActualGrossWeightKg(confirmedWeight);
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
inbound.setVolumeWeightKg(measurement.getVolumeWeightKg());
inbound.setMeasureCompletedAt(LocalDateTime.now());
inbound.setUpdatedBy(operatorId);
if (!repository.updateInbound(inbound)) {
throw concurrentModification();
}
return new InboundMeasurementResponses.MeasurementSubmitted(
measurement.getId(), measurement.getVersion(), inbound.getProcessStatus(),
inbound.getMeasureReviewStatus(), RECOMMENDED_PALLET_TYPE, null, null, null);
}
@Transactional
public InboundMeasurementResponses.WeightResult createWeightResult(
long tenantId, long inboundId, long operatorId,
InboundMeasurementRequests.CreateWeightResultRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
requireEditable(inbound);
if (repository.findWeightByFile(tenantId, inboundId, request.fileId()) != null) {
throw new BizException(ResultCode.CONFLICT, "同一称重图片不能重复计重");
}
String unit = rawOcrUnit(request.ocrWeightUnit());
InboundWeightResultEntity weight = new InboundWeightResultEntity();
weight.setTenantId(tenantId);
weight.setInboundId(inboundId);
weight.setFileId(request.fileId());
weight.setOcrProvider(request.ocrProvider());
weight.setOcrRawText(request.ocrRawText());
weight.setOcrWeightValue(request.ocrWeightValue());
weight.setOcrWeightUnit(unit);
weight.setConfidence(request.confidence());
weight.setRecognitionStatus(isPositive(request.ocrWeightValue()) && isSupportedUnit(unit)
? WEIGHT_PENDING_CONFIRM : WEIGHT_NEEDS_MANUAL);
weight.setCreatedBy(operatorId);
weight.setUpdatedBy(operatorId);
weight.setDeleted(false);
weight.setVersion(0);
repository.insertWeight(weight);
return toWeight(weight);
}
@Transactional
public InboundMeasurementResponses.WeightResult correctWeightResult(
long tenantId, long inboundId, long resultId, long operatorId, String operatorName,
InboundMeasurementRequests.CorrectWeightResultRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
requireEditable(inbound);
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
requireVersion(request.version(), weight.getVersion());
String adjustedUnit = normalizeUnit(request.adjustedWeightUnit());
BigDecimal beforeValue = effectiveValue(weight);
String beforeUnit = effectiveUnit(weight);
weight.setAdjustedWeightValue(request.adjustedWeightValue());
weight.setAdjustedWeightUnit(adjustedUnit);
weight.setAdjustmentReason(request.adjustmentReason());
weight.setAdjustedBy(operatorId);
weight.setAdjustedAt(LocalDateTime.now());
weight.setFinalWeightKg(null);
weight.setConfirmedBy(null);
weight.setConfirmedAt(null);
weight.setRecognitionStatus(WEIGHT_PENDING_CONFIRM);
weight.setUpdatedBy(operatorId);
if (!repository.updateWeight(weight)) {
throw concurrentModification();
}
insertRevision(weight, request.version() + 1, "CORRECT", beforeValue, beforeUnit,
request.adjustedWeightValue(), adjustedUnit, null, request.adjustmentReason(),
operatorId, operatorName);
return toWeight(weight);
}
@Transactional
public InboundMeasurementResponses.WeightResult confirmWeightResult(
long tenantId, long inboundId, long resultId, long operatorId, String operatorName,
InboundMeasurementRequests.ConfirmWeightResultRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
requireEditable(inbound);
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
requireVersion(request.version(), weight.getVersion());
if (WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) {
return toWeight(weight);
}
BigDecimal value = effectiveValue(weight);
String rawUnit = effectiveUnit(weight);
if (!isPositive(value) || !StringUtils.hasText(rawUnit)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "OCR 重量无法确认,请先人工修正数值和单位");
}
String unit = normalizeUnit(rawUnit);
BigDecimal finalKg = toKg(value, unit);
weight.setFinalWeightKg(finalKg);
weight.setRecognitionStatus(WEIGHT_CONFIRMED);
weight.setConfirmedBy(operatorId);
weight.setConfirmedAt(LocalDateTime.now());
weight.setUpdatedBy(operatorId);
if (!repository.updateWeight(weight)) {
throw concurrentModification();
}
insertRevision(weight, request.version() + 1, "CONFIRM", value, unit, value, unit,
finalKg, null, operatorId, operatorName);
recalculateConfirmedWeight(inbound, operatorId);
return toWeight(weight);
}
private void recalculateConfirmedWeight(InboundOrderEntity inbound, long operatorId) {
BigDecimal total = repository.listWeights(inbound.getTenantId(), inbound.getId()).stream()
.filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()))
.map(InboundWeightResultEntity::getFinalWeightKg)
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(3, RoundingMode.HALF_UP);
inbound.setActualGrossWeightKg(total);
if ("WAIT_MEASURE".equals(inbound.getProcessStatus())) {
inbound.setProcessStatus("MEASURING");
}
inbound.setUpdatedBy(operatorId);
if (!repository.updateInbound(inbound)) {
throw concurrentModification();
}
}
private BigDecimal confirmedWeightForSubmission(List<InboundWeightResultEntity> weights) {
if (weights.isEmpty()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "至少需要一张已确认的称重图片");
}
if (weights.stream().anyMatch(weight -> !WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()))) {
throw new BizException(ResultCode.VALIDATION_FAILED, "所有有效称重图片必须先完成确认");
}
BigDecimal total = weights.stream().map(InboundWeightResultEntity::getFinalWeightKg)
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(3, RoundingMode.HALF_UP);
if (!isPositive(total)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "确认后的实际总重量必须大于 0");
}
return total;
}
private void insertRevision(InboundWeightResultEntity weight, int revisionNo, String action,
BigDecimal beforeValue, String beforeUnit,
BigDecimal afterValue, String afterUnit, BigDecimal finalWeightKg,
String reason, long operatorId, String operatorName) {
InboundWeightRevisionEntity revision = new InboundWeightRevisionEntity();
revision.setTenantId(weight.getTenantId());
revision.setInboundId(weight.getInboundId());
revision.setWeightResultId(weight.getId());
revision.setRevisionNo(revisionNo);
revision.setAction(action);
revision.setBeforeWeightValue(beforeValue);
revision.setBeforeWeightUnit(beforeUnit);
revision.setAfterWeightValue(afterValue);
revision.setAfterWeightUnit(afterUnit);
revision.setFinalWeightKg(finalWeightKg);
revision.setReason(reason);
revision.setOperatorId(operatorId);
revision.setOperatorName(operatorName);
revision.setOperatedAt(LocalDateTime.now());
repository.insertRevision(revision);
}
private InboundMeasurementResponses.Detail toDetail(
InboundOrderEntity inbound, InboundMeasurementEntity measurement,
List<InboundMeasurementItemEntity> items, List<InboundWeightResultEntity> weights,
MeasureReviewRecordEntity review) {
return new InboundMeasurementResponses.Detail(
inbound.getId(), inbound.getInboundCode(), inbound.getCompanyName(), inbound.getChannelCode(),
inbound.getChannelName(), inbound.getProcessStatus(), inbound.getMeasureReviewStatus(),
RECOMMENDED_PALLET_TYPE,
review == null ? null : review.getReviewReason(), review == null ? null : review.getReviewerId(),
review == null ? null : review.getReviewerName(), measurement == null ? null : measurement.getId(),
measurement == null ? null : measurement.getSourceType(),
measurement == null ? null : measurement.getStatus(),
measurement == null ? null : measurement.getVersion(),
measurement == null ? null : measurement.getOriginalDocumentNo(),
measurement == null ? null : measurement.getOriginalGrossWeightKg(),
measurement == null ? null : measurement.getOriginalVolumeCbm(),
measurement == null ? inbound.getTotalPackageCount() : measurement.getTotalPackageCount(),
inbound.getActualGrossWeightKg(),
measurement == null ? inbound.getActualVolumeCbm() : measurement.getTotalVolumeCbm(),
measurement == null ? inbound.getVolumeWeightKg() : measurement.getVolumeWeightKg(),
measurement == null ? null : measurement.getActualGoodsName(),
items.stream().map(this::toItem).toList(), weights.stream().map(this::toWeight).toList());
}
private InboundMeasurementResponses.Item toItem(InboundMeasurementItemEntity item) {
return new InboundMeasurementResponses.Item(
item.getId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(),
item.getLengthCm(), item.getWidthCm(), item.getHeightCm(), item.getGirthCm(),
item.getUnitWeightKg(), item.getUnitVolumeCbm(), item.getTotalWeightKg(),
item.getTotalVolumeCbm(), item.getVolumeWeightKg(), item.getSourceImageFileId(),
item.getOverLimitWarning(), item.getOuterLengthCm(), item.getOuterWidthCm(),
item.getOuterHeightCm(), item.getRemark());
}
private InboundMeasurementResponses.WeightResult toWeight(InboundWeightResultEntity weight) {
return new InboundMeasurementResponses.WeightResult(
weight.getId(), weight.getFileId(), weight.getOcrProvider(), weight.getOcrRawText(),
weight.getOcrWeightValue(), weight.getOcrWeightUnit(), weight.getConfidence(),
weight.getAdjustedWeightValue(), weight.getAdjustedWeightUnit(), weight.getFinalWeightKg(),
weight.getRecognitionStatus(), weight.getAdjustmentReason(), weight.getAdjustedBy(),
weight.getAdjustedAt(), weight.getConfirmedBy(), weight.getConfirmedAt(), weight.getVersion());
}
private InboundOrderEntity requireInbound(long tenantId, long inboundId) {
InboundOrderEntity inbound = repository.findInbound(tenantId, inboundId);
if (inbound == null) {
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
}
return inbound;
}
private InboundMeasurementEntity requireMeasurement(long tenantId, long inboundId, long measurementId) {
InboundMeasurementEntity measurement = repository.findMeasurement(tenantId, inboundId, measurementId);
if (measurement == null) {
throw new BizException(ResultCode.NOT_FOUND, "测量批次不存在");
}
return measurement;
}
private InboundWeightResultEntity requireWeight(long tenantId, long inboundId, long resultId) {
InboundWeightResultEntity weight = repository.findWeight(tenantId, inboundId, resultId);
if (weight == null) {
throw new BizException(ResultCode.NOT_FOUND, "OCR 重量结果不存在");
}
return weight;
}
private void requireEditable(InboundOrderEntity inbound) {
if (!EDITABLE_INBOUND_STATUSES.contains(inbound.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT, "当前入库状态不允许维护测量数据");
}
}
private void requireVersion(Integer expected, Integer actual) {
if (expected == null || !expected.equals(actual)) {
throw concurrentModification();
}
}
private String rawOcrUnit(String unit) {
if (!StringUtils.hasText(unit)) {
return null;
}
return unit.trim().toUpperCase(Locale.ROOT);
}
private boolean isSupportedUnit(String unit) {
if (!StringUtils.hasText(unit)) {
return false;
}
String normalized = unit.trim().toUpperCase(Locale.ROOT);
return Set.of("KG", "KGS", "LB", "LBS").contains(normalized);
}
private String normalizeUnit(String unit) {
String normalized = unit.trim().toUpperCase(Locale.ROOT);
if ("KGS".equals(normalized)) {
normalized = "KG";
} else if ("LBS".equals(normalized)) {
normalized = "LB";
}
if (!Set.of("KG", "LB").contains(normalized)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "重量单位仅支持 KG 或 LB");
}
return normalized;
}
private BigDecimal toKg(BigDecimal value, String unit) {
BigDecimal kg = "LB".equals(unit) ? value.multiply(LB_TO_KG) : value;
return kg.setScale(3, RoundingMode.HALF_UP);
}
private BigDecimal effectiveValue(InboundWeightResultEntity weight) {
return weight.getAdjustedWeightValue() == null
? weight.getOcrWeightValue() : weight.getAdjustedWeightValue();
}
private String effectiveUnit(InboundWeightResultEntity weight) {
return weight.getAdjustedWeightUnit() == null
? weight.getOcrWeightUnit() : weight.getAdjustedWeightUnit();
}
private boolean isPositive(BigDecimal value) {
return value != null && value.compareTo(BigDecimal.ZERO) > 0;
}
private BizException concurrentModification() {
return new BizException(ResultCode.CONFLICT, "数据版本已变化,请刷新后重试");
}
}
@@ -0,0 +1,50 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.domain.InboundProcessStatus;
import com.baiyue.firstleg.dto.InboundOrderDetail;
import com.baiyue.firstleg.dto.InboundOrderQuery;
import com.baiyue.firstleg.dto.InboundOrderSummary;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.InboundOrderRepository;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
/** 入库单列表和详情只读应用服务,不执行任何状态推进。 */
@Service
public class InboundOrderQueryService {
private final InboundOrderRepository repository;
public InboundOrderQueryService(InboundOrderRepository repository) {
this.repository = repository;
}
public PageResult<InboundOrderSummary> page(long tenantId, InboundOrderQuery query) {
validateQuery(query);
return repository.page(tenantId, query);
}
public InboundOrderDetail detail(long tenantId, long inboundId) {
InboundOrderEntity order = repository.findOwned(tenantId, inboundId);
if (order == null) {
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
}
return repository.detail(tenantId, order);
}
private void validateQuery(InboundOrderQuery query) {
if (StringUtils.hasText(query.processStatus())
&& !InboundProcessStatus.VALUES.contains(query.processStatus())) {
throw new BizException(ResultCode.BAD_REQUEST, "入库流程状态不合法");
}
if (query.tab() != null && StringUtils.hasText(query.processStatus())) {
throw new BizException(ResultCode.BAD_REQUEST, "页签和流程状态不能同时指定");
}
if (query.expectedArrivalFrom() != null && query.expectedArrivalTo() != null
&& query.expectedArrivalFrom().isAfter(query.expectedArrivalTo())) {
throw new BizException(ResultCode.BAD_REQUEST, "预计到达开始时间不能晚于结束时间");
}
}
}
@@ -0,0 +1,184 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.MeasurementBatchCommand;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* 后台录入和第三方回传共用的测量批次持久化服务。
*
* <p>本服务只保存测量批次和明细,绝不推进入库单状态。第三方适配层应调用
* {@link #saveBatch(MeasurementBatchCommand)},由平台确认接口另行提交审核。</p>
*/
@Service
public class MeasurementBatchService {
static final String SOURCE_MANUAL = "MANUAL";
static final String SOURCE_THIRD_PARTY = "THIRD_PARTY";
static final String STATUS_DRAFT = "DRAFT";
static final String STATUS_RECEIVED = "RECEIVED";
static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167");
static final String CALCULATION_RULE_VERSION = "V1_CBM_CEILING_4_GIRTH_L2WH";
private final InboundMeasurementRepository repository;
public MeasurementBatchService(InboundMeasurementRepository repository) {
this.repository = repository;
}
@Transactional
public InboundMeasurementEntity saveBatch(MeasurementBatchCommand command) {
if (repository.findInbound(command.tenantId(), command.inboundId()) == null) {
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
}
validateSource(command);
List<InboundMeasurementItemEntity> calculatedItems = calculateItems(command);
InboundMeasurementEntity measurement;
if (command.measurementId() == null) {
measurement = newMeasurement(command);
applyTotals(measurement, calculatedItems);
repository.insertMeasurement(measurement);
} else {
measurement = requireEditableManualBatch(command);
applyHeader(measurement, command);
applyTotals(measurement, calculatedItems);
if (!repository.updateMeasurement(measurement)) {
throw concurrentModification();
}
}
calculatedItems.forEach(item -> {
item.setTenantId(command.tenantId());
item.setMeasurementId(measurement.getId());
});
repository.replaceItems(command.tenantId(), measurement.getId(), calculatedItems);
return measurement;
}
private void validateSource(MeasurementBatchCommand command) {
if (!SOURCE_MANUAL.equals(command.sourceType()) && !SOURCE_THIRD_PARTY.equals(command.sourceType())) {
throw new BizException(ResultCode.BAD_REQUEST, "测量数据来源不支持");
}
if (SOURCE_THIRD_PARTY.equals(command.sourceType())
&& (command.externalEventNo() == null || command.externalEventNo().isBlank())) {
throw new BizException(ResultCode.BAD_REQUEST, "第三方测量必须提供事件号");
}
if (command.items() == null || command.items().isEmpty()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细不能为空");
}
}
private InboundMeasurementEntity newMeasurement(MeasurementBatchCommand command) {
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setTenantId(command.tenantId());
measurement.setInboundId(command.inboundId());
measurement.setSourceType(command.sourceType());
measurement.setStatus(SOURCE_MANUAL.equals(command.sourceType()) ? STATUS_DRAFT : STATUS_RECEIVED);
measurement.setCreatedBy(command.operatorId());
measurement.setVersion(0);
applyHeader(measurement, command);
return measurement;
}
private InboundMeasurementEntity requireEditableManualBatch(MeasurementBatchCommand command) {
InboundMeasurementEntity measurement = repository.findMeasurement(
command.tenantId(), command.inboundId(), command.measurementId());
if (measurement == null) {
throw new BizException(ResultCode.NOT_FOUND, "测量批次不存在");
}
if (!SOURCE_MANUAL.equals(measurement.getSourceType()) || !STATUS_DRAFT.equals(measurement.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "仅人工草稿允许直接修改");
}
if (!Integer.valueOf(command.expectedVersion()).equals(measurement.getVersion())) {
throw concurrentModification();
}
return measurement;
}
private void applyHeader(InboundMeasurementEntity measurement, MeasurementBatchCommand command) {
measurement.setSourceCode(command.sourceCode());
measurement.setExternalEventNo(command.externalEventNo());
measurement.setDeviceCode(command.deviceCode());
measurement.setWarehouseCode(command.warehouseCode());
measurement.setMeasuredAt(command.measuredAt());
measurement.setRawPayloadJson(command.rawPayloadJson());
measurement.setOriginalDocumentNo(command.originalDocumentNo());
measurement.setOriginalGrossWeightKg(command.originalGrossWeightKg());
measurement.setOriginalVolumeCbm(command.originalVolumeCbm());
measurement.setActualGoodsName(command.actualGoodsName());
}
private List<InboundMeasurementItemEntity> calculateItems(MeasurementBatchCommand command) {
Set<String> itemNumbers = new HashSet<>();
return command.items().stream().map(source -> {
if (!itemNumbers.add(source.itemNo())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细号不能重复");
}
BigDecimal unitVolumeRaw = source.lengthCm().multiply(source.widthCm())
.multiply(source.heightCm()).divide(new BigDecimal("1000000"), 6, RoundingMode.HALF_UP);
BigDecimal unitVolume = unitVolumeRaw.setScale(4, RoundingMode.CEILING);
BigDecimal packageCount = BigDecimal.valueOf(source.packageCount());
BigDecimal totalVolumeRaw = unitVolumeRaw.multiply(packageCount).setScale(6, RoundingMode.HALF_UP);
BigDecimal totalVolume = unitVolume.multiply(packageCount).setScale(4, RoundingMode.CEILING);
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setItemNo(source.itemNo());
item.setBoxSpecification(source.boxSpecification());
item.setPackageCount(source.packageCount());
item.setLengthCm(source.lengthCm());
item.setWidthCm(source.widthCm());
item.setHeightCm(source.heightCm());
item.setGirthCm(source.lengthCm().add(source.widthCm().add(source.heightCm())
.multiply(BigDecimal.valueOf(2))).setScale(3, RoundingMode.CEILING));
item.setUnitWeightKg(source.unitWeightKg() == null ? null
: source.unitWeightKg().setScale(3, RoundingMode.CEILING));
item.setUnitVolumeCbm(unitVolume);
item.setVolumeRawCbm(totalVolumeRaw);
item.setVolumeCbm(totalVolume);
item.setTotalWeightKg(source.unitWeightKg() == null ? null
: source.unitWeightKg().multiply(packageCount).setScale(3, RoundingMode.CEILING));
item.setTotalVolumeCbm(totalVolume);
item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
item.setCalculationRuleVersion(CALCULATION_RULE_VERSION);
item.setSourceImageFileId(source.intelligentImageFileId());
item.setOverLimitWarning(source.overLimitWarning());
item.setOuterLengthCm(source.outerLengthCm());
item.setOuterWidthCm(source.outerWidthCm());
item.setOuterHeightCm(source.outerHeightCm());
item.setRemark(source.remark());
item.setCreatedAt(LocalDateTime.now());
return item;
}).toList();
}
private void applyTotals(InboundMeasurementEntity measurement,
List<InboundMeasurementItemEntity> items) {
long packages = items.stream().mapToLong(InboundMeasurementItemEntity::getPackageCount).sum();
if (packages > Integer.MAX_VALUE) {
throw new BizException(ResultCode.VALIDATION_FAILED, "测量总箱数超过系统上限");
}
BigDecimal rawVolume = items.stream().map(InboundMeasurementItemEntity::getVolumeRawCbm)
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(6, RoundingMode.HALF_UP);
BigDecimal volume = items.stream().map(InboundMeasurementItemEntity::getTotalVolumeCbm)
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(4, RoundingMode.CEILING);
measurement.setTotalPackageCount((int) packages);
measurement.setTotalVolumeRawCbm(rawVolume);
measurement.setTotalVolumeCbm(volume);
measurement.setVolumeWeightKg(volume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
}
private BizException concurrentModification() {
return new BizException(ResultCode.CONFLICT, "测量数据已被其他人修改,请刷新后重试");
}
}
@@ -0,0 +1,108 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
import com.baiyue.firstleg.config.InboundProperties;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
import feign.FeignException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Service
public class MeasurementLocationOccupationService {
private static final String SOURCE_SERVICE = "firstleg-service";
private final WarehousePalletLocationClient warehouse;
private final InboundPalletAllocationRepository allocations;
private final InboundWorkflowRepository workflow;
private final InboundProperties properties;
private final String internalToken;
public MeasurementLocationOccupationService(WarehousePalletLocationClient warehouse,
InboundPalletAllocationRepository allocations, InboundWorkflowRepository workflow,
InboundProperties properties,
@Value("${baiyue.security.internal-service-token}") String internalToken) {
this.warehouse = warehouse;
this.allocations = allocations;
this.workflow = workflow;
this.properties = properties;
this.internalToken = internalToken;
}
public void occupy(long tenantId, long inboundId, long operatorId, long measurementId,
int measurementVersion) {
InboundOrderEntity order = requireOrder(tenantId, inboundId);
long warehouseId = requireWarehouse(order);
BigDecimal volume = requireVolume(order);
String key = "measure-" + inboundId + "-" + measurementId + "-" + measurementVersion;
WarehousePalletLocationClient.BatchResult reserved = requireSuccess(call(() -> warehouse.reserve(
internalToken, new WarehousePalletLocationClient.ReserveRequest(tenantId, warehouseId,
SOURCE_SERVICE, inboundId, order.getInboundCode(), key, operatorId, volume,
properties.getDefaultPalletCapacityCbm(), "STANDARD",
LocalDateTime.now().plus(properties.getReservationTtl()), true))),
"测量完成后库位预占失败");
allocations.replace(tenantId, inboundId, warehouseId,
properties.getDefaultPalletCapacityCbm(), reserved);
try {
WarehousePalletLocationClient.BatchResult occupied = requireSuccess(call(() -> warehouse.confirm(
internalToken, new WarehousePalletLocationClient.ConfirmRequest(tenantId, warehouseId,
SOURCE_SERVICE, inboundId, operatorId, volume,
properties.getDefaultPalletCapacityCbm()))), "测量完成后库位占用失败");
allocations.replace(tenantId, inboundId, warehouseId,
properties.getDefaultPalletCapacityCbm(), occupied);
} catch (RuntimeException failure) {
try {
warehouse.release(internalToken, new WarehousePalletLocationClient.ReleaseRequest(
tenantId, SOURCE_SERVICE, inboundId, operatorId, "测量完成库位占用失败"));
} catch (RuntimeException releaseFailure) {
failure.addSuppressed(releaseFailure);
}
throw failure;
}
}
private InboundOrderEntity requireOrder(long tenantId, long inboundId) {
InboundOrderEntity order = workflow.findOwned(tenantId, inboundId);
if (order == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
return order;
}
private static long requireWarehouse(InboundOrderEntity order) {
if (order.getWarehouseId() == null || order.getWarehouseId() <= 0) {
throw new BizException(ResultCode.CONFLICT, "入库单缺少收货仓库");
}
return order.getWarehouseId();
}
private static BigDecimal requireVolume(InboundOrderEntity order) {
if (order.getActualVolumeCbm() == null || order.getActualVolumeCbm().signum() <= 0) {
throw new BizException(ResultCode.CONFLICT, "入库单缺少有效实测体积");
}
return order.getActualVolumeCbm();
}
private static <T> ApiResponse<T> call(RemoteCall<T> call) {
try { return call.invoke(); }
catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓储服务不可用");
}
}
private static <T> T requireSuccess(ApiResponse<T> response, String fallback) {
if (response == null || !response.successful() || response.data() == null) {
ResultCode code = response == null ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.of(response.code());
throw new BizException(code, response == null || response.message() == null
? fallback : response.message());
}
return response.data();
}
@FunctionalInterface
private interface RemoteCall<T> { ApiResponse<T> invoke(); }
}
@@ -0,0 +1,190 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.config.InboundProperties;
import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
import com.baiyue.firstleg.dto.MeasurementReviewRequest;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
/**
* 测量审核应用服务。审核是 MEASURE_PENDING 到计费阶段的唯一正常推进关口。
*/
@Service
public class MeasurementReviewService {
private final InboundWorkflowRepository repository;
private final InboundIdempotencyService idempotency;
private final InboundProperties properties;
public MeasurementReviewService(InboundWorkflowRepository repository,
InboundIdempotencyService idempotency,
InboundProperties properties) {
this.repository = repository;
this.idempotency = idempotency;
this.properties = properties;
}
@Transactional
public void approve(long tenantId, long operatorId, String operatorName, long inboundId,
String idempotencyKey, MeasurementReviewRequest request) {
review(tenantId, operatorId, operatorName, inboundId, idempotencyKey, request, true);
}
@Transactional
public void reject(long tenantId, long operatorId, String operatorName, long inboundId,
String idempotencyKey, MeasurementReviewRequest request) {
if (!StringUtils.hasText(request.reason())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "审核驳回必须填写原因");
}
review(tenantId, operatorId, operatorName, inboundId, idempotencyKey, request, false);
}
private void review(long tenantId, long operatorId, String operatorName, long inboundId,
String idempotencyKey, MeasurementReviewRequest request, boolean approved) {
String operation = approved ? "MEASURE_APPROVE" : "MEASURE_REJECT";
String canonical = inboundId + "|" + request.measurementId() + "|"
+ request.expectedVersion() + "|" + normalize(request.reason());
InboundIdempotencyService.Context context = idempotency.begin(
tenantId, operatorId, inboundId, operation, idempotencyKey, canonical);
if (context.replayed()) {
return;
}
InboundOrderEntity order = requireOrder(tenantId, inboundId);
requirePendingReview(order);
InboundMeasurementEntity measurement = repository.findMeasurement(
tenantId, inboundId, request.measurementId());
if (measurement == null) {
throw new BizException(ResultCode.NOT_FOUND, "待审核测量批次不存在");
}
if (!"SUBMITTED".equals(measurement.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "测量批次不是待审核状态");
}
if (repository.transitionReview(tenantId, inboundId, operatorId,
request.expectedVersion(), approved) != 1) {
throw concurrentConflict();
}
String result = approved ? "APPROVED" : "REJECTED";
if (repository.transitionMeasurement(tenantId, inboundId, request.measurementId(), result) != 1) {
throw concurrentConflict();
}
repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), result,
request.reason(), false, null, false, operatorId, operatorName));
repository.insertHistory(history(tenantId, inboundId, "MEASURE_PENDING",
approved ? "WAIT_PRICING" : "MEASURE_REJECTED",
approved ? "MEASURE_APPROVE" : "MEASURE_REJECT", request.reason(), operatorId, operatorName));
idempotency.complete(context, inboundId);
}
@Transactional
public void exceptionRelease(long tenantId, long operatorId, String operatorName, long inboundId,
String idempotencyKey, ExceptionReleaseRequest request) {
if (!StringUtils.hasText(request.reason())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "异常放行必须填写原因");
}
String canonical = inboundId + "|" + request.measurementId() + "|"
+ request.expectedVersion() + "|" + normalize(request.reason());
InboundIdempotencyService.Context context = idempotency.begin(
tenantId, operatorId, inboundId, "MEASURE_EXCEPTION_RELEASE", idempotencyKey, canonical);
if (context.replayed()) {
return;
}
if (!properties.isAllowRejectedMeasurement()) {
throw new BizException(ResultCode.CONFLICT, "测量驳回异常放行开关未开启");
}
InboundOrderEntity order = requireOrder(tenantId, inboundId);
if (!"MEASURE_REJECTED".equals(order.getProcessStatus())
|| !"REJECTED".equals(order.getMeasureReviewStatus())) {
throw new BizException(ResultCode.CONFLICT, "只有测量审核驳回单可异常放行");
}
InboundMeasurementEntity measurement = repository.findMeasurement(
tenantId, inboundId, request.measurementId());
if (measurement == null || !"REJECTED".equals(measurement.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "被放行测量批次必须为已驳回状态");
}
if (repository.releaseRejected(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) {
throw concurrentConflict();
}
repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), "REJECTED",
null, true, request.reason(), true, operatorId, operatorName));
repository.insertHistory(history(tenantId, inboundId, "MEASURE_REJECTED", "WAIT_PRICING",
"MEASURE_EXCEPTION_RELEASE", request.reason(), operatorId, operatorName));
idempotency.complete(context, inboundId);
}
public PageResult<MeasureReviewRecordEntity> history(
long tenantId, long inboundId, long page, long size) {
requireOrder(tenantId, inboundId);
return repository.reviewHistory(tenantId, inboundId, page, size);
}
private InboundOrderEntity requireOrder(long tenantId, long inboundId) {
InboundOrderEntity order = repository.findOwned(tenantId, inboundId);
if (order == null) {
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
}
return order;
}
private static void requirePendingReview(InboundOrderEntity order) {
if (!"MEASURE_PENDING".equals(order.getProcessStatus())
|| !"PENDING".equals(order.getMeasureReviewStatus())) {
throw new BizException(ResultCode.CONFLICT,
"仅待测量审核单可审核,已审核请求请使用原 Idempotency-Key 重放");
}
}
private static BizException concurrentConflict() {
return new BizException(ResultCode.CONFLICT, "入库单已被并发更新,请刷新后重试");
}
private static String normalize(String value) {
return value == null ? "" : value.trim();
}
private static MeasureReviewRecordEntity reviewRecord(
long tenantId, long inboundId, long measurementId, String result, String reviewReason,
boolean exceptionRelease, String releaseReason, boolean configValue,
long operatorId, String operatorName) {
MeasureReviewRecordEntity record = new MeasureReviewRecordEntity();
record.setTenantId(tenantId);
record.setInboundId(inboundId);
record.setMeasurementId(measurementId);
record.setReviewResult(result);
record.setReviewReason(normalize(reviewReason));
record.setExceptionRelease(exceptionRelease);
record.setReleaseReason(releaseReason);
record.setAllowRejectedConfig(configValue);
record.setReviewerId(operatorId);
record.setReviewerName(operatorName);
record.setReviewedAt(LocalDateTime.now());
return record;
}
static InboundStatusHistoryEntity history(long tenantId, long inboundId, String from, String to,
String action, String reason,
long operatorId, String operatorName) {
InboundStatusHistoryEntity history = new InboundStatusHistoryEntity();
history.setTenantId(tenantId);
history.setInboundId(inboundId);
history.setFromStatus(from);
history.setToStatus(to);
history.setAction(action);
history.setReason(reason);
history.setOperatorId(operatorId);
history.setOperatorName(operatorName);
history.setOccurredAt(LocalDateTime.now());
return history;
}
}
@@ -0,0 +1,27 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class MeasurementSubmissionService {
private final InboundMeasurementService measurements;
private final MeasurementLocationOccupationService locations;
public MeasurementSubmissionService(InboundMeasurementService measurements,
MeasurementLocationOccupationService locations) {
this.measurements = measurements;
this.locations = locations;
}
@Transactional
public InboundMeasurementResponses.MeasurementSubmitted submit(
long tenantId, long inboundId, long operatorId,
InboundMeasurementRequests.SubmitRequest request) {
var result = measurements.submit(tenantId, inboundId, operatorId, request);
locations.occupy(tenantId, inboundId, operatorId, result.measurementId(), result.version());
return result;
}
}
@@ -0,0 +1,31 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.MerchantInboundBillingResponse;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.InboundOrderRepository;
import org.springframework.stereotype.Service;
@Service
public class MerchantInboundBillingService {
private final InboundOrderRepository orders;
private final FbaPricingService pricing;
public MerchantInboundBillingService(InboundOrderRepository orders, FbaPricingService pricing) {
this.orders = orders;
this.pricing = pricing;
}
public MerchantInboundBillingResponse find(long tenantId, long orderId) {
InboundOrderEntity inbound = orders.findByOrderId(tenantId, orderId);
if (inbound == null) {
throw new BizException(ResultCode.NOT_FOUND, "订单尚未生成入库单");
}
if (!"INBOUNDED".equals(inbound.getProcessStatus()) || !"SENT".equals(inbound.getFeeStatus())) {
throw new BizException(ResultCode.NOT_FOUND, "入库费用尚未由客服审核并发布");
}
return new MerchantInboundBillingResponse(orders.detail(tenantId, inbound),
pricing.latest(tenantId, inbound.getId()));
}
}
@@ -0,0 +1,91 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.config.MeasurementOpenApiProperties;
import com.baiyue.firstleg.entity.ThirdPartyCallLogEntity;
import com.baiyue.firstleg.mapper.ThirdPartyCallLogMapper;
import com.baiyue.firstleg.security.MeasurementRequestAuthenticator;
import com.baiyue.firstleg.security.MeasurementRequestHeaders;
import org.springframework.stereotype.Service;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
/** 将每次第三方 API 调用独立留档,业务事务回滚也不会丢失失败记录。 */
@Service
public class ThirdPartyCallLogService {
private final ThirdPartyCallLogMapper mapper;
private final MeasurementOpenApiProperties properties;
public ThirdPartyCallLogService(ThirdPartyCallLogMapper mapper,
MeasurementOpenApiProperties properties) {
this.mapper = mapper;
this.properties = properties;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public long start(String method, String path, MeasurementRequestHeaders headers, String rawBody) {
ThirdPartyCallLogEntity log = new ThirdPartyCallLogEntity();
log.setId(IdWorker.getId());
MeasurementOpenApiProperties.AppCredential app = properties.getApps().get(headers.appKey());
log.setTenantId(app == null || app.getTenantId() == null ? 0L : app.getTenantId());
log.setAppKey(limit(headers.appKey(), 128));
log.setSignatureSubject(limit(headers.appKey(), 128));
log.setRequestMethod(method);
log.setRequestPath(limit(path, 255));
log.setRequestTimestamp(limit(headers.timestamp(), 32));
log.setNonce(limit(headers.nonce(), 128));
log.setIdempotencyKey(limit(headers.idempotencyKey(), 128));
log.setSignatureValue(limit(headers.signature(), 128));
log.setRequestHash(MeasurementRequestAuthenticator.sha256Hex(rawBody));
log.setRawPayload(rawBody == null ? "" : rawBody);
log.setProcessingStatus("PROCESSING");
log.setRequestedAt(LocalDateTime.now());
mapper.insert(log);
return log.getId();
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void complete(long id, String status, String code, String message, String resultJson) {
ThirdPartyCallLogEntity update = new ThirdPartyCallLogEntity();
update.setId(id);
update.setProcessingStatus(status);
update.setResultCode(limit(code, 32));
update.setResultMessage(limit(message, 1000));
update.setResultJson(resultJson);
update.setProcessedAt(LocalDateTime.now());
mapper.updateById(update);
}
/** 签名通过后原子认领 Idempotency-Key;非法签名不会污染幂等空间。 */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public boolean claimIdempotency(long logId) {
ThirdPartyCallLogEntity update = new ThirdPartyCallLogEntity();
update.setId(logId);
update.setIdempotencyAnchor(1);
try {
mapper.updateById(update);
return true;
} catch (DuplicateKeyException exception) {
return false;
}
}
public ThirdPartyCallLogEntity findIdempotencyOwner(long tenantId, String appKey, String idempotencyKey) {
return mapper.selectOne(new LambdaQueryWrapper<ThirdPartyCallLogEntity>()
.eq(ThirdPartyCallLogEntity::getTenantId, tenantId)
.eq(ThirdPartyCallLogEntity::getAppKey, appKey)
.eq(ThirdPartyCallLogEntity::getIdempotencyKey, idempotencyKey)
.eq(ThirdPartyCallLogEntity::getIdempotencyAnchor, 1));
}
private String limit(String value, int max) {
if (value == null || value.length() <= max) {
return value;
}
return value.substring(0, max);
}
}
@@ -23,6 +23,11 @@ spring:
baiyue:
security:
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
firstleg:
inbound:
allow-rejected-measurement: false
default-pallet-capacity-cbm: ${DEFAULT_PALLET_CAPACITY_CBM:1.200000}
reservation-ttl: ${PALLET_RESERVATION_TTL:30m}
management.endpoints.web.exposure.include: health,info,prometheus
logging:
@@ -0,0 +1,52 @@
SET NAMES utf8mb4;
USE baiyue_firstleg;
-- 二期 FBA 计费增量迁移。通过 information_schema 判断,允许重复部署。
DROP PROCEDURE IF EXISTS phase2_fba_add_column;
DELIMITER $$
CREATE PROCEDURE phase2_fba_add_column(IN p_table VARCHAR(64), IN p_column VARCHAR(64), IN p_definition TEXT)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = p_table AND COLUMN_NAME = p_column
) THEN
SET @sql = CONCAT('ALTER TABLE `', p_table, '` ADD COLUMN `', p_column, '` ', p_definition);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
END IF;
END$$
DELIMITER ;
CALL phase2_fba_add_column('fl_inbound_order', 'carrier_id', "BIGINT UNSIGNED NULL COMMENT '计费船司逻辑ID' AFTER channel_name");
CALL phase2_fba_add_column('fl_inbound_order', 'carrier_name', "VARCHAR(128) NULL COMMENT '计费船司名称快照' AFTER carrier_id");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'business_type', "VARCHAR(32) NOT NULL DEFAULT 'FBA' COMMENT '业务或计费类型' AFTER origin_warehouse_name");
CALL phase2_fba_add_column('fl_customer_charge_rule', 'version', "INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号' AFTER deleted");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'version', "INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号' AFTER deleted");
CALL phase2_fba_add_column('fl_inbound_fee', 'rule_code', "VARCHAR(50) NOT NULL DEFAULT '' COMMENT '客户规则编码快照' AFTER carrier_rate_version");
CALL phase2_fba_add_column('fl_inbound_fee', 'customer_id', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '客户ID快照' AFTER rule_code");
CALL phase2_fba_add_column('fl_inbound_fee', 'customer_name', "VARCHAR(128) NOT NULL DEFAULT '' COMMENT '客户名称快照' AFTER customer_id");
CALL phase2_fba_add_column('fl_inbound_fee', 'business_type', "VARCHAR(32) NOT NULL DEFAULT '' COMMENT '业务类型快照' AFTER customer_name");
CALL phase2_fba_add_column('fl_inbound_fee', 'tax_mode', "VARCHAR(24) NULL COMMENT '包税模式快照' AFTER business_type");
CALL phase2_fba_add_column('fl_inbound_fee', 'rounding_scale', "TINYINT UNSIGNED NOT NULL DEFAULT 3 COMMENT '计费量保留位数快照' AFTER tax_mode");
CALL phase2_fba_add_column('fl_inbound_fee', 'rounding_mode', "VARCHAR(24) NOT NULL DEFAULT 'CEILING' COMMENT '舍入方式快照' AFTER rounding_scale");
CALL phase2_fba_add_column('fl_inbound_fee', 'rule_effective_from', "DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '客户规则生效开始快照' AFTER rounding_mode");
CALL phase2_fba_add_column('fl_inbound_fee', 'rule_effective_to', "DATETIME(3) NULL COMMENT '客户规则生效结束快照' AFTER rule_effective_from");
CALL phase2_fba_add_column('fl_inbound_fee', 'rate_code', "VARCHAR(50) NOT NULL DEFAULT '' COMMENT '船司价格编码快照' AFTER rule_effective_to");
CALL phase2_fba_add_column('fl_inbound_fee', 'origin_warehouse_id', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '起运仓ID快照' AFTER rate_code");
CALL phase2_fba_add_column('fl_inbound_fee', 'origin_warehouse_name', "VARCHAR(128) NOT NULL DEFAULT '' COMMENT '起运仓名称快照' AFTER origin_warehouse_id");
CALL phase2_fba_add_column('fl_inbound_fee', 'channel_code', "VARCHAR(64) NOT NULL DEFAULT '' COMMENT '渠道编码快照' AFTER origin_warehouse_name");
CALL phase2_fba_add_column('fl_inbound_fee', 'carrier_id', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '船司ID快照' AFTER channel_code");
CALL phase2_fba_add_column('fl_inbound_fee', 'carrier_name', "VARCHAR(128) NOT NULL DEFAULT '' COMMENT '船司名称快照' AFTER carrier_id");
CALL phase2_fba_add_column('fl_inbound_fee', 'destination_code', "VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'FBA仓编码快照' AFTER carrier_name");
CALL phase2_fba_add_column('fl_inbound_fee', 'rate_effective_from', "DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '价格生效开始快照' AFTER destination_code");
CALL phase2_fba_add_column('fl_inbound_fee', 'rate_effective_to', "DATETIME(3) NULL COMMENT '价格生效结束快照' AFTER rate_effective_from");
CALL phase2_fba_add_column('fl_inbound_idempotency', 'result_resource_id', "BIGINT UNSIGNED NULL COMMENT '成功结果资源ID' AFTER result_json");
DROP PROCEDURE IF EXISTS phase2_fba_add_column;
-- 旧索引可能缺少业务类型、船司和结束时间;用新索引名并存,避免破坏线上依赖。
SET @idx_exists = (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_carrier_fba_rate' AND INDEX_NAME = 'idx_fba_rate_unique_match');
SET @sql = IF(@idx_exists = 0,
'ALTER TABLE fl_carrier_fba_rate ADD INDEX idx_fba_rate_unique_match (tenant_id, origin_warehouse_id, business_type, channel_code, carrier_id, destination_code, charge_unit, status, effective_from, effective_to)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -0,0 +1,90 @@
-- 二期入库测量与 OCR 维护增量(只增不删,可重复执行)。
-- 执行顺序:phase2-inbound-schema.sql 后执行本文件;已有二期库只需执行本文件。
SET NAMES utf8mb4;
USE baiyue_firstleg;
DROP PROCEDURE IF EXISTS phase2_measure_add_column;
DELIMITER $$
CREATE PROCEDURE phase2_measure_add_column(
IN p_table_name VARCHAR(64),
IN p_column_name VARCHAR(64),
IN p_column_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = p_table_name
AND column_name = p_column_name
) THEN
SET @phase2_measure_sql = CONCAT(
'ALTER TABLE `', REPLACE(p_table_name, '`', '``'),
'` ADD COLUMN `', REPLACE(p_column_name, '`', '``'), '` ', p_column_definition
);
PREPARE phase2_measure_statement FROM @phase2_measure_sql;
EXECUTE phase2_measure_statement;
DEALLOCATE PREPARE phase2_measure_statement;
END IF;
END$$
DELIMITER ;
CALL phase2_measure_add_column('fl_inbound_measurement', 'original_document_no',
'VARCHAR(64) NULL COMMENT ''原单据号码快照'' AFTER raw_payload_json');
CALL phase2_measure_add_column('fl_inbound_measurement', 'original_gross_weight_kg',
'DECIMAL(18,3) NULL COMMENT ''原单据毛重kg'' AFTER original_document_no');
CALL phase2_measure_add_column('fl_inbound_measurement', 'original_volume_cbm',
'DECIMAL(18,4) NULL COMMENT ''原单据体积或材积m3'' AFTER original_gross_weight_kg');
CALL phase2_measure_add_column('fl_inbound_measurement', 'actual_goods_name',
'VARCHAR(255) NULL COMMENT ''实际品名'' AFTER original_volume_cbm');
CALL phase2_measure_add_column('fl_inbound_measurement', 'volume_weight_kg',
'DECIMAL(18,3) NULL COMMENT ''本批次总材积重kg'' AFTER total_volume_cbm');
CALL phase2_measure_add_column('fl_inbound_measurement', 'version',
'INT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本号'' AFTER updated_at');
CALL phase2_measure_add_column('fl_inbound_measurement_item', 'box_specification',
'VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''箱规标识'' AFTER item_no');
CALL phase2_measure_add_column('fl_inbound_measurement_item', 'girth_cm',
'DECIMAL(12,3) NOT NULL DEFAULT 0 COMMENT ''周长cm,规则为长+2*(宽+高)'' AFTER height_cm');
CALL phase2_measure_add_column('fl_inbound_measurement_item', 'unit_weight_kg',
'DECIMAL(18,3) NULL COMMENT ''单箱重量kg'' AFTER girth_cm');
CALL phase2_measure_add_column('fl_inbound_measurement_item', 'unit_volume_cbm',
'DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''单箱业务体积m3'' AFTER unit_weight_kg');
CALL phase2_measure_add_column('fl_inbound_measurement_item', 'total_weight_kg',
'DECIMAL(18,3) NULL COMMENT ''本箱规总重量kg'' AFTER volume_cbm');
CALL phase2_measure_add_column('fl_inbound_measurement_item', 'total_volume_cbm',
'DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''本箱规总体积m3'' AFTER total_weight_kg');
CALL phase2_measure_add_column('fl_inbound_measurement_item', 'volume_weight_kg',
'DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''本箱规材积重kg'' AFTER total_volume_cbm');
CALL phase2_measure_add_column('fl_inbound_measurement_item', 'over_limit_warning',
'VARCHAR(500) NULL COMMENT ''超线提醒快照'' AFTER source_image_file_id');
CALL phase2_measure_add_column('fl_inbound_measurement_item', 'outer_length_cm',
'DECIMAL(12,3) NULL COMMENT ''外箱规长度cm'' AFTER over_limit_warning');
CALL phase2_measure_add_column('fl_inbound_measurement_item', 'outer_width_cm',
'DECIMAL(12,3) NULL COMMENT ''外箱规宽度cm'' AFTER outer_length_cm');
CALL phase2_measure_add_column('fl_inbound_measurement_item', 'outer_height_cm',
'DECIMAL(12,3) NULL COMMENT ''外箱规高度cm'' AFTER outer_width_cm');
DROP PROCEDURE IF EXISTS phase2_measure_add_column;
CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision (
id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量修订记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
weight_result_id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量结果ID',
revision_no INT UNSIGNED NOT NULL COMMENT '结果版本号,与乐观锁版本一致',
action VARCHAR(24) NOT NULL COMMENT '动作:CORRECT或CONFIRM',
before_weight_value DECIMAL(18,3) NULL COMMENT '变更前原单位重量',
before_weight_unit VARCHAR(8) NULL COMMENT '变更前单位',
after_weight_value DECIMAL(18,3) NULL COMMENT '变更后原单位重量',
after_weight_unit VARCHAR(8) NULL COMMENT '变更后单位',
final_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的重量kg',
reason VARCHAR(500) NULL COMMENT '人工修正原因',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
operated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间',
PRIMARY KEY (id),
UNIQUE KEY uk_weight_revision_no (tenant_id, weight_result_id, revision_no),
KEY idx_weight_revision_time (tenant_id, inbound_id, operated_at),
CONSTRAINT fk_weight_revision_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id),
CONSTRAINT fk_weight_revision_result FOREIGN KEY (weight_result_id) REFERENCES fl_inbound_weight_result (id)
) ENGINE=InnoDB COMMENT='OCR重量人工修正和确认的不可变版本历史';
@@ -0,0 +1,533 @@
SET NAMES utf8mb4;
CREATE DATABASE IF NOT EXISTS baiyue_firstleg
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_inbound_order (
id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
task_id BIGINT UNSIGNED NULL COMMENT '头程履约任务ID',
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID,不建立跨库外键',
order_no VARCHAR(32) NOT NULL COMMENT '订单号快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '租户内唯一入仓码',
process_status VARCHAR(24) NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态',
order_status_snapshot VARCHAR(24) NOT NULL COMMENT '订单状态快照',
measure_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '测量数据审核状态',
fee_status VARCHAR(24) NOT NULL DEFAULT 'UNPRICED' COMMENT '费用业务状态',
fee_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态',
notification_status VARCHAR(24) NOT NULL DEFAULT 'NOT_CREATED' COMMENT '客户通知状态',
expected_arrival_at DATETIME(3) NULL COMMENT '预计到达时间',
customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID',
customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照',
company_id BIGINT UNSIGNED NULL COMMENT '运营公司逻辑ID',
company_name VARCHAR(128) NULL COMMENT '运营公司名称快照',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '实际作业仓库ID',
warehouse_name VARCHAR(128) NOT NULL COMMENT '实际作业仓库名称快照',
delivery_warehouse_id BIGINT UNSIGNED NULL COMMENT '交货仓逻辑ID',
delivery_warehouse_name VARCHAR(128) NULL COMMENT '交货仓名称快照',
business_type VARCHAR(32) NOT NULL COMMENT '业务类型',
channel_code VARCHAR(64) NULL COMMENT '渠道编码',
channel_name VARCHAR(128) NULL COMMENT '渠道名称快照',
carrier_id BIGINT UNSIGNED NULL COMMENT '计费船司逻辑ID',
carrier_name VARCHAR(128) NULL COMMENT '计费船司名称快照',
tax_mode VARCHAR(24) NULL COMMENT '买单或退税模式',
container_mode VARCHAR(24) NULL COMMENT '整柜或散货模式',
customs_inspection TINYINT(1) NULL COMMENT '是否商检',
destination_type VARCHAR(24) NULL COMMENT '目的仓类型',
destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '目的仓逻辑ID',
destination_code VARCHAR(32) NULL COMMENT '目的仓编码,例如FBA仓编码',
destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照',
hold_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当前是否扣货',
current_hold_reason VARCHAR(500) NULL COMMENT '当前扣货理由快照',
total_package_count INT UNSIGNED NULL COMMENT '确认后的总箱数',
actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认后的实际总毛重kg',
chargeable_weight_kg DECIMAL(18,3) NULL COMMENT '当前费用快照的计费重量kg',
actual_volume_cbm DECIMAL(18,4) NULL COMMENT '确认后的实际总体积m3',
chargeable_volume_cbm DECIMAL(18,4) NULL COMMENT '当前费用快照的计费体积m3',
pallet_count INT UNSIGNED NULL COMMENT '已分配总托盘数快照',
volume_weight_kg DECIMAL(18,3) NULL COMMENT '总材积重kg',
actual_net_weight_kg DECIMAL(18,3) NULL COMMENT '总净重kg',
measure_completed_at DATETIME(3) NULL COMMENT '测量完成时间',
inbounded_at DATETIME(3) NULL COMMENT '入库完成时间',
express_company_code VARCHAR(64) NULL COMMENT '快递公司编码快照',
express_company_name VARCHAR(128) NULL COMMENT '快递公司名称快照',
express_tracking_no VARCHAR(128) NULL COMMENT '快递单号快照',
public_remark VARCHAR(1000) NULL COMMENT '客户可见备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_inbound_tenant_code (tenant_id, inbound_code),
UNIQUE KEY uk_inbound_tenant_order (tenant_id, order_id),
KEY idx_inbound_process_time (tenant_id, process_status, created_at, id),
KEY idx_inbound_measure_time (tenant_id, measure_review_status, created_at, id),
KEY idx_inbound_fee_time (tenant_id, fee_review_status, created_at, id),
KEY idx_inbound_warehouse_status (tenant_id, warehouse_id, process_status, created_at),
KEY idx_inbound_customer_time (tenant_id, customer_id, created_at),
KEY idx_inbound_company_time (tenant_id, company_id, created_at),
KEY idx_inbound_arrival (tenant_id, expected_arrival_at),
KEY idx_inbound_destination (tenant_id, destination_code, created_at),
KEY idx_inbound_channel (tenant_id, channel_code, created_at),
CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id)
) ENGINE=InnoDB COMMENT='二期头程入库单聚合根和列表查询快照';
CREATE TABLE IF NOT EXISTS fl_inbound_pickup_snapshot (
id BIGINT UNSIGNED NOT NULL COMMENT '提货信息快照ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
pickup_method VARCHAR(24) NULL COMMENT '提货方式',
pickup_at DATETIME(3) NULL COMMENT '提货时间',
contact_name VARCHAR(64) NULL COMMENT '提货联系人',
contact_phone VARCHAR(32) NULL COMMENT '提货联系人电话',
contact_email VARCHAR(128) NULL COMMENT '提货联系人邮箱',
country_code CHAR(2) NULL COMMENT '国家二字码',
province VARCHAR(64) NULL COMMENT '省或州',
city VARCHAR(64) NULL COMMENT '城市',
district VARCHAR(64) NULL COMMENT '区县',
address_line VARCHAR(512) NULL COMMENT '提货详细地址',
postal_code VARCHAR(20) NULL COMMENT '提货邮编',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_pickup_inbound (tenant_id, inbound_id),
CONSTRAINT fk_pickup_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='入库单提货信息不可变快照';
CREATE TABLE IF NOT EXISTS fl_inbound_destination_snapshot (
id BIGINT UNSIGNED NOT NULL COMMENT '目的仓地址快照ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
destination_type VARCHAR(24) NULL COMMENT '目的仓类型',
destination_code VARCHAR(32) NULL COMMENT '目的仓编码',
destination_name VARCHAR(128) NULL COMMENT '目的仓名称',
country_code CHAR(2) NULL COMMENT '国家二字码',
state_province VARCHAR(100) NULL COMMENT '省或州',
city VARCHAR(100) NULL COMMENT '城市',
address_line VARCHAR(512) NULL COMMENT '详细地址',
postal_code VARCHAR(20) NULL COMMENT '邮编',
contact_name VARCHAR(64) NULL COMMENT '联系人',
contact_phone VARCHAR(32) NULL COMMENT '联系人电话',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_destination_inbound (tenant_id, inbound_id),
CONSTRAINT fk_destination_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='入库单目的仓地址和联系人快照';
CREATE TABLE IF NOT EXISTS fl_inbound_transport_snapshot (
id BIGINT UNSIGNED NOT NULL COMMENT '运输信息快照ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
loading_status VARCHAR(24) NULL COMMENT '配载状态快照',
container_no VARCHAR(64) NULL COMMENT '集装箱号',
cabinet_no VARCHAR(64) NULL COMMENT '柜号或内部柜单号',
vessel_name VARCHAR(128) NULL COMMENT '船名',
voyage_no VARCHAR(64) NULL COMMENT '航次',
sailing_at DATETIME(3) NULL COMMENT '开船时间',
port_arrival_at DATETIME(3) NULL COMMENT '到港时间',
source_updated_at DATETIME(3) NULL COMMENT '上游数据更新时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_transport_inbound (tenant_id, inbound_id),
KEY idx_transport_container (tenant_id, container_no),
KEY idx_transport_cabinet (tenant_id, cabinet_no),
KEY idx_transport_loading (tenant_id, loading_status, sailing_at),
CONSTRAINT fk_transport_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='入库单关联运输信息只读快照';
CREATE TABLE IF NOT EXISTS fl_inbound_measurement (
id BIGINT UNSIGNED NOT NULL COMMENT '测量批次ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
source_type VARCHAR(24) NOT NULL COMMENT '数据来源:THIRD_PARTY或MANUAL',
source_code VARCHAR(64) NULL COMMENT '第三方应用或来源编码',
external_event_no VARCHAR(128) NULL COMMENT '第三方事件号',
device_code VARCHAR(64) NULL COMMENT '测量设备编码',
warehouse_code VARCHAR(50) NULL COMMENT '第三方回传仓库编码',
measured_at DATETIME(3) NULL COMMENT '设备测量时间',
status VARCHAR(24) NOT NULL DEFAULT 'RECEIVED' COMMENT '测量批次状态',
raw_payload_json JSON NULL COMMENT '第三方原始报文',
original_document_no VARCHAR(64) NULL COMMENT '原单据号码快照',
original_gross_weight_kg DECIMAL(18,3) NULL COMMENT '原单据毛重kg',
original_volume_cbm DECIMAL(18,4) NULL COMMENT '原单据体积或材积m3',
actual_goods_name VARCHAR(255) NULL COMMENT '实际品名',
total_package_count INT UNSIGNED NULL COMMENT '本批次总箱数',
total_volume_raw_cbm DECIMAL(18,6) NULL COMMENT '本批次原始总体积m3',
total_volume_cbm DECIMAL(18,4) NULL COMMENT '本批次业务总体积m3',
volume_weight_kg DECIMAL(18,3) NULL COMMENT '本批次总材积重kg',
created_by BIGINT UNSIGNED NULL COMMENT '创建人或系统主体ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_measure_external_event (tenant_id, source_code, external_event_no),
KEY idx_measure_inbound_time (tenant_id, inbound_id, created_at),
CONSTRAINT fk_measure_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='入库长宽高测量批次和第三方原始数据';
CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item (
id BIGINT UNSIGNED NOT NULL COMMENT '测量明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
measurement_id BIGINT UNSIGNED NOT NULL COMMENT '测量批次ID',
item_no VARCHAR(32) NOT NULL COMMENT '批次内明细号',
box_specification VARCHAR(64) NOT NULL COMMENT '箱规标识',
package_count INT UNSIGNED NOT NULL COMMENT '相同箱规箱数',
length_cm DECIMAL(12,3) NOT NULL COMMENT '原始长度cm',
width_cm DECIMAL(12,3) NOT NULL COMMENT '原始宽度cm',
height_cm DECIMAL(12,3) NOT NULL COMMENT '原始高度cm',
girth_cm DECIMAL(12,3) NOT NULL COMMENT '周长cm,规则为长+2*(宽+高)',
unit_weight_kg DECIMAL(18,3) NULL COMMENT '单箱重量kg,第三方仅回传尺寸时可为空',
unit_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '单箱业务体积m3',
volume_raw_cbm DECIMAL(18,6) NOT NULL COMMENT '原始体积m3',
volume_cbm DECIMAL(18,4) NOT NULL COMMENT '业务体积m3',
total_weight_kg DECIMAL(18,3) NULL COMMENT '本箱规总重量kg',
total_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '本箱规总体积m3',
volume_weight_kg DECIMAL(18,3) NOT NULL COMMENT '本箱规材积重kg',
calculation_rule_version VARCHAR(32) NOT NULL COMMENT '体积计算和舍入规则版本',
source_image_file_id BIGINT UNSIGNED NULL COMMENT '测量设备图片文件ID',
over_limit_warning VARCHAR(500) NULL COMMENT '超线提醒快照',
outer_length_cm DECIMAL(12,3) NULL COMMENT '外箱规长度cm',
outer_width_cm DECIMAL(12,3) NULL COMMENT '外箱规宽度cm',
outer_height_cm DECIMAL(12,3) NULL COMMENT '外箱规高度cm',
remark VARCHAR(500) NULL COMMENT '测量明细备注',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_measure_item_no (tenant_id, measurement_id, item_no),
CONSTRAINT fk_measure_item_batch FOREIGN KEY (measurement_id) REFERENCES fl_inbound_measurement (id)
) ENGINE=InnoDB COMMENT='入库长宽高和箱数测量明细';
CREATE TABLE IF NOT EXISTS fl_inbound_weight_result (
id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量结果ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
file_id BIGINT UNSIGNED NOT NULL COMMENT '称重图片文件ID',
ocr_provider VARCHAR(64) NULL COMMENT 'OCR服务商编码',
ocr_raw_text VARCHAR(1000) NULL COMMENT 'OCR原始文本',
ocr_weight_value DECIMAL(18,3) NULL COMMENT 'OCR识别原始重量值',
ocr_weight_unit VARCHAR(8) NULL COMMENT 'OCR识别原始单位',
confidence DECIMAL(6,5) NULL COMMENT '识别置信度',
adjusted_weight_value DECIMAL(18,3) NULL COMMENT '人工修正后的原单位重量',
adjusted_weight_unit VARCHAR(8) NULL COMMENT '人工修正后的单位',
final_weight_kg DECIMAL(18,3) NULL COMMENT '确认并统一换算后的重量kg',
recognition_status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '识别和确认状态',
adjustment_reason VARCHAR(500) NULL COMMENT '人工修正原因',
adjusted_by BIGINT UNSIGNED NULL COMMENT '修正人ID',
adjusted_at DATETIME(3) NULL COMMENT '修正时间',
confirmed_by BIGINT UNSIGNED NULL COMMENT '确认人ID',
confirmed_at DATETIME(3) NULL COMMENT '确认时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_weight_inbound_file (tenant_id, inbound_id, file_id),
KEY idx_weight_inbound_status (tenant_id, inbound_id, recognition_status),
CONSTRAINT fk_weight_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='称重图片OCR原值、人工修正值和确认重量';
CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision (
id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量修订记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
weight_result_id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量结果ID',
revision_no INT UNSIGNED NOT NULL COMMENT '结果版本号,与乐观锁版本一致',
action VARCHAR(24) NOT NULL COMMENT '动作:CORRECT或CONFIRM',
before_weight_value DECIMAL(18,3) NULL COMMENT '变更前原单位重量',
before_weight_unit VARCHAR(8) NULL COMMENT '变更前单位',
after_weight_value DECIMAL(18,3) NULL COMMENT '变更后原单位重量',
after_weight_unit VARCHAR(8) NULL COMMENT '变更后单位',
final_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的重量kg',
reason VARCHAR(500) NULL COMMENT '人工修正原因',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
operated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间',
PRIMARY KEY (id),
UNIQUE KEY uk_weight_revision_no (tenant_id, weight_result_id, revision_no),
KEY idx_weight_revision_time (tenant_id, inbound_id, operated_at),
CONSTRAINT fk_weight_revision_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id),
CONSTRAINT fk_weight_revision_result FOREIGN KEY (weight_result_id) REFERENCES fl_inbound_weight_result (id)
) ENGINE=InnoDB COMMENT='OCR重量人工修正和确认的不可变版本历史';
CREATE TABLE IF NOT EXISTS fl_measure_review_record (
id BIGINT UNSIGNED NOT NULL COMMENT '测量审核记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
measurement_id BIGINT UNSIGNED NULL COMMENT '被审核的测量批次ID',
review_result VARCHAR(24) NOT NULL COMMENT '审核结果',
review_reason VARCHAR(1000) NULL COMMENT '审核或驳回原因',
exception_release TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否异常放行',
release_reason VARCHAR(1000) NULL COMMENT '异常放行原因',
allow_rejected_config TINYINT(1) NOT NULL DEFAULT 0 COMMENT '操作时Nacos异常放行开关值',
reviewer_id BIGINT UNSIGNED NOT NULL COMMENT '审核人ID',
reviewer_name VARCHAR(64) NULL COMMENT '审核人名称快照',
reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '审核时间',
PRIMARY KEY (id),
KEY idx_review_inbound_time (tenant_id, inbound_id, reviewed_at),
KEY idx_review_exception_release (tenant_id, inbound_id, exception_release, reviewed_at),
CONSTRAINT fk_review_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id),
CONSTRAINT fk_review_measurement FOREIGN KEY (measurement_id) REFERENCES fl_inbound_measurement (id)
) ENGINE=InnoDB COMMENT='测量审核、驳回和异常放行不可变记录';
CREATE TABLE IF NOT EXISTS fl_customer_charge_rule (
id BIGINT UNSIGNED NOT NULL COMMENT '客户计费规则ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
rule_code VARCHAR(50) NOT NULL COMMENT '租户内规则编码',
rule_version INT UNSIGNED NOT NULL COMMENT '规则版本号',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户逻辑ID',
customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照',
business_type VARCHAR(32) NOT NULL COMMENT '业务类型',
charge_mode VARCHAR(16) NOT NULL COMMENT '计费口径:KG或CBM',
tax_mode VARCHAR(24) NULL COMMENT '包税或不包税类型',
conversion_factor DECIMAL(18,6) NOT NULL COMMENT '重量体积换算系数',
rounding_scale TINYINT UNSIGNED NOT NULL DEFAULT 3 COMMENT '结果保留小数位数',
rounding_mode VARCHAR(24) NOT NULL DEFAULT 'CEILING' COMMENT '舍入方式',
currency CHAR(3) NOT NULL COMMENT '默认币种',
effective_from DATETIME(3) NOT NULL COMMENT '生效开始时间',
effective_to DATETIME(3) NULL COMMENT '生效结束时间',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '规则状态',
remark VARCHAR(500) NULL COMMENT '规则备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_charge_rule_version (tenant_id, rule_code, rule_version),
KEY idx_charge_rule_match (tenant_id, customer_id, business_type, status, effective_from, effective_to)
) ENGINE=InnoDB COMMENT='客户头程计费规则版本';
CREATE TABLE IF NOT EXISTS fl_carrier_fba_rate (
id BIGINT UNSIGNED NOT NULL COMMENT '船司FBA价格ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
rate_code VARCHAR(50) NOT NULL COMMENT '租户内价格编码',
rate_version INT UNSIGNED NOT NULL COMMENT '价格版本号',
origin_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '起运仓逻辑ID',
origin_warehouse_name VARCHAR(128) NOT NULL COMMENT '起运仓名称快照',
business_type VARCHAR(32) NOT NULL COMMENT '业务或计费类型',
channel_code VARCHAR(64) NULL COMMENT '渠道编码',
carrier_id BIGINT UNSIGNED NULL COMMENT '船司逻辑ID',
carrier_name VARCHAR(128) NOT NULL COMMENT '船司名称快照',
destination_code VARCHAR(32) NOT NULL COMMENT 'FBA目的仓编码',
charge_unit VARCHAR(16) NOT NULL COMMENT '计费单位:KG或CBM',
unit_price DECIMAL(18,6) NOT NULL COMMENT '基础单价',
currency CHAR(3) NOT NULL COMMENT '币种',
additional_fee DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '固定附加费',
effective_from DATETIME(3) NOT NULL COMMENT '生效开始时间',
effective_to DATETIME(3) NULL COMMENT '生效结束时间',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '价格状态',
remark VARCHAR(500) NULL COMMENT '价格备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_fba_rate_version (tenant_id, rate_code, rate_version),
KEY idx_fba_rate_match (tenant_id, origin_warehouse_id, business_type, channel_code, carrier_id, destination_code, charge_unit, status, effective_from, effective_to)
) ENGINE=InnoDB COMMENT='船司FBA目的仓价格版本';
CREATE TABLE IF NOT EXISTS fl_inbound_fee (
id BIGINT UNSIGNED NOT NULL COMMENT '入库费用ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
fee_no VARCHAR(40) NOT NULL COMMENT '租户内唯一费用单号',
fee_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '费用快照版本',
charge_rule_id BIGINT UNSIGNED NOT NULL COMMENT '客户计费规则ID',
charge_rule_version INT UNSIGNED NOT NULL COMMENT '客户规则版本快照',
carrier_rate_id BIGINT UNSIGNED NOT NULL COMMENT '船司FBA价格ID',
carrier_rate_version INT UNSIGNED NOT NULL COMMENT '船司价格版本快照',
rule_code VARCHAR(50) NOT NULL COMMENT '客户规则编码快照',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID快照',
customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照',
business_type VARCHAR(32) NOT NULL COMMENT '业务类型快照',
tax_mode VARCHAR(24) NULL COMMENT '包税模式快照',
rounding_scale TINYINT UNSIGNED NOT NULL COMMENT '计费量保留位数快照',
rounding_mode VARCHAR(24) NOT NULL COMMENT '舍入方式快照',
rule_effective_from DATETIME(3) NOT NULL COMMENT '客户规则生效开始快照',
rule_effective_to DATETIME(3) NULL COMMENT '客户规则生效结束快照',
rate_code VARCHAR(50) NOT NULL COMMENT '船司价格编码快照',
origin_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '起运仓ID快照',
origin_warehouse_name VARCHAR(128) NOT NULL COMMENT '起运仓名称快照',
channel_code VARCHAR(64) NOT NULL COMMENT '渠道编码快照',
carrier_id BIGINT UNSIGNED NOT NULL COMMENT '船司ID快照',
carrier_name VARCHAR(128) NOT NULL COMMENT '船司名称快照',
destination_code VARCHAR(32) NOT NULL COMMENT 'FBA仓编码快照',
rate_effective_from DATETIME(3) NOT NULL COMMENT '价格生效开始快照',
rate_effective_to DATETIME(3) NULL COMMENT '价格生效结束快照',
charge_mode VARCHAR(16) NOT NULL COMMENT '计费口径:KG或CBM',
conversion_factor DECIMAL(18,6) NOT NULL COMMENT '使用的换算系数快照',
actual_weight_kg DECIMAL(18,3) NOT NULL COMMENT '试算时实际重量kg',
actual_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '试算时实际体积m3',
converted_quantity DECIMAL(18,4) NOT NULL COMMENT '换算后的重量或体积',
chargeable_quantity DECIMAL(18,4) NOT NULL COMMENT '最终计费量',
charge_unit VARCHAR(16) NOT NULL COMMENT '计费单位',
currency CHAR(3) NOT NULL COMMENT '币种',
subtotal_amount DECIMAL(18,2) NOT NULL COMMENT '基础费用金额',
additional_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '附加费金额',
total_amount DECIMAL(18,2) NOT NULL COMMENT '总金额',
fee_status VARCHAR(24) NOT NULL DEFAULT 'CALCULATED' COMMENT '费用状态',
review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态',
exception_release TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否基于测量异常放行生成',
calculated_by BIGINT UNSIGNED NOT NULL COMMENT '试算人ID',
calculated_at DATETIME(3) NOT NULL COMMENT '试算时间',
confirmed_by BIGINT UNSIGNED NULL COMMENT '确认人ID',
confirmed_at DATETIME(3) NULL COMMENT '确认时间',
sent_by BIGINT UNSIGNED NULL COMMENT '发送人ID',
sent_at DATETIME(3) NULL COMMENT '发送客户时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_inbound_fee_no (tenant_id, fee_no),
UNIQUE KEY uk_inbound_fee_version (tenant_id, inbound_id, fee_version),
KEY idx_inbound_fee_status (tenant_id, inbound_id, fee_status, review_status),
CONSTRAINT fk_fee_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id),
CONSTRAINT fk_fee_charge_rule FOREIGN KEY (charge_rule_id) REFERENCES fl_customer_charge_rule (id),
CONSTRAINT fk_fee_carrier_rate FOREIGN KEY (carrier_rate_id) REFERENCES fl_carrier_fba_rate (id)
) ENGINE=InnoDB COMMENT='入库头程费用计算和确认快照';
CREATE TABLE IF NOT EXISTS fl_inbound_fee_item (
id BIGINT UNSIGNED NOT NULL COMMENT '费用明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
fee_id BIGINT UNSIGNED NOT NULL COMMENT '费用头ID',
item_no INT UNSIGNED NOT NULL COMMENT '费用单内序号',
fee_code VARCHAR(50) NOT NULL COMMENT '费用项目编码',
fee_name VARCHAR(128) NOT NULL COMMENT '费用项目名称快照',
quantity DECIMAL(18,4) NOT NULL COMMENT '计费数量',
unit VARCHAR(16) NOT NULL COMMENT '计费单位',
unit_price DECIMAL(18,6) NOT NULL COMMENT '单价',
amount DECIMAL(18,2) NOT NULL COMMENT '金额',
currency CHAR(3) NOT NULL COMMENT '币种',
automatic_flag TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否自动计算',
remark VARCHAR(500) NULL COMMENT '费用备注',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fee_item_no (tenant_id, fee_id, item_no),
CONSTRAINT fk_fee_item_header FOREIGN KEY (fee_id) REFERENCES fl_inbound_fee (id)
) ENGINE=InnoDB COMMENT='入库头程费用项目明细';
CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation (
id BIGINT UNSIGNED NOT NULL COMMENT '头程托盘分配引用ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
allocation_no INT UNSIGNED NOT NULL COMMENT '入库单内分配序号',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务仓库ID',
pallet_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务托盘ID',
pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照',
pallet_capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '托盘可用容积快照',
allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '本托盘分配体积',
allocated_package_count INT UNSIGNED NULL COMMENT '本托盘分配箱数',
location_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务库位ID',
location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照',
reservation_no VARCHAR(64) NOT NULL COMMENT '仓库库位预占单号',
allocation_status VARCHAR(24) NOT NULL DEFAULT 'RESERVED' COMMENT '分配状态',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_pallet_allocation_no (tenant_id, inbound_id, allocation_no),
UNIQUE KEY uk_pallet_allocation_pallet (tenant_id, inbound_id, pallet_id),
KEY idx_allocation_location (tenant_id, warehouse_id, location_id, allocation_status),
CONSTRAINT fk_allocation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='头程侧托盘、库位和仓库预占结果引用';
CREATE TABLE IF NOT EXISTS fl_inbound_file (
id BIGINT UNSIGNED NOT NULL COMMENT '入库单文件关系ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
file_id BIGINT UNSIGNED NOT NULL COMMENT '文件服务文件ID',
file_type VARCHAR(24) NOT NULL COMMENT '文件类型:RELATED、MARK、PC_ORDER、MEASURE或WEIGHT',
file_name_snapshot VARCHAR(255) NOT NULL COMMENT '文件名快照',
visibility VARCHAR(16) NOT NULL DEFAULT 'INTERNAL' COMMENT '可见范围',
file_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '业务文件版本',
uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传人ID',
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
PRIMARY KEY (id),
UNIQUE KEY uk_inbound_file_relation (tenant_id, inbound_id, file_id, file_type),
KEY idx_inbound_file_type (tenant_id, inbound_id, file_type, deleted),
CONSTRAINT fk_file_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='入库单与文件中心对象的分类关系';
CREATE TABLE IF NOT EXISTS fl_inbound_hold_record (
id BIGINT UNSIGNED NOT NULL COMMENT '扣货记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
hold_action VARCHAR(16) NOT NULL COMMENT '动作:HOLD或RELEASE',
reason VARCHAR(1000) NOT NULL COMMENT '扣货或解除原因',
evidence_file_id BIGINT UNSIGNED NULL COMMENT '证据文件ID',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
operated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间',
PRIMARY KEY (id),
KEY idx_hold_inbound_time (tenant_id, inbound_id, operated_at),
CONSTRAINT fk_hold_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='入库单扣货和解除扣货不可变历史';
CREATE TABLE IF NOT EXISTS fl_inbound_internal_note (
id BIGINT UNSIGNED NOT NULL COMMENT '客服内部备注ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
content VARCHAR(2000) NOT NULL COMMENT '仅内部可见备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
KEY idx_internal_note_time (tenant_id, inbound_id, created_at),
CONSTRAINT fk_internal_note_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='入库单客服内部备注历史';
CREATE TABLE IF NOT EXISTS fl_inbound_status_history (
id BIGINT UNSIGNED NOT NULL COMMENT '入库状态历史ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
from_status VARCHAR(24) NULL COMMENT '变更前状态',
to_status VARCHAR(24) NOT NULL COMMENT '变更后状态',
action VARCHAR(32) NOT NULL COMMENT '触发动作',
reason VARCHAR(1000) NULL COMMENT '变更原因',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '发生时间',
PRIMARY KEY (id),
KEY idx_inbound_history_time (tenant_id, inbound_id, occurred_at),
CONSTRAINT fk_history_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='入库单流程状态不可变历史';
CREATE TABLE IF NOT EXISTS fl_inbound_outbox (
id BIGINT UNSIGNED NOT NULL COMMENT '领域事件全局ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '事件所属租户ID',
aggregate_type VARCHAR(32) NOT NULL DEFAULT 'INBOUND' COMMENT '聚合类型',
aggregate_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
payload_json JSON NOT NULL COMMENT '事件负载快照',
publish_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态',
retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
next_retry_at DATETIME(3) NULL COMMENT '下次重试时间',
published_at DATETIME(3) NULL COMMENT '发布时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
KEY idx_inbound_outbox_publish (publish_status, next_retry_at, created_at)
) ENGINE=InnoDB COMMENT='入库事务Outbox事件表';
CREATE TABLE IF NOT EXISTS fl_inbound_idempotency (
id BIGINT UNSIGNED NOT NULL COMMENT '入库写操作幂等记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
operation VARCHAR(40) NOT NULL COMMENT '操作类型',
operator_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '操作者或外部应用ID',
inbound_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '入库单ID,创建前可为0',
idempotency_key VARCHAR(128) NOT NULL COMMENT '幂等键',
request_hash CHAR(64) NOT NULL COMMENT '请求内容摘要',
status VARCHAR(16) NOT NULL COMMENT '处理状态',
result_json JSON NULL COMMENT '成功结果摘要',
result_resource_id BIGINT UNSIGNED NULL COMMENT '成功结果资源ID',
completed_at DATETIME(3) NULL COMMENT '完成时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_inbound_idempotency (tenant_id, operation, operator_id, idempotency_key),
KEY idx_inbound_idempotency_created (created_at)
) ENGINE=InnoDB COMMENT='第三方测量、审核、费用发送和入库写操作幂等记录';
@@ -0,0 +1,13 @@
-- 业务块4增量迁移:既有二期库补齐异常放行关口查询索引。
-- 可重复执行;新库仍先执行 phase2-inbound-schema.sql。
USE baiyue_firstleg;
SET NAMES utf8mb4;
SET @ddl = IF(
EXISTS(SELECT 1 FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'fl_measure_review_record'
AND index_name = 'idx_review_exception_release'),
'SELECT 1',
'ALTER TABLE fl_measure_review_record ADD INDEX idx_review_exception_release (tenant_id, inbound_id, exception_release, reviewed_at)'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -0,0 +1,30 @@
SET NAMES utf8mb4;
USE baiyue_firstleg;
-- 既有二期数据库增量:先确保 phase2-inbound-schema.sql 中的测量和幂等表已存在。
CREATE TABLE IF NOT EXISTS fl_third_party_call_log (
id BIGINT UNSIGNED NOT NULL COMMENT '第三方调用日志ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '识别出的租户ID,未知应用为0',
app_key VARCHAR(128) NULL COMMENT '第三方应用标识',
signature_subject VARCHAR(128) NULL COMMENT '签名主体快照',
request_method VARCHAR(8) NOT NULL COMMENT 'HTTP方法',
request_path VARCHAR(255) NOT NULL COMMENT '签名使用的规范路径',
request_timestamp VARCHAR(32) NULL COMMENT '请求头Unix毫秒时间戳原值',
nonce VARCHAR(128) NULL COMMENT '请求Nonce',
idempotency_key VARCHAR(128) NULL COMMENT 'HTTP幂等键,查询接口为空',
idempotency_anchor TINYINT UNSIGNED NULL COMMENT '签名通过后首个请求为1,重复调用为空',
signature_value VARCHAR(128) NULL COMMENT '第三方签名原值',
request_hash CHAR(64) NOT NULL COMMENT '原始请求体SHA-256小写十六进制',
raw_payload LONGTEXT NOT NULL COMMENT '未经重新序列化的UTF-8原始请求体',
processing_status VARCHAR(16) NOT NULL COMMENT 'PROCESSING、ACCEPTED或REJECTED',
result_code VARCHAR(32) NULL COMMENT '统一响应业务码',
result_message VARCHAR(1000) NULL COMMENT '处理结果或失败原因',
result_json JSON NULL COMMENT '首次成功结果快照',
requested_at DATETIME(3) NOT NULL COMMENT '服务端接收时间',
processed_at DATETIME(3) NULL COMMENT '处理完成时间',
PRIMARY KEY (id),
KEY idx_third_call_app_time (tenant_id, app_key, requested_at),
KEY idx_third_call_idempotency (tenant_id, app_key, idempotency_key),
KEY idx_third_call_nonce (tenant_id, app_key, nonce),
UNIQUE KEY uk_third_call_idempotency_owner (tenant_id, app_key, idempotency_key, idempotency_anchor)
) ENGINE=InnoDB COMMENT='第三方测量开放接口原始请求、签名主体和处理结果留档';

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