Compare commits

..
32 Commits
Author SHA1 Message Date
liuyihe f86fab838a 一件代发拣货 2026-09-01 10:35:54 +08:00
liuyihe 2f1bda8369 一件代发拣货 2026-09-01 09:57:10 +08:00
liuyihe a213be67c5 尾程暂扣回传 2026-08-31 15:22:32 +08:00
liuyihe a4189a0074 提交 2026-08-31 14:39:40 +08:00
liuyihe a4429f95b7 提交 2026-08-30 18:49:15 +08:00
liuyihe 5ddaa4d51f 提交 2026-08-28 15:25:50 +08:00
liuyihe 8a7c92bb0f 提交 2026-08-28 15:16:36 +08:00
liuyihe 4f2b670ebf 暂扣 标记合托功能 2026-08-28 14:55:18 +08:00
liuyihe 66b9c3aa77 暂扣 标记合托功能 2026-08-28 14:20:48 +08:00
liuyihe 6a83008ba6 提交 2026-08-28 09:59:17 +08:00
liuyihe 5f341d6fb5 一件代发 2026-08-27 17:23:18 +08:00
liuyihe 8d39c61863 提交 2026-08-27 16:54:03 +08:00
liuyihe 7e0911951d Merge branch 'dev' into dev-lyhfive
# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
#	scripts/admin-test-env-modules/phase3-container-loading.mjs
#	scripts/admin-test-env-modules/phase3-loading.mjs
2026-08-27 13:40:26 +08:00
liuyihe a79999d97c 提交 2026-08-27 13:35:52 +08:00
ouyangxq 9b6e257f8d fix(db): support clean test database initialization 2026-08-26 21:17:14 +08:00
ouyangxq 0103ea96c8 test: lock overseas PDA pallet routing 2026-08-26 20:39:03 +08:00
liuyihe 3975759458 提交 2026-08-26 19:46:14 +08:00
ouyangxq e9b03a3d35 test: allow inbound operation descriptions 2026-08-26 18:07:39 +08:00
ouyangxq 29c27d2fd7 fix: resolve FBA PDA carton by inbound prefix 2026-08-26 18:03:17 +08:00
ouyangxq cbcbb9c764 fix(ci): make AUTO deployment incremental 2026-08-26 18:01:47 +08:00
ouyangxq 64047df5b3 优化 2026-08-26 17:50:56 +08:00
ouyangxq aad8dd6344 优化 2026-08-26 17:31:12 +08:00
ouyangxq 9df87b17ac 优化 2026-08-26 17:31:09 +08:00
ouyangxq 2280154773 优化 2026-08-26 17:29:07 +08:00
ouyangxq 05e3aa4e8d 优化 2026-08-26 17:27:09 +08:00
ouyangxq ef45325643 Merge branch 'test' of http://git.buggoglobal.com/java-project/baiyue-firstleg into dev 2026-08-26 17:27:05 +08:00
ouyangxq 92d7c0a7b0 优化 2026-08-26 17:23:38 +08:00
ouyangxq 795e852944 优化 2026-08-26 17:09:02 +08:00
ouyangxq 6c4543c18c 优化 2026-08-26 16:50:22 +08:00
liuyihe 63182a2b88 Merge branch 'dev' into dev-lyhfive 2026-08-26 16:07:27 +08:00
ouyangxq fd1a81594a 优化 2026-08-26 15:58:54 +08:00
wujinliang f2877df697 1 2026-08-26 15:32:10 +08:00
202 changed files with 6280 additions and 667 deletions
Binary file not shown.
+2 -2
View File
@@ -11,7 +11,7 @@
- 当前产品只有“平台—客户”两级。`tenant_id=0` 是平台技术作用域;`tenant_id>0` 是客户隔离键,不得新增租户切换、租户选择或租户级 RBAC。
- 公共仓库、往来单位、快递、托盘、库位统一归属 `tenant_id=0`;订单、文件、费用等客户数据按客户 `tenant_id>0` 隔离。不得跨库 Join 或信任客户端客户 ID。
- 国内订单交易状态归 `order`,头程履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`,国内仓储资源归 `warehouse`;海外尾程订单、入仓计划、库存业务与海外本土业务归 `lastleg-service`,海外仓库/库位/托盘资源归 `warehouse-overseas-service`,海外公网入口归 `gateway-overseas`。文件对象归 `file`,第三方适配归 `integration-service`;跨服务通过内部客户端、DTO、事件和服务令牌协作。
- 国内订单交易状态归 `order`,头程履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`;全部仓库 ID、类型、状态、地址和能力主档归国内 `warehouse`,海外 `warehouse-overseas-service` 只维护引用同一国内 warehouseId 的库位、托盘和资源锁。海外尾程订单、入仓计划、库存业务与海外本土业务归 `lastleg-service`,海外公网入口归 `gateway-overseas`。文件对象归 `file`,第三方适配归 `integration-service`;跨服务通过内部客户端、DTO、事件和服务令牌协作。
- 普通业务成功/失败使用 HTTP 200 + body `code`(成功为字符串 `"200"`);认证、基础设施和网关拒绝可使用真实 401/403/503。
## 服务路由(按关键词定位)
@@ -84,7 +84,7 @@
- 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、四期范围漂移、订舱关联候选与柜量语义、海外 Gateway 覆盖、PDA 运行配置、Lastleg 海外安全投影、同步 Trace、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。
- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、四期范围漂移、仓库主档唯一权威、订舱关联候选与柜量语义、整柜出仓与船节点单调性、海外 Gateway 覆盖、PDA 运行配置/独立更新/语言预留、人工分段验收链、Lastleg 海外安全投影、同步 Trace、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。
- 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。
## 全量 HTTP 验收脚本路由
Vendored
+106 -17
View File
@@ -21,16 +21,77 @@ def serviceCheckboxes = [
'audit-service': 'SERVICE_AUDIT'
]
def commonPaths = [
'pom.xml', 'platform-bom/', 'common-core/', 'common-web/', 'common-security/',
'common-openapi/', 'common-audit/', 'common-event-contract/', 'Jenkinsfile',
'Jenkinsfile.service', 'deploy/'
def fullRebuildPaths = [
'pom.xml', 'platform-bom/', 'common-core/', 'deploy/docker/',
'deploy/docker-compose.yml', 'deploy/service-map.yml'
]
def commonPathServices = [
'common-web/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service',
'event-bridge-service', 'audit-service'
],
'common-security/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file',
'lastleg-service', 'warehouse-overseas-service', 'audit-service'
],
'common-openapi/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service',
'event-bridge-service', 'audit-service'
],
'common-audit/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file',
'warehouse-overseas-service', 'audit-service'
],
'common-event-contract/': [
'firstleg', 'iam', 'lastleg-service', 'event-bridge-service'
],
'deploy/docker-compose.overseas.yml': [
'gateway-overseas', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service'
],
'deploy/docker-compose.audit.yml': ['audit-service'],
'deploy/docker-compose.rocketmq.yml': ['event-bridge-service']
]
def databaseMigrationPaths = ['deploy/sql/']
def downstreamResults = [:]
def parseAutoBuildState(String description, List<String> allowedServices) {
String marker = ' | RESULTS'
if (!description?.startsWith('AUTO ') || !description.contains(marker)) return null
int markerIndex = description.indexOf(marker)
String commit = description.substring('AUTO '.length(), markerIndex).trim()
if (!(commit ==~ /[0-9a-fA-F]{7,40}/)) return null
String summary = description.substring(markerIndex + marker.length()).trim()
List<String> failedServices = []
if (summary) {
summary.split(',').collect { it.trim() }.findAll { it }.each { item ->
int separator = item.indexOf('=')
if (separator > 0) {
String service = item.substring(0, separator).trim()
String result = item.substring(separator + 1).trim()
if (allowedServices.contains(service) && result != 'SUCCESS') failedServices << service
}
}
}
return [commit: commit, failedServices: failedServices.unique()]
}
def previousAutoBuildState(def previousBuild, List<String> allowedServices) {
def candidate = previousBuild
int inspected = 0
while (candidate != null && inspected < 30) {
def state = parseAutoBuildState(candidate.description, allowedServices)
if (state != null && ['SUCCESS', 'UNSTABLE'].contains(candidate.result)) return state
candidate = candidate.previousBuild
inspected++
}
return null
}
def environmentChoicesForJob(String jobBaseName) {
return jobBaseName == 'backend-java-dev'
? ['development', 'test', 'staging', 'production']
@@ -112,6 +173,8 @@ pipeline {
error('生产环境禁止跳过测试')
}
env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
List selected = []
if (params.BUILD_MODE == 'ALL') {
selected = serviceModules
@@ -123,9 +186,17 @@ pipeline {
error('SELECT 模式至少需要勾选一个服务')
}
} else {
String base = env.GIT_PREVIOUS_SUCCESSFUL_COMMIT?.trim()
def autoState = previousAutoBuildState(currentBuild.previousBuild, serviceModules)
String base = autoState?.commit?.trim()
List retryServices = autoState?.failedServices ?: []
if (!base) {
// 首次启用新算法时兼容旧构建记录;后续只读取标记为 AUTO 的完成记录,
// SELECT/ALL 不会推进 AUTO 基线。
base = env.GIT_PREVIOUS_SUCCESSFUL_COMMIT?.trim()
}
if (!base || sh(script: "git cat-file -e ${base}^{commit} 2>/dev/null", returnStatus: true) != 0) {
selected = serviceModules
echo 'AUTO 未找到可用基线,安全回退为全量构建'
} else {
List changed = sh(script: "git diff --name-only ${base} HEAD", returnStdout: true)
.trim().split('\\n').collect { it.trim() }.findAll { it }
@@ -133,13 +204,27 @@ pipeline {
databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
}
env.DATABASE_CHANGE_DETECTED = databaseChanged ? 'true' : 'false'
boolean commonChanged = changed.any { path ->
boolean databaseMigration = databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
!databaseMigration && commonPaths.any { prefix -> path == prefix || path.startsWith(prefix) }
List serviceRelevantChanges = changed.findAll { path ->
!databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
}
selected = commonChanged ? serviceModules : serviceModules.findAll { module ->
changed.any { it == module || it.startsWith("${module}/") }
boolean fullRebuild = serviceRelevantChanges.any { path ->
fullRebuildPaths.any { prefix -> path == prefix || path.startsWith(prefix) }
}
if (fullRebuild) {
selected = serviceModules
} else {
selected = serviceModules.findAll { module ->
serviceRelevantChanges.any { it == module || it.startsWith("${module}/") }
}
commonPathServices.each { prefix, impactedServices ->
if (serviceRelevantChanges.any { path -> path == prefix || path.startsWith(prefix) }) {
selected.addAll(impactedServices)
}
}
}
selected.addAll(retryServices)
echo "AUTO 基线: ${base};变更选中: ${selected.unique().join(',') ?: '无'};" +
"补跑失败服务: ${retryServices.join(',') ?: '无'}"
}
}
@@ -165,14 +250,16 @@ pipeline {
env.SELECTED_SERVICES = selected.join(',')
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
if (!selected) {
currentBuild.description = env.DATABASE_CHANGE_DETECTED == 'true'
? '仅数据库迁移变更 | 需单独审核执行'
: '没有需要构建的业务服务'
currentBuild.description = params.BUILD_MODE == 'AUTO'
? "AUTO ${env.IMAGE_TAG} | RESULTS"
: (env.DATABASE_CHANGE_DETECTED == 'true'
? '仅数据库迁移变更 | 需单独审核执行'
: '没有需要构建的业务服务')
} else {
env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
String databaseSuffix = env.DATABASE_CHANGE_DETECTED == 'true' ? ' | DB migration pending' : ''
currentBuild.description = "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}${databaseSuffix}"
currentBuild.description = params.BUILD_MODE == 'AUTO'
? "AUTO ${env.IMAGE_TAG} | PENDING ${env.SELECTED_SERVICES}${databaseSuffix}"
: "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}${databaseSuffix}"
echo "本次独立调度服务: ${env.SELECTED_SERVICES}"
}
if (env.DATABASE_CHANGE_DETECTED == 'true') {
@@ -273,7 +360,9 @@ pipeline {
.collect { service, result -> "${service}=${result}" }
String summary = downstreamResults.collect { service, result -> "${service}=${result}" }.join(', ')
echo "单服务执行汇总: ${summary}"
currentBuild.description = "${env.IMAGE_TAG} | ${summary}"
currentBuild.description = params.BUILD_MODE == 'AUTO'
? "AUTO ${env.IMAGE_TAG} | RESULTS ${summary}"
: "${env.IMAGE_TAG} | ${summary}"
if (failed) {
unstable("部分服务执行失败,但其他服务已独立完成:${failed.join(', ')}")
}
@@ -117,6 +117,7 @@ public class AuthService {
.subject(account.getId().toString())
.claim("user_id", account.getId())
.claim("username", account.getUsername())
.claim("display_name", account.getDisplayName() == null ? "" : account.getDisplayName())
.claim("tenant_id", account.getDefaultTenantId())
.claim("operator_tenant_id", account.getOperatorTenantId())
.claim("membership_id", account.getDefaultMembershipId())
@@ -187,7 +188,7 @@ public class AuthService {
JwtClaimsSet claims = JwtClaimsSet.builder().issuer(properties.issuer())
.audience(List.of(properties.audience())).id(jwtId)
.issuedAt(issuedAt).expiresAt(expiresAt).subject(account.getId().toString())
.claim("user_id", account.getId()).claim("username", account.getUsername())
.claim("user_id", account.getId()).claim("username", account.getUsername()).claim("display_name", account.getDisplayName() == null ? "" : account.getDisplayName())
.claim("tenant_id", account.getDefaultTenantId()).claim("operator_tenant_id", account.getOperatorTenantId())
.claim("membership_id", account.getDefaultMembershipId()).claim("app_code", "MERCHANT")
.claim("client_type", authorization.getClientType()).claim("token_version", account.getTokenVersion())
+1
View File
@@ -17,6 +17,7 @@
| 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;订单级冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;同时冻结来源/根订单与入仓标识、客户、业务类型、目的仓/备注/托盘及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` |
| 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 |
| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` |
| 海外入仓单暂扣变更 V1 | `OverseasInboundOrderDetainedChangedV1.java`;聚合键固定为来源头程入仓单 ID,暂扣与解除暂扣均由尾程 Outbox 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 |
| 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
| IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 |
| 契约说明 | `package-info.java` |
@@ -13,6 +13,7 @@ public final class EventTypes {
public static final String FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED =
"FirstLegContainerShippingNodeRecorded";
public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded";
public static final String OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED = "OverseasInboundOrderDetainedChanged";
public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged";
public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged";
public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged";
@@ -0,0 +1,17 @@
package com.baiyue.common.event;
import java.time.Instant;
/** 海外尾程按来源入仓单回传的暂扣状态事实。 */
public record OverseasInboundOrderDetainedChangedV1(
String sourceInboundId,
String inboundCode,
String lastlegOrderId,
String lastlegOrderNo,
boolean detained,
String reason,
String operatorId,
String operatorName,
Instant changedAt
) {
}
@@ -7,21 +7,36 @@ import java.util.Set;
* 统一认证用户主体,携带用户、租户、成员身份、应用端、客户端类型及权限信息。
*/
public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
String username, String appCode, String clientType,
String username, String displayName, String appCode, String clientType,
Set<String> permissions, boolean impersonated, Long actorUserId,
Long actorMembershipId, boolean passwordChangeRequired,
Long overseasWarehouseId) implements Principal {
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
String username, String appCode, String clientType, Set<String> permissions) {
this(userId, tenantId, operatorTenantId, membershipId, username, appCode, clientType,
this(userId, tenantId, operatorTenantId, membershipId, username, null, appCode, clientType,
permissions, false, null, null, false, null);
}
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
String username, String appCode, String clientType, Set<String> permissions,
boolean impersonated, Long actorUserId, Long actorMembershipId,
boolean passwordChangeRequired) {
this(userId, tenantId, operatorTenantId, membershipId, username, appCode, clientType,
permissions, impersonated, actorUserId, actorMembershipId, passwordChangeRequired, null);
this(userId, tenantId, operatorTenantId, membershipId, username, null, appCode, clientType,
permissions, impersonated, actorUserId, actorMembershipId, passwordChangeRequired, null);
}
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
String username, String displayName, String appCode, String clientType,
Set<String> permissions, boolean impersonated, Long actorUserId,
Long actorMembershipId, boolean passwordChangeRequired) {
this(userId, tenantId, operatorTenantId, membershipId, username, displayName, appCode, clientType,
permissions, impersonated, actorUserId, actorMembershipId, passwordChangeRequired, null);
}
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
String username, String appCode, String clientType, Set<String> permissions,
boolean impersonated, Long actorUserId, Long actorMembershipId,
boolean passwordChangeRequired, Long overseasWarehouseId) {
this(userId, tenantId, operatorTenantId, membershipId, username, null, appCode, clientType,
permissions, impersonated, actorUserId, actorMembershipId, passwordChangeRequired,
overseasWarehouseId);
}
public AuthenticatedUser {
permissions = permissions == null ? Set.of() : Set.copyOf(permissions);
@@ -37,6 +37,7 @@ public class JwtClaimsMapper {
longClaim(jwt, "operator_tenant_id"),
longClaim(jwt, "membership_id"),
username,
firstNonBlankNullable(jwt.getClaimAsString("displayName"), jwt.getClaimAsString("display_name")),
appCode,
jwt.getClaimAsString("client_type"),
permissions,
@@ -87,4 +88,9 @@ public class JwtClaimsMapper {
for (String value : values) if (value != null && !value.isBlank()) return value;
return "unknown";
}
private String firstNonBlankNullable(String... values) {
for (String value : values) if (value != null && !value.isBlank()) return value.trim();
return null;
}
}
+11
View File
@@ -10,8 +10,19 @@
powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-local-nacos-config.ps1
```
也可以使用统一入口;`local` 会被强制限制在 `public` namespace:
```powershell
powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-nacos-config.ps1 -Environment local
```
配置发布成功后,各服务仍由自身的 Nacos Discovery 配置在启动时自动注册,不需要手工创建服务实例。
本地海外链路也发布在同一 `public / BAIYUE` 作用域,包括
`gateway-overseas.yml`、`lastleg-service.yml` 和
`warehouse-overseas-service.yml`。这些配置默认通过 `127.0.0.1` 连接本机
Auth、国内仓库、文件和海外服务;需要跨主机运行时使用对应环境变量覆盖。
## MyBatis SQL 日志
本地公共配置使用 MyBatis `Slf4jImpl`,数据库服务的 Mapper 日志级别默认为 `DEBUG`。执行数据库操作时,控制台会打印 SQL 模板、参数和影响行数。
@@ -5,5 +5,7 @@ spring.datasource:
password: ${EVENT_BRIDGE_DB_PASSWORD:123456}
baiyue:
region: ${REGION_CODE:CN}
event-bridge:
accept-any-region: ${EVENT_BRIDGE_ACCEPT_ANY_REGION:true}
deployment-cell: ${DEPLOYMENT_CELL:local-cn}
data-residency: ${DATA_RESIDENCY:CN}
+16
View File
@@ -0,0 +1,16 @@
springdoc:
api-docs.enabled: ${SWAGGER_ENABLED:true}
swagger-ui.enabled: ${SWAGGER_ENABLED:true}
baiyue:
security:
issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
pda-session-validation:
auth-url: ${AUTH_SESSION_VALIDATION_URL:http://127.0.0.1:9100}
internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s}
overseas-projection:
service-url: ${LASTLEG_SECURITY_SERVICE_URL:http://127.0.0.1:9303}
internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
timeout: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:2s}
+29
View File
@@ -0,0 +1,29 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${LASTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${LASTLEG_DB_USERNAME:root}
password: ${LASTLEG_DB_PASSWORD:123456}
lastleg:
dispatch-query-api.enabled: ${LASTLEG_DISPATCH_QUERY_API_ENABLED:true}
smart-dispatch-api.enabled: ${LASTLEG_SMART_DISPATCH_API_ENABLED:true}
baiyue:
clients:
domestic-warehouse:
url: ${DOMESTIC_WAREHOUSE_URL:http://127.0.0.1:9205}
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
file:
url: ${FILE_SERVICE_URL:http://127.0.0.1:9206}
event-bridge:
url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:http://127.0.0.1:9312}}
warehouse:
base-url: ${WAREHOUSE_OVERSEAS_SERVICE_URL:http://127.0.0.1:9305}
firstleg:
event-bridge-url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:http://127.0.0.1:9312}}
security:
internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
jwt-mode: jwks
session-mode: claims
issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
@@ -0,0 +1,19 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastlag_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${WAREHOUSE_DB_USERNAME:root}
password: ${WAREHOUSE_DB_PASSWORD:123456}
baiyue:
clients:
domestic-warehouse:
url: ${DOMESTIC_WAREHOUSE_URL:http://127.0.0.1:9205}
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
internal-service:
token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
security:
jwt-mode: jwks
session-mode: claims
issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
+15 -1
View File
@@ -1,5 +1,19 @@
# 海外 Nacos 配置
将本目录文件按原文件名作为 Data ID 发布到 `prod-overseas` 命名空间和 `BAIYUE` 分组。配置只引用环境变量,不保存密码或访问密钥。Gateway 必须同时启用 `overseas` Profile。
本目录是海外远程部署模板,由 dev/development、test 和 production 共用。配置只引用环境变量,不保存密码或访问密钥;三个环境必须发布到各自独立的 Nacos namespace,Gateway 必须同时启用 `overseas` Profile。
海外入口与仓储 Data ID 分别为 `gateway-overseas.yml`、`warehouse-overseas-service.yml`。`print-service.yml` 对应头程仓库保留的共用打印服务,不包含尾程旧 Print 模块。
发布示例:
```powershell
# 开发环境
powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-nacos-config.ps1 `
-Environment development -Region overseas -ServerAddr <dev-nacos> -Namespace <dev-overseas-namespace>
# 测试环境
powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-nacos-config.ps1 `
-Environment test -Region overseas -ServerAddr <test-nacos> -Namespace <test-overseas-namespace>
```
远程环境禁止使用 `public` namespace。数据库、Redis、JWKS、内部服务令牌和跨区服务地址继续由对应 Jenkins Secret file 提供;不要把真实值写入本目录。
+12 -30
View File
@@ -4,54 +4,36 @@
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd">
<mirrors>
<mirror>
<id>baiyue-nexus-public</id>
<name>柏悦 Nexus Maven 聚合仓库</name>
<url>http://8.163.30.128:18081/repository/maven-public/</url>
<mirrorOf>external:*</mirrorOf>
<id>aliyun-public</id>
<name>阿里云 Maven 公共镜像</name>
<url>https://maven.aliyun.com/repository/public</url>
<mirrorOf>central</mirrorOf>
</mirror>
</mirrors>
<servers>
<server>
<id>baiyue-nexus-public</id>
<username>${env.NEXUS_USERNAME}</username>
<password>${env.NEXUS_PASSWORD}</password>
</server>
<server>
<id>baiyue-nexus-releases</id>
<username>${env.NEXUS_USERNAME}</username>
<password>${env.NEXUS_PASSWORD}</password>
</server>
<server>
<id>baiyue-nexus-snapshots</id>
<username>${env.NEXUS_USERNAME}</username>
<password>${env.NEXUS_PASSWORD}</password>
</server>
</servers>
<profiles>
<profile>
<id>柏悦 Nexus</id>
<id>国内公共镜像</id>
<repositories>
<repository>
<id>baiyue-nexus-public</id>
<url>http://8.163.30.128:18081/repository/maven-public/</url>
<id>central</id>
<url>https://repo.maven.apache.org/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>true</enabled></snapshots>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>baiyue-nexus-public</id>
<url>http://8.163.30.128:18081/repository/maven-public/</url>
<id>central</id>
<url>https://repo.maven.apache.org/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>true</enabled></snapshots>
<snapshots><enabled>false</enabled></snapshots>
</pluginRepository>
</pluginRepositories>
</profile>
</profiles>
<activeProfiles>
<activeProfile>柏悦 Nexus</activeProfile>
<activeProfile>国内公共镜像</activeProfile>
</activeProfiles>
</settings>
+136 -10
View File
@@ -890,6 +890,7 @@ CREATE TABLE IF NOT EXISTS wh_partner (
data_scope_id BIGINT UNSIGNED NOT NULL COMMENT '所属业务数据范围',
partner_code VARCHAR(50) NOT NULL COMMENT '平台内唯一往来单位编码', partner_name VARCHAR(128) NOT NULL COMMENT '往来单位名称',
partner_type VARCHAR(20) NOT NULL COMMENT '往来单位类型:CARRIER、SHIPPER或CONSIGNEE',
carrier_code VARCHAR(16) NULL COMMENT '船司标准编码:MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER;仅CARRIER必填',
contact_name VARCHAR(64) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(32) NOT NULL COMMENT '联系电话',
contact_email VARCHAR(128) NULL COMMENT '联系邮箱', country_code CHAR(2) NOT NULL COMMENT '国家二字码',
province VARCHAR(64) NULL COMMENT '省或州', city VARCHAR(64) NOT NULL COMMENT '城市', district VARCHAR(64) NULL COMMENT '区县',
@@ -901,7 +902,8 @@ CREATE TABLE IF NOT EXISTS wh_partner (
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记:0未删除、1已删除', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY(id), UNIQUE KEY uk_wh_partner_tenant_code(tenant_id,partner_code,deleted),
KEY idx_wh_partner_tenant_status(tenant_id,status,deleted),
CONSTRAINT chk_wh_partner_type CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE'))
CONSTRAINT chk_wh_partner_type CHECK (partner_type IN ('CARRIER','SHIPPER','CONSIGNEE')),
CONSTRAINT chk_wh_partner_carrier_code CHECK (carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'))
) ENGINE=InnoDB COMMENT='平台统一维护、供全部客户共享的往来单位主数据';
CREATE TABLE IF NOT EXISTS wh_partner_type (
partner_id BIGINT UNSIGNED NOT NULL COMMENT '往来单位ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共往来单位固定使用 tenant_id=0',
@@ -1872,7 +1874,8 @@ CALL add_column_if_missing('wh_partner', 'tenant_id', 'BIGINT UNSIGNED NOT NULL
CALL add_column_if_missing('wh_partner', 'partner_code', 'VARCHAR(50) NOT NULL DEFAULT '''' AFTER tenant_id');
CALL add_column_if_missing('wh_partner', 'partner_name', 'VARCHAR(128) NOT NULL DEFAULT '''' AFTER partner_code');
CALL add_column_if_missing('wh_partner', 'partner_type', 'VARCHAR(20) NOT NULL DEFAULT ''CUSTOMER'' AFTER partner_name');
CALL add_column_if_missing('wh_partner', 'contact_name', 'VARCHAR(64) NOT NULL DEFAULT '''' AFTER partner_type');
CALL add_column_if_missing('wh_partner', 'carrier_code', 'VARCHAR(16) NULL COMMENT ''船司标准编码:MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER;仅CARRIER必填'' AFTER partner_type');
CALL add_column_if_missing('wh_partner', 'contact_name', 'VARCHAR(64) NOT NULL DEFAULT '''' AFTER carrier_code');
CALL add_column_if_missing('wh_partner', 'contact_phone', 'VARCHAR(32) NOT NULL DEFAULT '''' AFTER contact_name');
CALL add_column_if_missing('wh_partner', 'contact_email', 'VARCHAR(128) NULL');
CALL add_column_if_missing('wh_partner', 'country_code', 'CHAR(2) NOT NULL DEFAULT ''CN'' AFTER contact_email');
@@ -1888,6 +1891,8 @@ CALL add_column_if_missing('wh_partner', 'updated_by', 'BIGINT UNSIGNED NULL');
CALL add_column_if_missing('wh_partner', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)');
CALL add_column_if_missing('wh_partner', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0');
CALL add_column_if_missing('wh_partner', 'version', 'INT NOT NULL DEFAULT 0');
UPDATE wh_partner SET carrier_code = 'OTHER'
WHERE id = 191000000000000001 AND partner_type = 'CARRIER' AND carrier_code IS NULL;
-- 该表在旧库中不存在时先由 warehouse-express-company.sql 建表;此段补齐早期部署缺列。
CALL add_column_if_missing('wh_express_company', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id');
@@ -2497,6 +2502,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照',
hold_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当前是否扣货',
current_hold_reason VARCHAR(500) NULL COMMENT '当前扣货理由快照',
last_overseas_hold_version BIGINT UNSIGNED 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',
@@ -2786,6 +2792,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID,不建立跨库外键',
cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照',
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变',
detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
@@ -2823,6 +2830,7 @@ BEGIN
END$$
DELIMITER ;
CALL unified_carton_label_add_column('label_version', 'INT UNSIGNED NOT NULL DEFAULT 1 COMMENT ''内部兼容批次字段,固定为1且不进入箱码'' AFTER carton_index');
CALL unified_carton_label_add_column('sku_code', 'VARCHAR(128) NULL COMMENT ''箱唛绑定的SKU快照;二维码内容不变'' AFTER cargo_name');
CALL unified_carton_label_add_column('batch_status', 'VARCHAR(16) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''批次状态:ACTIVE有效、VOID已作废'' AFTER label_version');
CALL unified_carton_label_add_column('cargo_fingerprint', 'CHAR(64) NULL COMMENT ''影响标签字段的SHA-256指纹'' AFTER batch_status');
CALL unified_carton_label_add_column('voided_at', 'DATETIME(3) NULL COMMENT ''批次作废时间'' AFTER cargo_fingerprint');
@@ -4950,11 +4958,14 @@ BEGIN
ALTER TABLE fl_container_booking ADD COLUMN customer_name VARCHAR(128) NULL
COMMENT '已关联真实订舱客户名称快照' AFTER customer_id;
END IF;
UPDATE fl_container_booking container_booking
JOIN fl_ocean_booking ocean_booking ON ocean_booking.id=container_booking.ocean_booking_id
SET container_booking.customer_id=COALESCE(container_booking.customer_id,ocean_booking.customer_id),
container_booking.customer_name=COALESCE(container_booking.customer_name,ocean_booking.customer_name)
WHERE container_booking.customer_id IS NULL OR container_booking.customer_name IS NULL;
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE()
AND table_name='fl_ocean_booking') THEN
UPDATE fl_container_booking container_booking
JOIN fl_ocean_booking ocean_booking ON ocean_booking.id=container_booking.ocean_booking_id
SET container_booking.customer_id=COALESCE(container_booking.customer_id,ocean_booking.customer_id),
container_booking.customer_name=COALESCE(container_booking.customer_name,ocean_booking.customer_name)
WHERE container_booking.customer_id IS NULL OR container_booking.customer_name IS NULL;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND index_name='uk_container_booking_container_no') THEN
ALTER TABLE fl_container_booking ADD UNIQUE KEY uk_container_booking_container_no (container_no);
@@ -5414,6 +5425,66 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_history (
CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id)
) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史';
CREATE TABLE IF NOT EXISTS fl_loading_work_pallet (
id BIGINT UNSIGNED NOT NULL COMMENT '一件代发现场作业托盘ID',
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA装柜拣货任务ID',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID快照',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID',
warehouse_pallet_id BIGINT UNSIGNED NULL COMMENT '仓库实物托盘ID;未绑定仓库资源时为空',
pallet_code VARCHAR(64) NOT NULL COMMENT '现场托盘号',
open_operation_id VARCHAR(128) NOT NULL COMMENT '开托幂等操作号',
seal_operation_id VARCHAR(128) NULL COMMENT '封托幂等操作号',
pallet_status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT '托盘状态:OPEN打托中、SEALED已封托、CANCELLED已取消',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建操作人名称快照',
sealed_by BIGINT UNSIGNED NULL COMMENT '封托操作人ID',
sealed_by_name VARCHAR(64) NULL COMMENT '封托操作人名称快照',
sealed_at DATETIME(3) NULL COMMENT '封托时间',
version INT UNSIGNED NOT NULL DEFAULT 0 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_loading_work_pallet_code (pick_task_id, pallet_code),
UNIQUE KEY uk_loading_work_pallet_open_operation (open_operation_id),
UNIQUE KEY uk_loading_work_pallet_seal_operation (seal_operation_id),
KEY idx_loading_work_pallet_plan (loading_plan_id, pallet_status, id),
CONSTRAINT fk_loading_work_pallet_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
CONSTRAINT fk_loading_work_pallet_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
CONSTRAINT chk_loading_work_pallet_status CHECK (pallet_status IN ('OPEN','SEALED','CANCELLED'))
) ENGINE=InnoDB COMMENT='一件代发PDA现场实际托盘;允许同托盘承载多个客户订单';
CREATE TABLE IF NOT EXISTS fl_loading_work_pallet_carton (
id BIGINT UNSIGNED NOT NULL COMMENT '一件代发现场托盘箱唛绑定ID',
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA装柜拣货任务ID',
work_pallet_id BIGINT UNSIGNED NOT NULL COMMENT '现场作业托盘ID',
loading_plan_item_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划货物明细ID',
pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '箱唛所属客户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '来源入仓单ID',
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '来源入仓货物明细ID',
carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '稳定箱唛记录ID',
box_code VARCHAR(80) NOT NULL COMMENT '稳定箱唛箱码快照',
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照',
units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数量快照',
bound_by BIGINT UNSIGNED NOT NULL COMMENT '最后绑定操作人ID',
bound_by_name VARCHAR(64) NULL COMMENT '最后绑定操作人名称快照',
operation_id VARCHAR(128) NOT NULL COMMENT '最近一次绑定或移托幂等操作号',
version INT UNSIGNED NOT NULL DEFAULT 0 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_loading_work_carton_task_label (pick_task_id, carton_label_id),
UNIQUE KEY uk_loading_work_carton_operation (operation_id),
KEY idx_loading_work_carton_pallet (work_pallet_id, sku_code, id),
KEY idx_loading_work_carton_plan_item (loading_plan_item_id, id),
CONSTRAINT fk_loading_work_carton_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
CONSTRAINT fk_loading_work_carton_pallet FOREIGN KEY (work_pallet_id) REFERENCES fl_loading_work_pallet (id),
CONSTRAINT fk_loading_work_carton_plan_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id),
CONSTRAINT fk_loading_work_carton_pick_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id),
CONSTRAINT fk_loading_work_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label (id),
CONSTRAINT chk_loading_work_carton_units CHECK (units_per_carton IS NULL OR units_per_carton > 0)
) ENGINE=InnoDB COMMENT='一件代发PDA逐箱上托事实;客户订单不合并但实际托盘可混托';
DROP PROCEDURE IF EXISTS repair_phase3_loading_pick_task_foreign_keys;
DELIMITER $$
CREATE PROCEDURE repair_phase3_loading_pick_task_foreign_keys()
@@ -13436,6 +13507,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'created_at', '
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'created_by', '创建人ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'created_by_name', '创建人名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'current_hold_reason', '当前扣货理由快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'last_overseas_hold_version', '海外暂扣事件最新聚合版本,用于阻止乱序覆盖');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'customer_id', '客户逻辑ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'customer_name', '客户名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'customs_inspection', '是否商检');
@@ -14748,6 +14820,7 @@ CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_transfer_opera
CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_transfer_operation', 'target_business_id', '目标子入仓单ID');
CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_transfer_operation', 'tenant_id', '仓储公共资源域,固定为0');
CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'address_line', '详细地址');
CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'carrier_code', '船司标准编码:MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER;仅CARRIER必填');
CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'city', '城市');
CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'contact_email', '联系邮箱');
CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'contact_name', '联系人姓名');
@@ -16229,12 +16302,35 @@ DROP PROCEDURE IF EXISTS add_container_empty_report_fields;
CREATE TABLE IF NOT EXISTS fl_container_event_inbox (
event_id VARCHAR(64) NOT NULL COMMENT '跨区事件全局唯一ID', event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
event_version INT UNSIGNED NOT NULL COMMENT '事件契约版本', aggregate_id VARCHAR(64) NOT NULL COMMENT '货柜聚合ID',
aggregate_version BIGINT UNSIGNED NOT NULL COMMENT '货柜聚合版本', payload_hash VARCHAR(80) NOT NULL COMMENT '规范化负载SHA-256摘要',
event_version INT UNSIGNED NOT NULL COMMENT '事件契约版本', aggregate_id VARCHAR(64) NOT NULL COMMENT '跨区业务聚合ID',
aggregate_version BIGINT UNSIGNED NOT NULL COMMENT '跨区业务聚合版本', payload_hash VARCHAR(80) NOT NULL COMMENT '规范化负载SHA-256摘要',
payload_json JSON NOT NULL COMMENT '原始事件负载', status VARCHAR(16) NOT NULL COMMENT 'RECEIVED或PROCESSED',
received_at DATETIME(3) NOT NULL COMMENT '接收时间(UTC)', processed_at DATETIME(3) NULL COMMENT '处理完成时间(UTC)',
PRIMARY KEY (event_id), KEY idx_container_event_aggregate (aggregate_id,aggregate_version)
) ENGINE=InnoDB COMMENT='海外货柜事件消费Inbox';
) ENGINE=InnoDB COMMENT='海外到国内业务事件消费Inbox';
DROP PROCEDURE IF EXISTS add_inbound_overseas_hold_version;
DELIMITER $$
CREATE PROCEDURE add_inbound_overseas_hold_version()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='fl_inbound_order'
AND column_name='last_overseas_hold_version'
) THEN
ALTER TABLE fl_inbound_order
ADD COLUMN last_overseas_hold_version BIGINT UNSIGNED NULL
COMMENT '海外暂扣事件最新聚合版本,用于阻止乱序覆盖'
AFTER current_hold_reason;
END IF;
ALTER TABLE fl_container_event_inbox
MODIFY COLUMN aggregate_id VARCHAR(64) NOT NULL COMMENT '跨区业务聚合ID',
MODIFY COLUMN aggregate_version BIGINT UNSIGNED NOT NULL COMMENT '跨区业务聚合版本',
COMMENT='海外到国内业务事件消费Inbox';
END$$
DELIMITER ;
CALL add_inbound_overseas_hold_version();
DROP PROCEDURE IF EXISTS add_inbound_overseas_hold_version;
-- 标准跨区事件摘要包含 sha256: 前缀,共 71 字符;兼容既有 Finance 64 位摘要。
ALTER TABLE fl_inbound_outbox
@@ -16281,6 +16377,9 @@ VALUES
(5107061,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:create','创建海外库位',NULL,'POST',761,'ACTIVE',1,0),
(5107062,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:edit','编辑海外库位',NULL,'PUT',762,'ACTIVE',1,0),
(5107063,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:delete','删除海外库位',NULL,'DELETE',763,'ACTIVE',1,0),
(5107090,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:outbound:list','海外出库单','/overseas-warehouse/outbounds','GET',765,'ACTIVE',1,0),
(5107091,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:create','创建海外出库单',NULL,'POST',766,'ACTIVE',1,0),
(5107092,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:delivery:list','海外派送单','/overseas-warehouse/delivery-orders','GET',767,'ACTIVE',1,0),
(5107100,0,'PLATFORM','MENU',0,'platform:overseas-business:purchase:list','海外采购订单','/overseas-business/purchases','GET',800,'ACTIVE',1,0),
(5107101,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:edit','编辑海外采购订单',NULL,'PATCH',801,'ACTIVE',1,0),
(5107102,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:review','审核海外采购订单',NULL,'POST',802,'ACTIVE',1,0),
@@ -16455,10 +16554,27 @@ WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
AND UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN')
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
-- PLATFORM_CUSTOM_ADMIN needs access to the overseas outbound and delivery lists shown in the admin navigation.
INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, role.id, permission.id, NOW(3)
FROM baiyue_iam.iam_role role
JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.permission_code IN (
'platform:overseas-warehouse:outbound:list',
'platform:overseas-warehouse:delivery:list'
)
WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
AND UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN'
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
-- 海外智能排车草稿锁:拖入即锁定,移除或关闭草稿即释放。
CREATE DATABASE IF NOT EXISTS baiyue_lastleg
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_lastleg;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_warehouse_id VARCHAR(128) NULL COMMENT ''来源国内装柜仓 ID 快照'' AFTER customer_name'));
PREPARE lastleg_order_source_warehouse_stmt FROM @ddl; EXECUTE lastleg_order_source_warehouse_stmt; DEALLOCATE PREPARE lastleg_order_source_warehouse_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_warehouse_name VARCHAR(255) NULL COMMENT ''来源国内装柜仓名称快照'' AFTER source_warehouse_id'));
PREPARE lastleg_order_source_warehouse_stmt FROM @ddl; EXECUTE lastleg_order_source_warehouse_stmt; DEALLOCATE PREPARE lastleg_order_source_warehouse_stmt;
CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock (
id BIGINT NOT NULL COMMENT '主键ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
@@ -16616,6 +16732,16 @@ CREATE TABLE IF NOT EXISTS iam_customer_dimension_initialization_task (
KEY idx_customer_dimension_task_diagnosis (status,updated_at)
) ENGINE=InnoDB COMMENT='IAM客户默认尺寸可靠后台初始化任务及诊断状态';
-- 兼容已存在的旧表:CREATE TABLE IF NOT EXISTS 不会为旧表补充新字段。
SET @customer_dimension_lease_ddl = (SELECT IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='iam_customer_dimension_initialization_task' AND column_name='lease_until'),
'SELECT 1',
'ALTER TABLE iam_customer_dimension_initialization_task ADD COLUMN lease_until DATETIME(3) NULL COMMENT ''当前任务租约截止时间'' AFTER lease_owner'));
PREPARE customer_dimension_lease_stmt FROM @customer_dimension_lease_ddl;
EXECUTE customer_dimension_lease_stmt;
DEALLOCATE PREPARE customer_dimension_lease_stmt;
CALL baiyue_firstleg.assert_unified_final_schema();
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
SELECT 'PASS' AS migration_status,
+19 -1
View File
@@ -195,7 +195,7 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order (
purchase_quantity BIGINT NOT NULL COMMENT '采购数量', budget_unit_price DECIMAL(18,2) NULL COMMENT '预算单价',
currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', receiving_address VARCHAR(1000) NULL COMMENT '收货地址',
contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话',
purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID', purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称',
purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID', purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称', outbound_details JSON NULL COMMENT '出库明细',
inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '采购单据累计入库数量', domestic_carrier VARCHAR(128) NULL COMMENT '境内承运商',
domestic_tracking_no VARCHAR(128) NULL COMMENT '境内物流单号', overseas_carrier VARCHAR(128) NULL COMMENT '海外承运商',
overseas_tracking_no VARCHAR(128) NULL COMMENT '海外物流单号', estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间',
@@ -211,6 +211,8 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order (
KEY idx_ll_overseas_procurement_list(customer_id,status,created_at),
KEY idx_ll_overseas_procurement_warehouse(customer_id,purchase_warehouse_id,created_at)
) ENGINE=InnoDB COMMENT='海外采购订单';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='outbound_details'), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN outbound_details JSON NULL COMMENT ''出库明细'' AFTER inbound_quantity'));
PREPARE procurement_outbound_stmt FROM @ddl; EXECUTE procurement_outbound_stmt; DEALLOCATE PREPARE procurement_outbound_stmt;
SET @ddl = (SELECT IF(EXISTS (
SELECT 1 FROM information_schema.columns
@@ -1053,6 +1055,22 @@ CREATE TABLE IF NOT EXISTS ll_order_operation_log (
) ENGINE=InnoDB COMMENT='尾程订单操作记录';
USE baiyue_lastleg;
SET @ddl = (SELECT IF(EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='version'
), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''入库计划乐观锁版本'' AFTER updated_at'));
PREPARE ll_inbound_plan_version_ddl FROM @ddl;
EXECUTE ll_inbound_plan_version_ddl;
DEALLOCATE PREPARE ll_inbound_plan_version_ddl;
SET @ddl = (SELECT IF(EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='pallet_merge_batch_id'
), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN pallet_merge_batch_id VARCHAR(64) NULL COMMENT ''平台预标记合托分组号'' AFTER pallet_merge_flag'));
PREPARE ll_order_merge_batch_ddl FROM @ddl;
EXECUTE ll_order_merge_batch_ddl;
DEALLOCATE PREPARE ll_order_merge_batch_ddl;
CREATE TABLE IF NOT EXISTS ll_security_event_inbox (
event_id VARCHAR(64) NOT NULL COMMENT '跨区安全事件ID', event_type VARCHAR(96) NOT NULL COMMENT 'IAM安全事件类型',
payload_hash CHAR(64) NOT NULL COMMENT '规范化载荷SHA-256哈希', payload_json JSON NOT NULL COMMENT '冻结事件载荷JSON',
+49 -53
View File
@@ -1,66 +1,62 @@
# 数据库环境切换说明
# Nacos 环境切换说明
## 环境定义
数据库、Redis 和服务地址均由所选环境的 Nacos 配置决定。本地启动时只切换
Nacos 地址与 namespace,不再通过 `application-test.yml` 单独切换数据库。
- `dev`:本地开发环境,连接 `127.0.0.1:3306`,默认账号为 `root/123456`。
- `test`:联调测试环境,连接 `8.163.30.128:3306`,各微服务仍使用独立数据库。
## 环境映射
所有服务默认使用 `dev`:
| Maven Profile | Nacos 地址 | Namespace |
| --- | --- | --- |
| `local` | `127.0.0.1:8848` | `public` |
| `dev` | `dev-nacos.buggoglobal.com:8848` | `DEV_NACOS_NAMESPACE` |
| `test` | `8.163.30.128:8848` | `d99d7998-5bb0-4ffe-a2a0-1a564ce14463` |
```yaml
spring:
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
本地 Nacos 使用 `nacos/nacos`。开发和测试密码不写入 Git,需在启动 IDEA
之前设置一次用户环境变量:
```powershell
[Environment]::SetEnvironmentVariable('DEV_NACOS_PASSWORD', '<开发 Nacos 密码>', 'User')
[Environment]::SetEnvironmentVariable('TEST_NACOS_PASSWORD', '<测试 Nacos 密码>', 'User')
[Environment]::SetEnvironmentVariable('DEV_NACOS_NAMESPACE', '<开发海外 namespace>', 'User')
```
建议不要直接修改配置文件。通过 IDEA 的启动配置设置 `Active profiles` 即可切换:
设置后完全退出并重新打开 IDEA,使 Maven 能读取新环境变量。
- 本地数据库:`dev`
- 测试数据库:`test`
## IDEA 中切换
也可以使用启动参数:
1. 在 Maven 面板的 `Profiles` 中,`local`、`dev`、`test` 只勾选一个。
2. 重新加载 Maven 项目。
3. 在目标服务的 Maven `Plugins > spring-boot > spring-boot:run` 启动。
```text
--spring.profiles.active=test
例如海外查询链路需要分别启动 `gateway-overseas`、`lastleg-service` 和
`warehouse-overseas-service`。已经运行的进程不会自动切换环境,切换 Profile
后必须停止并重新启动。
IDEA 普通绿色 Run 按钮不执行 Maven,因此不会读取 Maven Profile。需要继续使用
绿色 Run 时,请在 Run Configuration 的 Environment variables 中设置
`NACOS_SERVER_ADDR`、`NACOS_NAMESPACE`、`NACOS_USERNAME` 和 `NACOS_PASSWORD`。
`gateway-overseas` 已默认启用 `overseas` Spring Profile。
## PowerShell 中切换
也可以在当前终端加载同一套变量,然后从同一终端启动服务:
```powershell
. .\scripts\use-nacos-environment.ps1 -Environment local -Region overseas
. .\scripts\use-nacos-environment.ps1 -Environment dev -Region overseas
. .\scripts\use-nacos-environment.ps1 -Environment test -Region overseas
```
## 测试数据库密码
脚本读取 `DEV_NACOS_PASSWORD`、`TEST_NACOS_PASSWORD`,namespace 优先读取
`*_OVERSEAS_NACOS_NAMESPACE`,未设置时读取 `*_NACOS_NAMESPACE`。命令前面的点和
空格不能省略,否则变量只存在于脚本子进程中。
测试数据库密码不写入 Git。启动 `test` 环境前,在 IDEA 启动配置的环境变量中设置:
## 当前限制
```text
TEST_DB_PASSWORD=测试数据库密码
```
公共测试连接参数支持覆盖:
```text
TEST_DB_HOST=8.163.30.128
TEST_DB_PORT=3306
TEST_DB_USERNAME=root
TEST_DB_PASSWORD=测试数据库密码
```
每个服务还支持使用自己的 `*_TEST_DB_URL`、`*_TEST_DB_USERNAME` 和 `*_TEST_DB_PASSWORD` 覆盖公共参数。
## 分库映射
| 服务 | 测试数据库 |
| --- | --- |
| 认证服务 | `baiyue_auth` |
| 权限服务 | `baiyue_iam` |
| 订单服务 | `baiyue_order` |
| 仓库服务 | `baiyue_warehouse` |
| 头程履约服务 | `baiyue_firstleg` |
| 文件服务 | `baiyue_file` |
| 审计服务 | `baiyue_audit` |
| 事件桥接服务 | `baiyue_event_bridge` |
网关、打印和集成服务没有业务数据库,但同样支持 `dev/test` Profile。网关在两种环境下都会自动加载 `cn` 路由配置。
## 注意事项
- `test` 只切换数据库,Nacos 和 Redis 默认仍连接本机。
- 环境变量优先级高于配置文件,可用于临时覆盖单个服务。
- 修改 IDEA 环境变量后需要重启对应服务。
- 不要把测试数据库密码提交到配置文件、启动脚本或 Git。
- 测试 Nacos 的 `8848` 客户端端口已确认可连接。
- 开发 Nacos 网页控制台可访问,但 `dev-nacos.buggoglobal.com:8848` 当前从本机
无法连接。需要开放 Nacos 客户端端口以及 Nacos 2.x/3.x 所需的 gRPC 端口,
或提供一个客户端可访问的内部地址。
- 国内和海外存在同名 Data ID,远程环境必须使用各自独立 namespace。开发海外
namespace 尚未确认,因此通过 `DEV_NACOS_NAMESPACE` 在本机配置。
- 不要同时勾选多个环境 Profile;Maven 对同名属性的最终取值不直观,容易连错环境。
@@ -30,6 +30,7 @@ public class EventBridgeMetrics {
EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,
EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED,
EventTypes.OVERSEAS_CONTAINER_UNLOADED,
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED,
EventTypes.IAM_USER_STATUS_CHANGED,
EventTypes.IAM_TENANT_STATUS_CHANGED,
EventTypes.IAM_PERMISSION_VERSION_CHANGED,
@@ -31,10 +31,14 @@ public class EventInboxService {
@Value("${baiyue.region:CN}")
private String localRegion;
/** Local single-bridge mode may carry both regional envelopes; production keeps strict routing. */
@Value("${baiyue.event-bridge.accept-any-region:false}")
private boolean acceptAnyRegion;
@Transactional
public EventReceiveResult receive(EventEnvelope envelope) {
envelope.requireValid();
if (!envelope.targetRegion().name().equalsIgnoreCase(localRegion)) {
if (!acceptAnyRegion && !envelope.targetRegion().name().equalsIgnoreCase(localRegion)) {
throw new IllegalArgumentException("事件目标区域与当前事件桥区域不一致");
}
if (!EventPayloadHash.matches(
@@ -29,7 +29,7 @@ spring:
namespace: ${NACOS_NAMESPACE:}
baiyue:
region: ${REGION_CODE:OVERSEAS}
region: ${REGION_CODE:CN}
event-dispatcher:
consumer-url: ${EVENT_CONSUMER_URL:${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}}
firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://127.0.0.1:9202/internal/firstleg/v1/container-events}
@@ -125,6 +125,15 @@ class EventInboxServiceTest {
.hasMessageContaining("目标区域");
}
@Test
void 本地单实例模式允许接收不同目标区域事件() {
EventInboxService localService = service("CN", true);
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
assertThat(localService.receive(envelope).result()).isEqualTo("已接收");
verify(store).insert(any(BridgeInbox.class));
}
private EventEnvelope envelope(String hash) {
return new EventEnvelope("550e8400-e29b-41d4-a716-446655440000", "OrderApproved", 1,
Instant.parse("2026-07-29T08:30:00Z"), RegionCode.CN, RegionCode.OVERSEAS,
@@ -133,11 +142,16 @@ class EventInboxServiceTest {
}
private EventInboxService service(String region) {
return service(region, false);
}
private EventInboxService service(String region, boolean acceptAnyRegion) {
EventInboxService service = new EventInboxService();
ReflectionTestUtils.setField(service, "inboxStore", store);
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
ReflectionTestUtils.setField(service, "metrics", metrics);
ReflectionTestUtils.setField(service, "localRegion", region);
ReflectionTestUtils.setField(service, "acceptAnyRegion", acceptAnyRegion);
return service;
}
+2
View File
@@ -12,6 +12,7 @@
| 商户/平台资质 | `controller/QualificationFileController.java`、`PlatformQualificationFileController.java` |
| 导出中心 | `controller/PlatformExportTaskController.java`、`service/ExportTaskService.java`、`service/ExportStateService.java`、`service/GenericXlsxExportRunner.java`、`config/ExportDefinitionRegistry.java`、`client/ExportProviderClient.java`、`mq/` |
| 测量图片/证据 | `controller/PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`service/MeasurementImageService.java` |
| 平台订单商品图片读取 | `controller/PlatformProductImageController.java` 的 `/api/platform/v1/files/product-images/{tenantId}/{fileId}`、`service/ProductImageService.java`;仅平台 `platform:order:list` 可按明确客户与文件 ID 内联读取,响应 `no-store` |
| 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` |
| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;业务类型分别为 `FINANCE_VOUCHER`、`OVERSEAS_UNLOAD_PHOTO`,必须按客户与业务 ID 精确校验 |
| 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` |
@@ -23,6 +24,7 @@
- 上传先写真实文件再登记 IAM 元数据;异常结果未知时保留文件供重试,禁止伪造 URL。
- 所有文件按客户、入库单、文件类型校验归属;重量图片可 OCR,第三方尺寸证据不得触发重量 OCR。
- 商品图片平台读取必须同时校验平台身份、`platform:order:list`、客户范围、文件业务类型与正整数 ID;不得提供匿名 URL、跨客户读取或把内部存储路径返回页面。
- 公共导出只接受 `ExportDefinitionRegistry` 明确登记的 `exportType`,由 File 调用所属业务服务的内部 Provider 分批取数并生成文件;业务页面不得直连 Provider。新增或修改导出类型时同步检查业务服务 Provider、File Feign 客户端/响应上限、权限、Admin 下载中心标签与重试映射及契约测试。
- 生产环境显式配置 `FILE_STORAGE_ROOT`;磁盘故障返回 `S0503`,不得包装成业务错误。
@@ -13,8 +13,8 @@ final class FinanceOperatorSnapshot {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof AuthenticatedUser user
&& user.userId() != null && user.userId() == operatorId
&& StringUtils.hasText(user.username())) {
return user.username().trim();
&& (StringUtils.hasText(user.displayName()) || StringUtils.hasText(user.username()))) {
return StringUtils.hasText(user.displayName()) ? user.displayName().trim() : user.username().trim();
}
return null;
}
@@ -41,23 +41,26 @@ public class ReceivableStatisticsService {
LocalDate from = month.atDay(1);
LocalDate to = month.plusMonths(1).atDay(1);
StringBuilder sql = new StringBuilder("""
SELECT tenant_id, customer_name_snapshot,
SUM(total_amount) total_receivable,
SUM(paid_amount) total_received,
SUM(outstanding_amount) total_outstanding
FROM fin_settlement
WHERE direction='RECEIVABLE'
AND status<>'VOIDED'
AND currency_code='CNY'
AND generated_date>=?
AND generated_date<?
SELECT s.tenant_id,
COALESCE(p.customer_name_snapshot, MAX(s.customer_name_snapshot)) customer_name_snapshot,
SUM(s.total_amount) total_receivable,
SUM(s.paid_amount) total_received,
SUM(s.outstanding_amount) total_outstanding
FROM fin_settlement s
LEFT JOIN fin_customer_settlement_profile p
ON p.tenant_id=s.tenant_id AND p.status='ACTIVE'
WHERE s.direction='RECEIVABLE'
AND s.status<>'VOIDED'
AND s.currency_code='CNY'
AND s.generated_date>=?
AND s.generated_date<?
""");
List<Object> args = new ArrayList<>(List.of(from, to));
if (customerId != null) {
sql.append(" AND tenant_id=?");
sql.append(" AND s.tenant_id=?");
args.add(customerId);
}
sql.append(" GROUP BY tenant_id,customer_name_snapshot ORDER BY customer_name_snapshot,tenant_id");
sql.append(" GROUP BY s.tenant_id,p.customer_name_snapshot ORDER BY customer_name_snapshot,s.tenant_id");
return jdbc.query(sql.toString(), (rs, rowNum) -> checked(new ReceivableStatisticsRow(
rs.getLong("tenant_id"), rs.getString("customer_name_snapshot"), CURRENCY,
rs.getBigDecimal("total_receivable"), rs.getBigDecimal("total_received"),
@@ -40,8 +40,8 @@ class ReceivableStatisticsServiceTest {
assertThat(result.records()).containsExactly(rows.get(0));
var sql = org.mockito.ArgumentCaptor.forClass(String.class);
verify(jdbc).query(sql.capture(), any(RowMapper.class), any(Object[].class));
assertThat(sql.getValue()).contains("direction='RECEIVABLE'", "status<>'VOIDED'", "currency_code='CNY'",
"generated_date>=?", "generated_date<?", "GROUP BY tenant_id,customer_name_snapshot");
assertThat(sql.getValue()).contains("s.direction='RECEIVABLE'", "s.status<>'VOIDED'", "s.currency_code='CNY'",
"s.generated_date>=?", "s.generated_date<?", "fin_customer_settlement_profile", "GROUP BY s.tenant_id,p.customer_name_snapshot");
}
@Test
@@ -28,6 +28,16 @@ class FinanceOperatorSnapshotTest {
assertThat(FinanceOperatorSnapshot.currentName(72L)).isNull();
}
@Test
void prefersAuthenticatedDisplayNameOverLoginUsername() {
AuthenticatedUser user = new AuthenticatedUser(71L, null, null, null,
"12555214478", "廖万昌", "MERCHANT", "WEB", Set.of(), false, null, null, false);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(user, null));
assertThat(FinanceOperatorSnapshot.currentName(71L)).isEqualTo("廖万昌");
}
@Test
void labelsSystemGeneratedEntriesWithoutInventingAnOperator() {
assertThat(FinanceOperatorSnapshot.currentName(0L)).isEqualTo("系统");
+7 -2
View File
@@ -14,7 +14,7 @@
| 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` |
| 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` |
| 平台测量/OCR | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java`;手工单箱测量也从该 Controller 与 `CartonMeasurementService` 进入 |
| 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`;基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql` |
| 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`。`fl_inbound_carton_label.sku_code` 是箱唛的 SKU 冻结快照,生成时经 `client/OrderCargoClient.java` 获取;既有记录按 `inbound_detail_id` 回填。`ONE_CLICK_DROPSHIP` 缺 SKU 时禁止生成箱唛。`box_code`、二维码内容和现有打印版式不携带 SKU,不能因该绑定改变。基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql`、`../deploy/sql/头程数据库统一增量-MySQL8.sql` |
| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` |
| 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` |
| FBA 主数据/计费 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java` |
@@ -29,14 +29,16 @@
| 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` |
| 分柜与预装 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java` |
| 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`loading/`;按入仓码候选与箱数分配分别从 `/inbound-candidates`、`/plans/{planId}/inbound-assignments` 进入,明细规格拆分留在服务端 |
| 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 |
| PDA 拣货与装柜 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java` |
| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;从 `fl_inbound_order.destination_type` 冻结 V2 `orders[].destinationType=FBA|OVERSEAS`,OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` |
| 三期海运订舱、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;关联候选入口 `/api/platform/v1/ocean-bookings/link-candidates`,订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` |
| 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/四期/船节点标准化契约.md` |
| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 |
| 拖车与出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java` |
| 拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;按真实 `containerBookingId` 查询聚合和 `gate`、确认离仓,契约 `src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml` |
| 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` |
| 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`EventBridgeClient.java` |
@@ -51,6 +53,7 @@
- 价格簿查询消费者依赖 `PriceBookQueryUseCase` 或稳定查询端口,不得反向依赖持久化 Mapper;移动旧查询类时同步更新生产调用、测试、Mock 与 import,避免同一职责在根 `repository/mapper` 和 `pricebookquery/` 双写。
- 第三方回传必须签名、时间窗、防重放、请求哈希,并绑定订单货物和 File 证据;不得信任外部客户字段。
- 稳定箱码映射以入库快照和 Order 货物明细为事实;订单审核创建入库单时必须幂等生成,打印接口只复用或补齐同一映射,用户是否下载箱唛不得决定箱码有效性。手工测量、墨家设备和 PDA 扫码统一通过 `InboundCartonLabelService` 校验客户、入库单、货物明细、总箱数/下标和有效批次,不得在测量时临时伪造箱码或接受跨客户、跨入库箱码。
- 一件代发以 `fl_inbound_carton_label.sku_code` 作为不可变 SKU 快照;PDA 仍只扫描稳定 `box_code`,不得将 SKU 塞入二维码或以 SKU 替代箱码。实际打托后的查询必须能按“现场托盘 -> 客户 -> SKU -> 箱数”聚合;历史箱唛仅能在同客户、同入库单范围内按 `inbound_detail_id` 回填,禁止按货名、箱规或箱号猜测 SKU。修改本链路时同步检查 `OrderCargoClient`、Order 内部货物接口及 PDA 打托实现。
- 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。
- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),拖车使用 `TRUCKING`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
@@ -58,6 +61,8 @@
- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。
- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/四期/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。
- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,国内出口报关资料必须存在且已完成,拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。
- 手工船节点的预计时间只追加事实,不推进 `latestStandardNode`;携带 `actualAt` 时标准节点序号必须严格大于最新实际节点,重复、相同或回退节点返回冲突。已审核实际时间修正只能更新同一事实的时间/证据,不得制造第二条同节点实际事实或绕过单调推进。
- 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜照片上传时间加两天计算,实际报空时间可为空。
- Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。
- `DIRECT_PAID` 仅适用于 `PAYABLE`,必须同时保存 `paidAt`、合法 `paymentMethod`,并至少提供 `paymentReferenceNo` 或一个当前客户范围内的 `FINANCE_VOUCHER`;`RECEIVABLE` 与 `ON_ACCOUNT` 不得残留付款字段。创建、调整和审核前均通过 `FeePaymentSnapshotValidator` 失败关闭复核。
@@ -63,7 +63,12 @@ public interface OrderCargoClient {
}
record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm,
String skuNo, Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm,
BigDecimal heightCm) {
public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm,
BigDecimal heightCm) {
this(cargoId, orderId, itemNo, cargoName, null, packageCount, lengthCm, widthCm, heightCm);
}
}
}
@@ -4,8 +4,9 @@ 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.event.EventEnvelope;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.common.event.EventTypes;
import com.baiyue.firstleg.service.OverseasContainerEventConsumer;
import com.baiyue.firstleg.service.OverseasInboundOrderDetainedConsumer;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PostMapping;
@@ -20,14 +21,17 @@ import java.security.MessageDigest;
@RequiredArgsConstructor
public class InternalOverseasContainerEventController {
private final OverseasContainerEventConsumer consumer;
private final OverseasInboundOrderDetainedConsumer detainedConsumer;
@Value("${baiyue.security.internal-service-token}") private String expectedToken;
@PostMapping("/internal/firstleg/v1/container-events")
public ApiResponse<ContainerBookingEntity> receive(
public ApiResponse<Object> receive(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestBody EventEnvelope event) {
authenticate(token);
return ApiResponse.success(consumer.consume(event));
Object result = EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED.equals(event.eventType())
? detainedConsumer.consume(event) : consumer.consume(event);
return ApiResponse.success(result);
}
private void authenticate(String token) {
@@ -17,6 +17,11 @@ import java.util.List;
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.LoadingPickService;
import com.baiyue.firstleg.service.LoadingWorkPalletService;
import com.baiyue.firstleg.dto.LoadingPalletDtos.OpenRequest;
import com.baiyue.firstleg.dto.LoadingPalletDtos.ScanRequest;
import com.baiyue.firstleg.dto.LoadingPalletDtos.SealRequest;
import com.baiyue.firstleg.dto.LoadingPalletDtos.Pallet;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
@@ -41,6 +46,8 @@ import org.springframework.web.bind.annotation.RestController;
public class PlatformLoadingPdaController {
@Resource
private LoadingPickService service;
@Resource
private LoadingWorkPalletService palletService;
@GetMapping("/tasks")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
@@ -164,4 +171,45 @@ public class PlatformLoadingPdaController {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.operations(taskId));
}
@GetMapping("/tasks/{taskId}/pallets")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<List<Pallet>> pallets(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(palletService.list(taskId, warehouseId));
}
@PostMapping("/tasks/{taskId}/pallets/open")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Pallet> openPallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@Valid @RequestBody OpenRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(palletService.open(taskId, request, user.userId(), user.username()));
}
/** 扫到其他未封托托盘上的箱唛时,同时完成按箱移托。 */
@PostMapping("/tasks/{taskId}/pallets/{palletId}/cartons/scan")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Pallet> scanCarton(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@PathVariable @Positive long palletId,
@Valid @RequestBody ScanRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(palletService.scan(taskId, palletId, request,
user.userId(), user.username()));
}
@PostMapping("/tasks/{taskId}/pallets/{palletId}/seal")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Pallet> sealPallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@PathVariable @Positive long palletId,
@Valid @RequestBody SealRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(palletService.seal(taskId, palletId, request,
user.userId(), user.username()));
}
}
@@ -0,0 +1,41 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
/** 一件代发PDA现场打托接口模型。 */
public final class LoadingPalletDtos {
private LoadingPalletDtos() {}
public record OpenRequest(
@NotNull @Min(1) Long warehouseId,
@Min(1) Long warehousePalletId,
@NotBlank @Size(max = 64) String palletCode,
@NotBlank @Size(max = 128) String operationId
) {}
public record ScanRequest(
@NotNull @Min(1) Long warehouseId,
@NotBlank @Size(max = 80) String boxCode,
@NotBlank @Size(max = 128) String operationId
) {}
public record SealRequest(
@NotNull @Min(1) Long warehouseId,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 128) String operationId
) {}
public record Carton(Long id, String boxCode, String skuCode, Long tenantId,
Long inboundId, Long inboundDetailId, Integer unitsPerCarton) {}
public record SkuSummary(String skuCode, Integer cartonCount, Long productQuantity) {}
public record Pallet(Long id, String palletCode, Long warehousePalletId, String status,
Integer version, Integer cartonCount, Integer skuCount,
Integer customerCount, List<SkuSummary> skus, List<Carton> cartons) {}
}
@@ -22,6 +22,7 @@ public class InboundCartonLabelEntity {
private Long inboundDetailId;
private Integer cargoItemNo;
private String cargoName;
private String skuCode;
private Integer detailPackageCount;
private String inboundCode;
private String boxCode;
@@ -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_hold_record")
public class InboundHoldRecordEntity {
@TableId(type = IdType.INPUT) private Long id;
private Long tenantId;
private Long inboundId;
private String holdAction;
private String reason;
private Long evidenceFileId;
private Long operatorId;
private String operatorName;
private LocalDateTime operatedAt;
}
@@ -53,6 +53,7 @@ public class InboundOrderEntity extends BaseEntity {
private String destinationName;
private Boolean holdFlag;
private String currentHoldReason;
private Long lastOverseasHoldVersion;
private Integer totalPackageCount;
private BigDecimal actualGrossWeightKg;
private BigDecimal chargeableWeightKg;
@@ -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.baomidou.mybatisplus.annotation.Version;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 一件代发箱唛与现场实际托盘的当前绑定事实。 */
@Getter
@Setter
@TableName("fl_loading_work_pallet_carton")
public class LoadingWorkPalletCartonEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long pickTaskId;
private Long workPalletId;
private Long loadingPlanItemId;
private Long pickItemId;
private Long tenantId;
private Long inboundId;
private Long inboundDetailId;
private Long cartonLabelId;
private String boxCode;
private String skuCode;
private Integer unitsPerCarton;
private Long boundBy;
private String boundByName;
private String operationId;
@Version
private Integer version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -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.baomidou.mybatisplus.annotation.Version;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 一件代发装柜现场的实际托盘。 */
@Getter
@Setter
@TableName("fl_loading_work_pallet")
public class LoadingWorkPalletEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long pickTaskId;
private Long loadingPlanId;
private Long warehouseId;
private Long warehousePalletId;
private String palletCode;
private String openOperationId;
private String sealOperationId;
private String palletStatus;
private Long createdBy;
private String createdByName;
private Long sealedBy;
private String sealedByName;
private LocalDateTime sealedAt;
@Version
private Integer version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -182,7 +182,8 @@ public class LoadingDeliveryService {
}
loadingModelBridge.requireCompleted(tenantId, target.sourceInboundId());
completionGate.requireReleasedBooking(tenantId, target.sourceInboundId());
requireCompletedPicking(tenantId, target.sourceInboundId());
// The cabinet PDA task is the source of truth while settling. The inbound-level
// picking review is completed after this transition and must not gate it here.
requireCompletionEvidence(tenantId, evidenceInboundId);
if (repository.completeLoadedChild(tenantId, target.sourceInboundId(), operatorId,
request.expectedVersion()) != 1) throw concurrent();
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundHoldRecordEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundHoldRecordMapper extends BaseMapper<InboundHoldRecordEntity> {
}
@@ -5,10 +5,25 @@ import com.baiyue.firstleg.entity.InboundOrderEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import org.apache.ibatis.annotations.Select;
/** 入库聚合根条件更新 Mapper,所有推进均同时校验租户、前置状态和版本。 */
@Mapper
public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> {
@Select("SELECT * FROM fl_inbound_order WHERE id=#{inboundId} AND deleted=0 FOR UPDATE")
InboundOrderEntity selectActiveByIdForUpdate(@Param("inboundId") long inboundId);
@Update("""
UPDATE fl_inbound_order
SET hold_flag=#{detained}, current_hold_reason=#{reason},
last_overseas_hold_version=#{sourceVersion}, updated_by=#{operatorId}, version=version+1
WHERE id=#{inboundId} AND tenant_id=#{tenantId} AND deleted=0
AND (last_overseas_hold_version IS NULL OR last_overseas_hold_version < #{sourceVersion})
""")
int applyOverseasHold(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("detained") boolean detained, @Param("reason") String reason,
@Param("sourceVersion") long sourceVersion, @Param("operatorId") long operatorId);
@Update("""
UPDATE fl_inbound_order
SET process_status = 'WAIT_SPLIT', measure_review_status = 'APPROVED',
@@ -48,7 +48,7 @@ public interface LoadingCompletedEventMapper {
/** 读取头程托盘及箱唛快照;普通订单向尾程交接打印编号,一键代发同时冻结托盘库存。 */
@Select("""
SELECT i.account_id, i.inbound_id AS source_inbound_id,
SELECT i.account_id, i.inbound_id AS source_inbound_id,0 AS actual_binding,
a.pallet_code, a.allocation_no, a.picked_package_count AS allocated_package_count,
i.inbound_detail_id AS source_inbound_detail_id, i.sku_code,
i.product_name_zh, i.package_count, i.product_quantity,
@@ -75,6 +75,29 @@ public interface LoadingCompletedEventMapper {
""")
List<PalletRow> palletRows(@Param("planId") long planId);
/** 一件代发现场逐箱扫描结果;同一实物托盘可以包含多个客户,但行仍保留客户与入仓单归属。 */
@Select("""
SELECT carton.tenant_id AS account_id,carton.inbound_id AS source_inbound_id,1 AS actual_binding,
pallet.pallet_code,NULL AS allocation_no,
COUNT(*) OVER (PARTITION BY pallet.id,carton.tenant_id,carton.inbound_id) AS allocated_package_count,
carton.inbound_detail_id AS source_inbound_detail_id,carton.sku_code,
item.product_name_zh,item.package_count,item.product_quantity,carton.box_code
FROM fl_loading_work_pallet pallet
JOIN fl_loading_work_pallet_carton carton ON carton.work_pallet_id=pallet.id
JOIN fl_loading_plan_item item ON item.id=carton.loading_plan_item_id AND item.deleted=0
JOIN fl_loading_pick_task task ON task.id=pallet.pick_task_id
AND task.loading_plan_id=pallet.loading_plan_id
WHERE pallet.loading_plan_id=#{planId} AND pallet.pallet_status='SEALED'
ORDER BY carton.tenant_id,carton.inbound_id,pallet.id,item.inbound_detail_id,carton.id
""")
List<PalletRow> actualPalletRows(@Param("planId") long planId);
@Select("SELECT COUNT(*) FROM fl_loading_work_pallet WHERE loading_plan_id=#{planId} AND pallet_status<>'CANCELLED'")
int workPalletCount(@Param("planId") long planId);
@Select("SELECT COUNT(*) FROM fl_loading_work_pallet WHERE loading_plan_id=#{planId} AND pallet_status='OPEN'")
int openWorkPalletCount(@Param("planId") long planId);
@Getter @Setter
class Row {
private Long loadingPlanId; private String planNo; private Long containerBookingId;
@@ -96,6 +119,7 @@ public interface LoadingCompletedEventMapper {
@Getter @Setter
class PalletRow {
private Long accountId; private Long sourceInboundId; private String palletCode;
private Boolean actualBinding;
private Integer allocationNo; private Integer allocatedPackageCount;
private Long sourceInboundDetailId; private String skuCode; private String productNameZh;
private Integer packageCount; private Integer productQuantity; private String boxCode;
@@ -0,0 +1,90 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.LoadingWorkPalletCartonEntity;
import lombok.Getter;
import lombok.Setter;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface LoadingWorkPalletCartonMapper extends BaseMapper<LoadingWorkPalletCartonEntity> {
@Select("""
SELECT label.id carton_label_id,label.tenant_id,label.inbound_id,label.inbound_detail_id,
label.box_code,plan_item.id loading_plan_item_id,pick_item.id pick_item_id,
plan_item.sku_code,plan_item.units_per_carton
FROM fl_inbound_carton_label label
JOIN fl_loading_plan_item plan_item
ON plan_item.account_id=label.tenant_id AND plan_item.inbound_id=label.inbound_id
AND plan_item.inbound_detail_id=label.inbound_detail_id AND plan_item.deleted=0
JOIN fl_loading_pick_item pick_item ON pick_item.loading_plan_item_id=plan_item.id
JOIN fl_loading_pick_task task ON task.id=pick_item.pick_task_id
AND task.loading_plan_id=plan_item.loading_plan_id
JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id
JOIN fl_container_booking booking ON booking.id=plan.container_booking_id
JOIN fl_inbound_order inbound_order ON inbound_order.id=plan_item.inbound_id
AND inbound_order.tenant_id=plan_item.account_id AND inbound_order.deleted=0
WHERE task.id=#{taskId} AND task.task_status='LOADING' AND booking.warehouse_id=#{warehouseId}
AND label.box_code=#{boxCode} AND label.batch_status='ACTIVE'
AND inbound_order.fulfillment_type='ONE_CLICK_DROPSHIP'
AND pick_item.item_status<>'SKIPPED'
ORDER BY label.tenant_id,label.id
""")
List<CartonCandidate> selectCandidates(@Param("taskId") long taskId,
@Param("warehouseId") long warehouseId,
@Param("boxCode") String boxCode);
@Select("SELECT * FROM fl_loading_work_pallet_carton WHERE pick_task_id=#{taskId} AND carton_label_id=#{labelId}")
LoadingWorkPalletCartonEntity selectBinding(@Param("taskId") long taskId,
@Param("labelId") long labelId);
@Select("SELECT * FROM fl_loading_work_pallet_carton WHERE operation_id=#{operationId}")
LoadingWorkPalletCartonEntity selectByOperation(@Param("operationId") String operationId);
@Select("SELECT * FROM fl_loading_work_pallet_carton WHERE work_pallet_id=#{palletId} ORDER BY id")
List<LoadingWorkPalletCartonEntity> selectPalletCartons(@Param("palletId") long palletId);
@Select("SELECT COUNT(*) FROM fl_loading_work_pallet_carton WHERE work_pallet_id=#{palletId}")
int countPalletCartons(@Param("palletId") long palletId);
@Select("""
SELECT COUNT(*) FROM (
SELECT carton.pick_item_id
FROM fl_loading_work_pallet_carton carton
JOIN fl_loading_pick_item pick_item ON pick_item.id=carton.pick_item_id
WHERE carton.pick_task_id=#{taskId}
GROUP BY carton.pick_item_id,pick_item.picked_package_count
HAVING COUNT(*)>pick_item.picked_package_count
) unreconciled
""")
int countUnreconciled(@Param("taskId") long taskId);
@Update("""
UPDATE fl_loading_work_pallet_carton
SET work_pallet_id=#{targetPalletId},bound_by=#{operatorId},bound_by_name=#{operatorName},
operation_id=#{operationId},version=version+1
WHERE id=#{id} AND version=#{version}
""")
int move(@Param("id") long id, @Param("version") int version,
@Param("targetPalletId") long targetPalletId,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName,
@Param("operationId") String operationId);
@Getter
@Setter
class CartonCandidate {
private Long cartonLabelId;
private Long tenantId;
private Long inboundId;
private Long inboundDetailId;
private String boxCode;
private Long loadingPlanItemId;
private Long pickItemId;
private String skuCode;
private Integer unitsPerCarton;
}
}
@@ -0,0 +1,77 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.LoadingWorkPalletEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface LoadingWorkPalletMapper extends BaseMapper<LoadingWorkPalletEntity> {
@Select("""
SELECT task.id task_id,task.loading_plan_id,task.task_status,booking.warehouse_id,
assignment.assignee_id,
SUM(CASE WHEN inbound_order.fulfillment_type='ONE_CLICK_DROPSHIP' THEN 1 ELSE 0 END) dropship_item_count
FROM fl_loading_pick_task task
JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id
JOIN fl_container_booking booking ON booking.id=plan.container_booking_id
JOIN fl_loading_pick_item pick_item ON pick_item.pick_task_id=task.id
JOIN fl_loading_plan_item plan_item ON plan_item.id=pick_item.loading_plan_item_id AND plan_item.deleted=0
JOIN fl_inbound_order inbound_order ON inbound_order.id=plan_item.inbound_id
AND inbound_order.tenant_id=plan_item.account_id AND inbound_order.deleted=0
LEFT JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id
WHERE task.id=#{taskId} AND booking.warehouse_id=#{warehouseId}
GROUP BY task.id,task.loading_plan_id,task.task_status,booking.warehouse_id,assignment.assignee_id
""")
TaskContext selectTaskContext(@Param("taskId") long taskId, @Param("warehouseId") long warehouseId);
@Select("SELECT * FROM fl_loading_work_pallet WHERE open_operation_id=#{operationId}")
LoadingWorkPalletEntity selectByOpenOperation(@Param("operationId") String operationId);
@Select("SELECT * FROM fl_loading_work_pallet WHERE seal_operation_id=#{operationId}")
LoadingWorkPalletEntity selectBySealOperation(@Param("operationId") String operationId);
@Select("""
SELECT pallet.*
FROM fl_loading_work_pallet pallet
JOIN fl_loading_pick_task task ON task.id=pallet.pick_task_id
JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id
JOIN fl_container_booking booking ON booking.id=plan.container_booking_id
WHERE pallet.pick_task_id=#{taskId} AND pallet.id=#{palletId}
AND booking.warehouse_id=#{warehouseId}
""")
LoadingWorkPalletEntity selectScoped(@Param("taskId") long taskId,
@Param("palletId") long palletId,
@Param("warehouseId") long warehouseId);
@Select("""
SELECT pallet.*
FROM fl_loading_work_pallet pallet
JOIN fl_loading_pick_task task ON task.id=pallet.pick_task_id
JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id
JOIN fl_container_booking booking ON booking.id=plan.container_booking_id
WHERE pallet.pick_task_id=#{taskId} AND booking.warehouse_id=#{warehouseId}
AND pallet.pallet_status<>'CANCELLED'
ORDER BY pallet.id
""")
List<LoadingWorkPalletEntity> selectTaskPallets(@Param("taskId") long taskId,
@Param("warehouseId") long warehouseId);
@Update("""
UPDATE fl_loading_work_pallet
SET pallet_status='SEALED',seal_operation_id=#{operationId},sealed_by=#{operatorId},
sealed_by_name=#{operatorName},sealed_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{palletId} AND pick_task_id=#{taskId} AND warehouse_id=#{warehouseId}
AND pallet_status='OPEN' AND version=#{expectedVersion}
""")
int seal(@Param("taskId") long taskId, @Param("palletId") long palletId,
@Param("warehouseId") long warehouseId, @Param("expectedVersion") int expectedVersion,
@Param("operationId") String operationId, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
record TaskContext(Long taskId, Long loadingPlanId, String taskStatus, Long warehouseId,
Long assigneeId, Integer dropshipItemCount) {}
}
@@ -62,6 +62,16 @@ public class InboundCartonLabelRepository {
.set(InboundCartonLabelEntity::getCargoFingerprint, fingerprint));
}
public void initializeLegacySku(long tenantId, long inboundId, long inboundDetailId, String skuCode) {
if (skuCode == null || skuCode.isBlank()) return;
labels.update(null, new LambdaUpdateWrapper<InboundCartonLabelEntity>()
.eq(InboundCartonLabelEntity::getTenantId, tenantId)
.eq(InboundCartonLabelEntity::getInboundId, inboundId)
.eq(InboundCartonLabelEntity::getInboundDetailId, inboundDetailId)
.isNull(InboundCartonLabelEntity::getSkuCode)
.set(InboundCartonLabelEntity::getSkuCode, skuCode.trim()));
}
public boolean initializeOriginalPackageCount(long tenantId, long inboundId, int packageCount) {
return inbounds.update(null, new LambdaUpdateWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
@@ -50,6 +50,14 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
throw new BizException(ResultCode.CONFLICT, "当前入库单状态不允许首次生成箱号");
}
List<OrderCargoClient.CargoLabelBinding> cargoes = loadCargoes(inbound);
if ("ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())
&& cargoes.stream().anyMatch(cargo -> cargo.skuNo() == null || cargo.skuNo().isBlank())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "一件代发货物明细缺少SKU,不能生成箱唛");
}
existing.forEach(label -> cargoes.stream()
.filter(cargo -> java.util.Objects.equals(cargo.cargoId(), label.getInboundDetailId()))
.findFirst().ifPresent(cargo -> repository.initializeLegacySku(tenantId, inboundId,
label.getInboundDetailId(), cargo.skuNo())));
String fingerprint = fingerprint(cargoes);
if (!existing.isEmpty()) {
String existingFingerprint = consistent.get(0).getCargoFingerprint();
@@ -202,6 +210,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
label.setInboundDetailId(cargo.cargoId());
label.setCargoItemNo(cargo.itemNo());
label.setCargoName(cargo.cargoName());
label.setSkuCode(cargo.skuNo());
label.setDetailPackageCount(cargo.packageCount());
label.setInboundCode(inbound.getInboundCode());
label.setBoxCode(inbound.getInboundCode() + "#" + total + "-" + index);
@@ -256,7 +265,8 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
private String fingerprint(List<OrderCargoClient.CargoLabelBinding> cargoes) {
String source = cargoes.stream().map(cargo -> String.join("|", String.valueOf(cargo.itemNo()),
cargo.cargoName() == null ? "" : cargo.cargoName().trim(), String.valueOf(cargo.packageCount()),
cargo.cargoName() == null ? "" : cargo.cargoName().trim(),
cargo.skuNo() == null ? "" : cargo.skuNo().trim(), String.valueOf(cargo.packageCount()),
decimalOrEmpty(cargo.lengthCm()), decimalOrEmpty(cargo.widthCm()), decimalOrEmpty(cargo.heightCm())))
.collect(java.util.stream.Collectors.joining("\n"));
try {
@@ -50,13 +50,33 @@ public class LoadingCompletedOutboxService {
List<Row> rows = snapshotMapper.rows(planId);
if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件");
Map<Long, List<String>> cartonMarks = cartonLabelPools(rows);
List<PalletRow> palletRows = snapshotMapper.palletRows(planId);
List<PalletRow> palletRows = palletRows(planId);
rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId,
java.util.TreeMap::new, java.util.stream.Collectors.toList()))
.forEach((accountId, accountRows) -> insert(envelope(accountId, accountRows,
palletRows.stream().filter(item -> accountId.equals(item.getAccountId())).toList(), cartonMarks)));
}
/** 已开展逐箱打托的入仓单使用现场事实;未开展的存量入仓单继续兼容原托盘分配快照。 */
private List<PalletRow> palletRows(long planId) {
int workPalletCount = snapshotMapper.workPalletCount(planId);
if (snapshotMapper.openWorkPalletCount(planId) > 0) {
throw new BizException(ResultCode.CONFLICT, "一件代发存在未封托的现场托盘,不能完成装柜交接");
}
List<PalletRow> actual = snapshotMapper.actualPalletRows(planId);
if (workPalletCount > 0 && actual.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "一件代发现场托盘没有有效箱唛,不能完成装柜交接");
}
if (actual.isEmpty()) return snapshotMapper.palletRows(planId);
Set<InboundPalletKey> actualInbounds = actual.stream().map(row ->
new InboundPalletKey(row.getAccountId(), row.getSourceInboundId())).collect(java.util.stream.Collectors.toSet());
List<PalletRow> result = new ArrayList<>(actual);
snapshotMapper.palletRows(planId).stream()
.filter(row -> !actualInbounds.contains(new InboundPalletKey(row.getAccountId(), row.getSourceInboundId())))
.forEach(result::add);
return result;
}
private EventEnvelope envelope(long accountId, List<Row> rows, List<PalletRow> palletRows,
Map<Long, List<String>> cartonMarks) {
Row first = rows.get(0);
@@ -182,9 +202,13 @@ public class LoadingCompletedOutboxService {
}
private record LabelPoolKey(long accountId, long inboundId) { }
private record InboundPalletKey(long accountId, long inboundId) { }
private List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> palletSnapshots(List<Row> values, List<PalletRow> source) {
if (source.isEmpty()) return List.of();
if (source.stream().anyMatch(item -> Boolean.TRUE.equals(item.getActualBinding()))) {
return actualPalletSnapshots(values, source);
}
Map<String, List<PalletRow>> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy(
p -> (p.getAllocationNo() == null ? "" : p.getAllocationNo()) + "|" + p.getPalletCode(),
LinkedHashMap::new, java.util.stream.Collectors.toList()));
@@ -235,6 +259,43 @@ public class LoadingCompletedOutboxService {
return result;
}
/** 现场逐箱扫描已给出精确箱到托关系,不再使用旧的按托盘容量顺序推算。 */
private List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> actualPalletSnapshots(
List<Row> values, List<PalletRow> source) {
Map<Long, Row> details = values.stream().collect(java.util.stream.Collectors.toMap(
Row::getSourceInboundDetailId, value -> value, (left, right) -> left, LinkedHashMap::new));
Map<String, List<PalletRow>> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy(
PalletRow::getPalletCode, LinkedHashMap::new, java.util.stream.Collectors.toList()));
List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> result = new ArrayList<>();
int actualPackages = 0;
for (Map.Entry<String, List<PalletRow>> pallet : byPallet.entrySet()) {
Map<Long, List<PalletRow>> byDetail = pallet.getValue().stream().collect(
java.util.stream.Collectors.groupingBy(PalletRow::getSourceInboundDetailId,
LinkedHashMap::new, java.util.stream.Collectors.toList()));
List<FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot> skus = new ArrayList<>();
for (Map.Entry<Long, List<PalletRow>> detailRows : byDetail.entrySet()) {
Row detail = details.get(detailRows.getKey());
if (detail == null) throw new BizException(ResultCode.CONFLICT, "现场托盘箱唛无法匹配装柜货物明细");
List<String> marks = detailRows.getValue().stream().map(PalletRow::getBoxCode)
.filter(Objects::nonNull).distinct().toList();
if (marks.size() != detailRows.getValue().size()) {
throw new BizException(ResultCode.CONFLICT, "现场托盘包含空箱唛或重复箱唛");
}
int quantity = detail.getProductQuantity() == null ? marks.size()
: (int) Math.round((double) detail.getProductQuantity() * marks.size()
/ Math.max(1, detail.getPackageCount()));
skus.add(new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(detail.getSkuCode(),
detail.getProductNameZh(), marks.size(), quantity, marks));
actualPackages += marks.size();
}
result.add(new FirstLegLoadingPlanCompletedV2.PalletSnapshot(pallet.getKey(), skus));
}
if (actualPackages != sumInt(values, Row::getPackageCount)) {
throw new BizException(ResultCode.CONFLICT, "一件代发现场托盘箱唛数与实装箱数不一致");
}
return result;
}
private static boolean dropship(String value) {
return "ONE_CLICK_DROPSHIP".equals(value);
}
@@ -0,0 +1,206 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.LoadingPalletDtos.Carton;
import com.baiyue.firstleg.dto.LoadingPalletDtos.OpenRequest;
import com.baiyue.firstleg.dto.LoadingPalletDtos.Pallet;
import com.baiyue.firstleg.dto.LoadingPalletDtos.ScanRequest;
import com.baiyue.firstleg.dto.LoadingPalletDtos.SealRequest;
import com.baiyue.firstleg.dto.LoadingPalletDtos.SkuSummary;
import com.baiyue.firstleg.entity.LoadingWorkPalletCartonEntity;
import com.baiyue.firstleg.entity.LoadingWorkPalletEntity;
import com.baiyue.firstleg.mapper.LoadingWorkPalletCartonMapper;
import com.baiyue.firstleg.mapper.LoadingWorkPalletCartonMapper.CartonCandidate;
import com.baiyue.firstleg.mapper.LoadingWorkPalletMapper;
import com.baiyue.firstleg.mapper.LoadingWorkPalletMapper.TaskContext;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/** 一件代发现场打托旁路;不改变既有拣货数量和仓库资源结算。 */
@Service
public class LoadingWorkPalletService {
@Resource private LoadingWorkPalletMapper pallets;
@Resource private LoadingWorkPalletCartonMapper cartons;
@Transactional
public Pallet open(long taskId, OpenRequest request, long operatorId, String operatorName) {
TaskContext context = requireWorkTask(taskId, request.warehouseId(), operatorId);
String code = request.palletCode().trim();
String operationId = request.operationId().trim();
LoadingWorkPalletEntity replay = pallets.selectByOpenOperation(operationId);
if (replay != null) {
if (replay.getPickTaskId() != taskId || !replay.getPalletCode().equals(code)) {
throw conflict("开托幂等号已用于其他请求");
}
return view(replay);
}
LoadingWorkPalletEntity value = new LoadingWorkPalletEntity();
value.setPickTaskId(taskId);
value.setLoadingPlanId(context.loadingPlanId());
value.setWarehouseId(request.warehouseId());
value.setWarehousePalletId(request.warehousePalletId());
value.setPalletCode(code);
value.setOpenOperationId(operationId);
value.setPalletStatus("OPEN");
value.setCreatedBy(operatorId);
value.setCreatedByName(operatorName);
value.setVersion(0);
try {
pallets.insert(value);
} catch (DuplicateKeyException exception) {
replay = pallets.selectByOpenOperation(operationId);
if (replay == null || replay.getPickTaskId() != taskId || !replay.getPalletCode().equals(code)) {
throw conflict("托盘号已存在或开托操作重复");
}
value = replay;
}
return view(value);
}
@Transactional
public Pallet scan(long taskId, long palletId, ScanRequest request,
long operatorId, String operatorName) {
requireWorkTask(taskId, request.warehouseId(), operatorId);
LoadingWorkPalletEntity target = requireOpenPallet(taskId, palletId, request.warehouseId());
String operationId = request.operationId().trim();
LoadingWorkPalletCartonEntity replay = cartons.selectByOperation(operationId);
if (replay != null) {
if (replay.getPickTaskId() != taskId || replay.getWorkPalletId() != palletId
|| !replay.getBoxCode().equals(request.boxCode().trim())) {
throw conflict("箱唛操作幂等号已用于其他请求");
}
return view(target);
}
List<CartonCandidate> candidates = cartons.selectCandidates(taskId, request.warehouseId(),
request.boxCode().trim());
if (candidates.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "箱唛不属于当前任务的一件代发货物");
if (candidates.size() > 1) throw conflict("箱唛在当前多客户任务中不唯一,请检查箱唛数据");
CartonCandidate candidate = candidates.get(0);
if (candidate.getSkuCode() == null || candidate.getSkuCode().isBlank()) {
throw conflict("一件代发箱唛缺少SKU,不能上托");
}
LoadingWorkPalletCartonEntity current = cartons.selectBinding(taskId, candidate.getCartonLabelId());
if (current == null) {
current = binding(taskId, palletId, candidate, operationId, operatorId, operatorName);
try {
cartons.insert(current);
} catch (DuplicateKeyException exception) {
LoadingWorkPalletCartonEntity concurrent = cartons.selectBinding(taskId, candidate.getCartonLabelId());
if (concurrent == null || concurrent.getWorkPalletId() != palletId) {
throw conflict("箱唛已被其他托盘绑定");
}
}
return view(target);
}
if (current.getWorkPalletId() == palletId) return view(target);
LoadingWorkPalletEntity source = pallets.selectScoped(taskId, current.getWorkPalletId(), request.warehouseId());
if (source == null || !"OPEN".equals(source.getPalletStatus())) {
throw conflict("来源托盘已封托,不能直接移箱");
}
if (cartons.move(current.getId(), current.getVersion(), palletId, operatorId, operatorName, operationId) != 1) {
throw conflict("箱唛绑定已变化,请刷新后重试");
}
return view(target);
}
@Transactional
public Pallet seal(long taskId, long palletId, SealRequest request,
long operatorId, String operatorName) {
requireWorkTask(taskId, request.warehouseId(), operatorId);
LoadingWorkPalletEntity replay = pallets.selectBySealOperation(request.operationId().trim());
if (replay != null) {
if (replay.getId() != palletId || replay.getPickTaskId() != taskId) {
throw conflict("封托幂等号已用于其他请求");
}
return view(replay);
}
requireOpenPallet(taskId, palletId, request.warehouseId());
if (cartons.countPalletCartons(palletId) == 0) throw conflict("空托盘不能封托");
if (cartons.countUnreconciled(taskId) > 0) {
throw conflict("现场上托箱数超过已提交拣货箱数,请先完成原PDA拣货提交");
}
if (pallets.seal(taskId, palletId, request.warehouseId(), request.expectedVersion(),
request.operationId().trim(), operatorId, operatorName) != 1) {
throw conflict("托盘状态或版本已变化,请刷新后重试");
}
return view(pallets.selectScoped(taskId, palletId, request.warehouseId()));
}
@Transactional(readOnly = true)
public List<Pallet> list(long taskId, long warehouseId) {
TaskContext context = pallets.selectTaskContext(taskId, warehouseId);
if (context == null) throw new BizException(ResultCode.NOT_FOUND, "未找到当前仓库的PDA任务");
return pallets.selectTaskPallets(taskId, warehouseId).stream().map(this::view).toList();
}
private TaskContext requireWorkTask(long taskId, long warehouseId, long operatorId) {
TaskContext context = pallets.selectTaskContext(taskId, warehouseId);
if (context == null) throw new BizException(ResultCode.NOT_FOUND, "未找到当前仓库的PDA任务");
if (!"LOADING".equals(context.taskStatus())) throw conflict("PDA任务已完成,不能继续打托");
if (context.dropshipItemCount() == null || context.dropshipItemCount() == 0) {
throw conflict("当前任务没有一件代发货物");
}
if (context.assigneeId() == null || context.assigneeId() != operatorId) {
throw conflict("请先领取PDA任务后再执行打托");
}
return context;
}
private LoadingWorkPalletEntity requireOpenPallet(long taskId, long palletId, long warehouseId) {
LoadingWorkPalletEntity pallet = pallets.selectScoped(taskId, palletId, warehouseId);
if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "现场托盘不存在");
if (!"OPEN".equals(pallet.getPalletStatus())) throw conflict("托盘不是打托中状态");
return pallet;
}
private static LoadingWorkPalletCartonEntity binding(long taskId, long palletId,
CartonCandidate source, String operationId,
long operatorId, String operatorName) {
LoadingWorkPalletCartonEntity value = new LoadingWorkPalletCartonEntity();
value.setPickTaskId(taskId);
value.setWorkPalletId(palletId);
value.setLoadingPlanItemId(source.getLoadingPlanItemId());
value.setPickItemId(source.getPickItemId());
value.setTenantId(source.getTenantId());
value.setInboundId(source.getInboundId());
value.setInboundDetailId(source.getInboundDetailId());
value.setCartonLabelId(source.getCartonLabelId());
value.setBoxCode(source.getBoxCode());
value.setSkuCode(source.getSkuCode());
value.setUnitsPerCarton(source.getUnitsPerCarton());
value.setBoundBy(operatorId);
value.setBoundByName(operatorName);
value.setOperationId(operationId);
value.setVersion(0);
return value;
}
private Pallet view(LoadingWorkPalletEntity pallet) {
List<LoadingWorkPalletCartonEntity> values = cartons.selectPalletCartons(pallet.getId());
Map<String, List<LoadingWorkPalletCartonEntity>> bySku = values.stream().collect(
java.util.stream.Collectors.groupingBy(LoadingWorkPalletCartonEntity::getSkuCode,
LinkedHashMap::new, java.util.stream.Collectors.toList()));
List<SkuSummary> skus = bySku.entrySet().stream().map(entry -> new SkuSummary(entry.getKey(),
entry.getValue().size(), entry.getValue().stream().map(LoadingWorkPalletCartonEntity::getUnitsPerCarton)
.filter(Objects::nonNull).mapToLong(Integer::longValue).sum())).toList();
List<Carton> cartonViews = values.stream().map(value -> new Carton(value.getId(), value.getBoxCode(),
value.getSkuCode(), value.getTenantId(), value.getInboundId(), value.getInboundDetailId(),
value.getUnitsPerCarton())).toList();
return new Pallet(pallet.getId(), pallet.getPalletCode(), pallet.getWarehousePalletId(),
pallet.getPalletStatus(), pallet.getVersion(), values.size(), bySku.size(),
(int) values.stream().map(LoadingWorkPalletCartonEntity::getTenantId).distinct().count(),
skus, cartonViews);
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
}
@@ -0,0 +1,135 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.OverseasInboundOrderDetainedChangedV1;
import com.baiyue.common.event.RegionCode;
import com.baiyue.firstleg.entity.ContainerEventInboxEntity;
import com.baiyue.firstleg.entity.InboundHoldRecordEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.mapper.ContainerEventInboxMapper;
import com.baiyue.firstleg.mapper.InboundHoldRecordMapper;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Locale;
import java.util.Objects;
/** 将海外暂扣事实按来源入仓单绑定回写到国内头程入仓单。 */
@Service
@RequiredArgsConstructor
public class OverseasInboundOrderDetainedConsumer {
private final InboundOrderMapper inboundOrders;
private final InboundHoldRecordMapper holdRecords;
private final ContainerEventInboxMapper inbox;
private final ObjectMapper objectMapper;
@Transactional
public InboundOrderEntity consume(EventEnvelope event) {
validateEnvelope(event);
OverseasInboundOrderDetainedChangedV1 payload = payload(event);
long inboundId = parsePositive(payload.sourceInboundId(), "来源入仓单 ID 无效");
long tenantId = parsePositive(event.tenantId(), "客户 ID 无效");
if (!payload.sourceInboundId().equals(event.aggregateId())) {
throw new IllegalArgumentException("事件 aggregateId 与来源入仓单 ID 不一致");
}
if (payload.changedAt() == null || payload.inboundCode() == null || payload.inboundCode().isBlank()
|| payload.reason() == null || payload.reason().isBlank()) {
throw new IllegalArgumentException("暂扣事件缺少入仓码、原因或变更时间");
}
ContainerEventInboxEntity existing = inbox.selectById(event.eventId());
if (existing != null) {
if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) {
throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突");
}
return requiredInbound(inboundId, tenantId, payload.inboundCode());
}
ContainerEventInboxEntity inboxRow = inbox(event);
try {
inbox.insert(inboxRow);
} catch (DuplicateKeyException duplicate) {
ContainerEventInboxEntity raced = inbox.selectById(event.eventId());
if (raced == null || !raced.getPayloadHash().equalsIgnoreCase(event.payloadHash())) throw duplicate;
return requiredInbound(inboundId, tenantId, payload.inboundCode());
}
InboundOrderEntity inbound = requiredInbound(inboundId, tenantId, payload.inboundCode());
if (inbound.getLastOverseasHoldVersion() == null
|| event.aggregateVersion() > inbound.getLastOverseasHoldVersion()) {
int updated = inboundOrders.applyOverseasHold(tenantId, inboundId, payload.detained(),
payload.detained() ? payload.reason() : null, event.aggregateVersion(),
parseOperatorId(payload.operatorId()));
if (updated == 1) insertHistory(event, payload, tenantId, inboundId);
}
inboxRow.setStatus("PROCESSED");
inboxRow.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC));
inbox.updateById(inboxRow);
return requiredInbound(inboundId, tenantId, payload.inboundCode());
}
private void validateEnvelope(EventEnvelope event) {
event.requireValid();
if (!EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED.equals(event.eventType())
|| event.eventVersion() != 1 || event.sourceRegion() != RegionCode.OVERSEAS
|| event.targetRegion() != RegionCode.CN || !"INBOUND_ORDER".equals(event.aggregateType())) {
throw new IllegalArgumentException("不支持的海外入仓单暂扣事件");
}
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(event.payload()), event.payloadHash())) {
throw new IllegalArgumentException("payloadHash 与事件负载不一致");
}
}
private OverseasInboundOrderDetainedChangedV1 payload(EventEnvelope event) {
try { return objectMapper.treeToValue(event.payload(), OverseasInboundOrderDetainedChangedV1.class); }
catch (Exception exception) { throw new IllegalArgumentException("海外入仓单暂扣事件负载无效", exception); }
}
private InboundOrderEntity requiredInbound(long inboundId, long tenantId, String inboundCode) {
InboundOrderEntity value = inboundOrders.selectActiveByIdForUpdate(inboundId);
if (value == null) throw new IllegalArgumentException("来源头程入仓单不存在");
if (!Objects.equals(value.getTenantId(), tenantId) || !Objects.equals(value.getInboundCode(), inboundCode)) {
throw new IllegalArgumentException("事件与来源头程入仓单归属不一致");
}
return value;
}
private void insertHistory(EventEnvelope event, OverseasInboundOrderDetainedChangedV1 payload,
long tenantId, long inboundId) {
InboundHoldRecordEntity row = new InboundHoldRecordEntity();
row.setId(IdWorker.getId()); row.setTenantId(tenantId); row.setInboundId(inboundId);
row.setHoldAction(payload.detained() ? "HOLD" : "RELEASE"); row.setReason(payload.reason());
row.setOperatorId(parseOperatorId(payload.operatorId())); row.setOperatorName(payload.operatorName());
row.setOperatedAt(LocalDateTime.ofInstant(payload.changedAt(), ZoneOffset.UTC));
holdRecords.insert(row);
}
private ContainerEventInboxEntity inbox(EventEnvelope event) {
ContainerEventInboxEntity row = new ContainerEventInboxEntity();
row.setEventId(event.eventId()); row.setEventType(event.eventType()); row.setEventVersion(event.eventVersion());
row.setAggregateId(event.aggregateId()); row.setAggregateVersion(event.aggregateVersion());
row.setPayloadHash(event.payloadHash().toLowerCase(Locale.ROOT)); row.setPayloadJson(event.payload().toString());
row.setStatus("RECEIVED"); row.setReceivedAt(LocalDateTime.now(ZoneOffset.UTC));
return row;
}
private long parsePositive(String value, String message) {
try { long parsed = Long.parseLong(value); if (parsed > 0) return parsed; }
catch (RuntimeException ignored) { }
throw new IllegalArgumentException(message);
}
private long parseOperatorId(String value) {
try { return value == null ? 0L : Long.parseLong(value); }
catch (NumberFormatException ignored) { return 0L; }
}
}
@@ -15,6 +15,7 @@ DELIMITER ;
CALL phase2_carton_label_add_column('label_version', 'INT UNSIGNED NOT NULL DEFAULT 1 COMMENT ''内部兼容批次字段,固定为1且不进入箱码'' AFTER carton_index');
CALL phase2_carton_label_add_column('batch_status', 'VARCHAR(16) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''批次状态:ACTIVE有效、VOID已作废'' AFTER label_version');
CALL phase2_carton_label_add_column('cargo_fingerprint', 'CHAR(64) NULL COMMENT ''影响标签字段的SHA-256指纹'' AFTER batch_status');
CALL phase2_carton_label_add_column('sku_code', 'VARCHAR(128) NULL COMMENT ''箱唛绑定的SKU快照;二维码内容不变'' AFTER cargo_name');
CALL phase2_carton_label_add_column('voided_at', 'DATETIME(3) NULL COMMENT ''批次作废时间'' AFTER cargo_fingerprint');
DROP PROCEDURE phase2_carton_label_add_column;
@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID,不建立跨库外键',
cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照',
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变',
detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
@@ -42,6 +42,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照',
hold_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当前是否扣货',
current_hold_reason VARCHAR(500) NULL COMMENT '当前扣货理由快照',
last_overseas_hold_version BIGINT UNSIGNED 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',
@@ -196,6 +197,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID,不建立跨库外键',
cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照',
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变',
detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
@@ -102,8 +102,8 @@ CREATE TABLE IF NOT EXISTS fl_container_event_inbox (
event_id VARCHAR(64) NOT NULL COMMENT '跨区事件全局唯一ID',
event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
event_version INT UNSIGNED NOT NULL COMMENT '事件契约版本',
aggregate_id VARCHAR(64) NOT NULL COMMENT '货柜聚合ID',
aggregate_version BIGINT UNSIGNED NOT NULL COMMENT '货柜聚合版本',
aggregate_id VARCHAR(64) NOT NULL COMMENT '跨区业务聚合ID',
aggregate_version BIGINT UNSIGNED NOT NULL COMMENT '跨区业务聚合版本',
payload_hash VARCHAR(80) NOT NULL COMMENT '规范化负载SHA-256摘要',
payload_json JSON NOT NULL COMMENT '原始事件负载',
status VARCHAR(16) NOT NULL COMMENT 'RECEIVED或PROCESSED',
@@ -111,7 +111,7 @@ CREATE TABLE IF NOT EXISTS fl_container_event_inbox (
processed_at DATETIME(3) NULL COMMENT '处理完成时间(UTC)',
PRIMARY KEY (event_id),
KEY idx_container_event_aggregate (aggregate_id, aggregate_version)
) ENGINE=InnoDB COMMENT='海外货柜事件消费Inbox';
) ENGINE=InnoDB COMMENT='海外到国内业务事件消费Inbox';
DROP PROCEDURE IF EXISTS phase3_container_real_unit_columns;
DELIMITER $$
@@ -623,6 +623,66 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_history (
CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id)
) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史';
CREATE TABLE IF NOT EXISTS fl_loading_work_pallet (
id BIGINT UNSIGNED NOT NULL COMMENT '一件代发现场作业托盘ID',
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA装柜拣货任务ID',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID快照',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID',
warehouse_pallet_id BIGINT UNSIGNED NULL COMMENT '仓库实物托盘ID;未绑定仓库资源时为空',
pallet_code VARCHAR(64) NOT NULL COMMENT '现场托盘号',
open_operation_id VARCHAR(128) NOT NULL COMMENT '开托幂等操作号',
seal_operation_id VARCHAR(128) NULL COMMENT '封托幂等操作号',
pallet_status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT '托盘状态:OPEN打托中、SEALED已封托、CANCELLED已取消',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建操作人名称快照',
sealed_by BIGINT UNSIGNED NULL COMMENT '封托操作人ID',
sealed_by_name VARCHAR(64) NULL COMMENT '封托操作人名称快照',
sealed_at DATETIME(3) NULL COMMENT '封托时间',
version INT UNSIGNED NOT NULL DEFAULT 0 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_loading_work_pallet_code (pick_task_id, pallet_code),
UNIQUE KEY uk_loading_work_pallet_open_operation (open_operation_id),
UNIQUE KEY uk_loading_work_pallet_seal_operation (seal_operation_id),
KEY idx_loading_work_pallet_plan (loading_plan_id, pallet_status, id),
CONSTRAINT fk_loading_work_pallet_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
CONSTRAINT fk_loading_work_pallet_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
CONSTRAINT chk_loading_work_pallet_status CHECK (pallet_status IN ('OPEN','SEALED','CANCELLED'))
) ENGINE=InnoDB COMMENT='一件代发PDA现场实际托盘;允许同托盘承载多个客户订单';
CREATE TABLE IF NOT EXISTS fl_loading_work_pallet_carton (
id BIGINT UNSIGNED NOT NULL COMMENT '一件代发现场托盘箱唛绑定ID',
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA装柜拣货任务ID',
work_pallet_id BIGINT UNSIGNED NOT NULL COMMENT '现场作业托盘ID',
loading_plan_item_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划货物明细ID',
pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '箱唛所属客户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '来源入仓单ID',
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '来源入仓货物明细ID',
carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '稳定箱唛记录ID',
box_code VARCHAR(80) NOT NULL COMMENT '稳定箱唛箱码快照',
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照',
units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数量快照',
bound_by BIGINT UNSIGNED NOT NULL COMMENT '最后绑定操作人ID',
bound_by_name VARCHAR(64) NULL COMMENT '最后绑定操作人名称快照',
operation_id VARCHAR(128) NOT NULL COMMENT '最近一次绑定或移托幂等操作号',
version INT UNSIGNED NOT NULL DEFAULT 0 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_loading_work_carton_task_label (pick_task_id, carton_label_id),
UNIQUE KEY uk_loading_work_carton_operation (operation_id),
KEY idx_loading_work_carton_pallet (work_pallet_id, sku_code, id),
KEY idx_loading_work_carton_plan_item (loading_plan_item_id, id),
CONSTRAINT fk_loading_work_carton_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
CONSTRAINT fk_loading_work_carton_pallet FOREIGN KEY (work_pallet_id) REFERENCES fl_loading_work_pallet (id),
CONSTRAINT fk_loading_work_carton_plan_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id),
CONSTRAINT fk_loading_work_carton_pick_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id),
CONSTRAINT fk_loading_work_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label (id),
CONSTRAINT chk_loading_work_carton_units CHECK (units_per_carton IS NULL OR units_per_carton > 0)
) ENGINE=InnoDB COMMENT='一件代发PDA逐箱上托事实;客户订单不合并但实际托盘可混托';
DROP PROCEDURE IF EXISTS repair_phase3_loading_pick_task_foreign_keys;
DELIMITER $$
CREATE PROCEDURE repair_phase3_loading_pick_task_foreign_keys()
@@ -119,11 +119,104 @@ paths:
parameters: [{$ref: '#/components/parameters/TaskId'}]
responses:
'200': {description: Ordered operation history}
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/pallets:
get:
summary: 查询一件代发现场实际托盘、SKU汇总和逐箱明细
parameters:
- {$ref: '#/components/parameters/TaskId'}
- {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
responses:
'200':
description: 当前任务未取消的现场托盘
content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/WorkPallet'}}}}
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/pallets/open:
post:
summary: 为已领取任务开启一个一件代发现场托盘
parameters: [{$ref: '#/components/parameters/TaskId'}]
requestBody:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/OpenPalletRequest'}}}
responses:
'200': {description: 已开启托盘或相同operationId的幂等结果}
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/pallets/{palletId}/cartons/scan:
post:
summary: 扫描一件代发箱唛上托;箱唛已在其他未封托托盘时按箱移托
description: 本操作只记录实际箱到托关系,不推进既有拣货数量、订单状态或仓库资源结算。
parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/PalletId'}]
requestBody:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/ScanCartonRequest'}}}
responses:
'200': {description: 包含最新SKU与箱唛汇总的目标托盘}
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/pallets/{palletId}/seal:
post:
summary: 封托并校验现场上托箱数未超过既有PDA已提交拣货箱数
parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/PalletId'}]
requestBody:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/SealPalletRequest'}}}
responses:
'200': {description: 已封托托盘或A0409数量/版本冲突}
components:
parameters:
TaskId: {name: taskId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
ItemId: {name: itemId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
PalletId: {name: palletId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
schemas:
OpenPalletRequest:
type: object
required: [warehouseId, palletCode, operationId]
properties:
warehouseId: {type: integer, format: int64, minimum: 1}
warehousePalletId: {type: integer, format: int64, minimum: 1, nullable: true}
palletCode: {type: string, maxLength: 64}
operationId: {type: string, maxLength: 128}
ScanCartonRequest:
type: object
required: [warehouseId, boxCode, operationId]
properties:
warehouseId: {type: integer, format: int64, minimum: 1}
boxCode: {type: string, maxLength: 80}
operationId: {type: string, maxLength: 128}
SealPalletRequest:
type: object
required: [warehouseId, expectedVersion, operationId]
properties:
warehouseId: {type: integer, format: int64, minimum: 1}
expectedVersion: {type: integer, minimum: 0}
operationId: {type: string, maxLength: 128}
WorkPallet:
type: object
required: [id, palletCode, status, version, cartonCount, skuCount, customerCount, skus, cartons]
properties:
id: {type: integer, format: int64}
palletCode: {type: string}
warehousePalletId: {type: integer, format: int64, nullable: true}
status: {type: string, enum: [OPEN, SEALED]}
version: {type: integer, minimum: 0}
cartonCount: {type: integer, minimum: 0}
skuCount: {type: integer, minimum: 0}
customerCount: {type: integer, minimum: 0}
skus: {type: array, items: {$ref: '#/components/schemas/WorkPalletSku'}}
cartons: {type: array, items: {$ref: '#/components/schemas/WorkPalletCarton'}}
WorkPalletSku:
type: object
required: [skuCode, cartonCount, productQuantity]
properties:
skuCode: {type: string}
cartonCount: {type: integer, minimum: 1}
productQuantity: {type: integer, format: int64, minimum: 0}
WorkPalletCarton:
type: object
required: [id, boxCode, skuCode, tenantId, inboundId, inboundDetailId]
properties:
id: {type: integer, format: int64}
boxCode: {type: string}
skuCode: {type: string}
tenantId: {type: integer, format: int64}
inboundId: {type: integer, format: int64}
inboundDetailId: {type: integer, format: int64}
unitsPerCarton: {type: integer, minimum: 1, nullable: true}
Task:
type: object
required: [id, loadingPlanId, containerBookingId, inboundId, inboundVersion, loadingVersion, cabinetNo, sourceType, recordStatus, taskStatus, workflowStatus, version, items]
@@ -24,6 +24,13 @@ class PlatformLoadingPdaControllerContractTest {
authority("review", "platform:firstleg:pda:review", AuthenticatedUser.class, long.class, ReviewRequest.class);
authority("complete", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, CompleteLoadingRequest.class);
authority("operations", "platform:firstleg:pda:view", AuthenticatedUser.class, long.class);
authority("pallets", "platform:firstleg:pda:view", AuthenticatedUser.class, long.class, long.class);
authority("openPallet", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class,
com.baiyue.firstleg.dto.LoadingPalletDtos.OpenRequest.class);
authority("scanCarton", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, long.class,
com.baiyue.firstleg.dto.LoadingPalletDtos.ScanRequest.class);
authority("sealPallet", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, long.class,
com.baiyue.firstleg.dto.LoadingPalletDtos.SealRequest.class);
}
private void authority(String name, String expected, Class<?>... parameterTypes) throws Exception {
@@ -187,6 +187,30 @@ class LoadingCompletedOutboxServiceTest {
assertThat(event.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2);
}
@Test void publishesExactPdaCartonToPalletBindingsWithoutSequentialRedistribution() throws Exception {
Row first = row(11, 100, 101); first.setFulfillmentType("ONE_CLICK_DROPSHIP");
first.setPackageCount(2); first.setProductQuantity(20); first.setSkuCode("SKU-A");
Row second = row(11, 100, 102); second.setFulfillmentType("ONE_CLICK_DROPSHIP");
second.setPackageCount(2); second.setProductQuantity(10); second.setSkuCode("SKU-B");
when(snapshots.rows(90L)).thenReturn(List.of(first, second));
when(snapshots.workPalletCount(90L)).thenReturn(2);
when(snapshots.actualPalletRows(90L)).thenReturn(List.of(
actualPallet(11,100,"PLT-01",101,"SKU-A","BOX-A2"),
actualPallet(11,100,"PLT-01",102,"SKU-B","BOX-B1"),
actualPallet(11,100,"PLT-02",101,"SKU-A","BOX-A1"),
actualPallet(11,100,"PLT-02",102,"SKU-B","BOX-B2")));
service.publish(90L);
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(outbox).insert(captor.capture());
EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class);
assertThat(event.payload().at("/orders/0/pallets/0/skuPackages/0/cartonMarks/0").asText())
.isEqualTo("BOX-A2");
assertThat(event.payload().at("/orders/0/pallets/1/skuPackages/0/cartonMarks/0").asText())
.isEqualTo("BOX-A1");
}
@Test
void pricingBusinessTypeCannotTurnAStandardOrderIntoDropship() throws Exception {
Row value = row(11, 100, 101);
@@ -238,6 +262,13 @@ class LoadingCompletedOutboxServiceTest {
row.setPackageCount(packages); row.setProductQuantity(quantity); row.setBoxCode(boxCode); return row;
}
private PalletRow actualPallet(long account,long inbound,String palletNo,long detail,
String sku,String boxCode) {
PalletRow row = pallet(account,inbound,0,palletNo,1,detail,sku,1,1,boxCode);
row.setActualBinding(true);
return row;
}
private LabelRow label(long detailId, String boxCode) {
LabelRow row = new LabelRow(); row.setInboundDetailId(detailId); row.setBoxCode(boxCode); return row;
}
@@ -0,0 +1,86 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.dto.LoadingPalletDtos.ScanRequest;
import com.baiyue.firstleg.dto.LoadingPalletDtos.SealRequest;
import com.baiyue.firstleg.entity.LoadingWorkPalletCartonEntity;
import com.baiyue.firstleg.entity.LoadingWorkPalletEntity;
import com.baiyue.firstleg.mapper.LoadingWorkPalletCartonMapper;
import com.baiyue.firstleg.mapper.LoadingWorkPalletCartonMapper.CartonCandidate;
import com.baiyue.firstleg.mapper.LoadingWorkPalletMapper;
import com.baiyue.firstleg.mapper.LoadingWorkPalletMapper.TaskContext;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(LoadingWorkPalletService.class)
class LoadingWorkPalletServiceTest {
@MockBean private LoadingWorkPalletMapper pallets;
@MockBean private LoadingWorkPalletCartonMapper cartons;
@Resource private LoadingWorkPalletService service;
@Test
void scansDropshipCartonOntoMixedCustomerWorkPalletWithoutChangingPickWorkflow() {
when(pallets.selectTaskContext(10L, 20L)).thenReturn(context());
LoadingWorkPalletEntity pallet = pallet(30L, "OPEN");
when(pallets.selectScoped(10L, 30L, 20L)).thenReturn(pallet);
CartonCandidate candidate = candidate();
when(cartons.selectCandidates(10L, 20L, "BOX-A")).thenReturn(List.of(candidate));
doAnswer(invocation -> {
LoadingWorkPalletCartonEntity value = invocation.getArgument(0);
value.setId(50L);
return 1;
}).when(cartons).insert(any(LoadingWorkPalletCartonEntity.class));
when(cartons.selectPalletCartons(30L)).thenAnswer(invocation -> List.of());
service.scan(10L, 30L, new ScanRequest(20L, "BOX-A", "SCAN-1"), 99L, "作业员");
var captor = org.mockito.ArgumentCaptor.forClass(LoadingWorkPalletCartonEntity.class);
verify(cartons).insert(captor.capture());
assertThat(captor.getValue().getTenantId()).isEqualTo(88L);
assertThat(captor.getValue().getLoadingPlanItemId()).isEqualTo(41L);
assertThat(captor.getValue().getSkuCode()).isEqualTo("SKU-A");
}
@Test
void refusesToSealUntilScannedCartonsAreReconciledByExistingPickSubmission() {
when(pallets.selectTaskContext(10L, 20L)).thenReturn(context());
when(pallets.selectScoped(10L, 30L, 20L)).thenReturn(pallet(30L, "OPEN"));
when(cartons.countPalletCartons(30L)).thenReturn(2);
when(cartons.countUnreconciled(10L)).thenReturn(1);
assertThatThrownBy(() -> service.seal(10L, 30L,
new SealRequest(20L, 0, "SEAL-1"), 99L, "作业员"))
.hasMessageContaining("原PDA拣货提交");
}
private static TaskContext context() {
return new TaskContext(10L, 11L, "LOADING", 20L, 99L, 2);
}
private static LoadingWorkPalletEntity pallet(long id, String status) {
LoadingWorkPalletEntity value = new LoadingWorkPalletEntity();
value.setId(id); value.setPickTaskId(10L); value.setLoadingPlanId(11L);
value.setWarehouseId(20L); value.setPalletCode("PLT-01");
value.setPalletStatus(status); value.setVersion(0);
return value;
}
private static CartonCandidate candidate() {
CartonCandidate value = new CartonCandidate();
value.setCartonLabelId(40L); value.setTenantId(88L); value.setInboundId(81L);
value.setInboundDetailId(82L); value.setBoxCode("BOX-A");
value.setLoadingPlanItemId(41L); value.setPickItemId(42L);
value.setSkuCode("SKU-A"); value.setUnitsPerCarton(20);
return value;
}
}
@@ -0,0 +1,87 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.OverseasInboundOrderDetainedChangedV1;
import com.baiyue.common.event.RegionCode;
import com.baiyue.firstleg.entity.InboundHoldRecordEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.mapper.ContainerEventInboxMapper;
import com.baiyue.firstleg.mapper.InboundHoldRecordMapper;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class OverseasInboundOrderDetainedConsumerTest {
@Mock InboundOrderMapper inboundOrders;
@Mock InboundHoldRecordMapper holdRecords;
@Mock ContainerEventInboxMapper inbox;
ObjectMapper objectMapper;
OverseasInboundOrderDetainedConsumer consumer;
@BeforeEach
void setUp() {
objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
consumer = new OverseasInboundOrderDetainedConsumer(inboundOrders, holdRecords, inbox, objectMapper);
}
@Test
void appliesHoldBySourceInboundIdAndWritesHistory() {
InboundOrderEntity inbound = inbound(null);
when(inboundOrders.selectActiveByIdForUpdate(1001L)).thenReturn(inbound);
when(inboundOrders.applyOverseasHold(88L, 1001L, true, "等待查验", 7L, 66L)).thenReturn(1);
consumer.consume(event(7, true, "等待查验"));
verify(inboundOrders).applyOverseasHold(88L, 1001L, true, "等待查验", 7L, 66L);
ArgumentCaptor<InboundHoldRecordEntity> history = ArgumentCaptor.forClass(InboundHoldRecordEntity.class);
verify(holdRecords).insert(history.capture());
assertThat(history.getValue().getInboundId()).isEqualTo(1001L);
assertThat(history.getValue().getHoldAction()).isEqualTo("HOLD");
verify(inbox).updateById(argThat(row -> "PROCESSED".equals(row.getStatus())));
}
@Test
void staleReleaseCannotOverwriteNewerHoldState() {
when(inboundOrders.selectActiveByIdForUpdate(1001L)).thenReturn(inbound(9L));
consumer.consume(event(7, false, "解除"));
verify(inboundOrders, never()).applyOverseasHold(anyLong(), anyLong(), anyBoolean(), any(), anyLong(), anyLong());
verifyNoInteractions(holdRecords);
verify(inbox).updateById(argThat(row -> "PROCESSED".equals(row.getStatus())));
}
private InboundOrderEntity inbound(Long sourceVersion) {
InboundOrderEntity value = new InboundOrderEntity();
value.setId(1001L); value.setTenantId(88L); value.setInboundCode("IN-1001");
value.setLastOverseasHoldVersion(sourceVersion);
return value;
}
private EventEnvelope event(long version, boolean detained, String reason) {
var payload = new OverseasInboundOrderDetainedChangedV1("1001", "IN-1001", "9001",
"LM-9001", detained, reason, "66", "海外操作员", Instant.parse("2026-08-31T06:00:00Z"));
var node = objectMapper.valueToTree(payload);
return new EventEnvelope(String.format("00000000-0000-4000-8000-%012d", version),
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, 1, payload.changedAt(),
RegionCode.OVERSEAS, RegionCode.CN, "88", "INBOUND_ORDER", "1001", version, 1L,
"00000000-0000-4000-8000-000000000099", "hold-1001-" + version, null, node,
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)));
}
}
@@ -39,9 +39,9 @@ spring:
baiyue:
security:
issuer: ${JWT_ISSUER:https://auth.example.com}
issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWK_SET_URI:https://auth.example.com/.well-known/jwks.json}
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
pda-session-validation:
auth-url: ${AUTH_SESSION_VALIDATION_URL}
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
@@ -9,6 +9,36 @@ import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class OverseasSecurityValidationDeploymentContractTest {
@Test
void gatewayLocalJwtDefaultsMatchAuthAndLastleg() throws Exception {
String gatewayApplication = read("gateway-overseas/src/main/resources/application.yml");
String lastlegApplication = read("lastleg-service/src/main/resources/application.yml");
String localGateway = read("config/nacos/local/gateway-overseas.yml");
String localLastleg = read("config/nacos/local/lastleg-service.yml");
String localWarehouse = read("config/nacos/local/warehouse-overseas-service.yml");
String remoteGateway = read("config/nacos/overseas/gateway-overseas.yml");
String remoteLastleg = read("config/nacos/overseas/lastleg-service.yml");
String remoteWarehouse = read("config/nacos/overseas/warehouse-overseas-service.yml");
for (String source : new String[]{gatewayApplication, lastlegApplication, localGateway, localLastleg, localWarehouse}) {
assertThat(source)
.contains("${JWT_ISSUER:auth-service}")
.contains("${JWT_AUDIENCE:lastleg-api}")
.contains("${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}");
}
assertThat(gatewayApplication).doesNotContain("auth.example.com");
assertThat(localGateway)
.contains("${AUTH_SESSION_VALIDATION_URL:http://127.0.0.1:9100}")
.contains("${LASTLEG_SECURITY_SERVICE_URL:http://127.0.0.1:9303}");
assertThat(localLastleg).contains("${WAREHOUSE_OVERSEAS_SERVICE_URL:http://127.0.0.1:9305}");
assertThat(localWarehouse).contains("${DOMESTIC_WAREHOUSE_URL:http://127.0.0.1:9205}");
assertThat(remoteGateway + remoteLastleg + remoteWarehouse)
.doesNotContain("127.0.0.1", "root", "123456");
assertThat(remoteGateway).contains("${LASTLEG_SECURITY_SERVICE_URL:http://lastleg-service:9303}");
assertThat(remoteLastleg).contains("${LASTLEG_DB_URL}", "${LASTLEG_DB_USERNAME}", "${LASTLEG_DB_PASSWORD}");
assertThat(remoteWarehouse).contains("${WAREHOUSE_DB_URL}", "${WAREHOUSE_DB_USERNAME}", "${WAREHOUSE_DB_PASSWORD}");
}
@Test
void gatewayAndEventBridgeUseLastlegSecurityEndpointsWithoutTheRetiredService() throws Exception {
String client = read("gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java");
+1 -1
View File
@@ -24,7 +24,7 @@ spring:
encode: UTF-8
baiyue:
security-event:
event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312}
event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://127.0.0.1:9312}
customer-dimension-initialization:
fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000}
max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12}
@@ -39,6 +39,9 @@ VALUES
(5107061,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:create','创建海外库位',NULL,'POST',761,'ACTIVE',1,0),
(5107062,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:edit','编辑海外库位',NULL,'PUT',762,'ACTIVE',1,0),
(5107063,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:delete','删除海外库位',NULL,'DELETE',763,'ACTIVE',1,0),
(5107090,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:outbound:list','海外出库单','/overseas-warehouse/outbounds','GET',765,'ACTIVE',1,0),
(5107091,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:create','创建海外出库单',NULL,'POST',766,'ACTIVE',1,0),
(5107092,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:delivery:list','海外派送单','/overseas-warehouse/delivery-orders','GET',767,'ACTIVE',1,0),
(5107100,0,'PLATFORM','MENU',0,'platform:overseas-business:purchase:list','海外采购订单','/overseas-business/purchases','GET',800,'ACTIVE',1,0),
(5107101,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:edit','编辑海外采购订单',NULL,'PATCH',801,'ACTIVE',1,0),
(5107102,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:review','审核海外采购订单',NULL,'POST',802,'ACTIVE',1,0),
@@ -212,3 +215,16 @@ JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code
WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
AND UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN')
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
-- PLATFORM_CUSTOM_ADMIN needs access to the overseas outbound and delivery lists shown in the admin navigation.
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, role.id, permission.id, NOW(3)
FROM iam_role role
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.permission_code IN (
'platform:overseas-warehouse:outbound:list',
'platform:overseas-warehouse:delivery:list'
)
WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
AND UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN'
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
@@ -41,10 +41,12 @@ class OverseasPhase4PermissionSqlContractTest {
assertThat(sql).contains(REQUIRED.toArray(String[]::new));
assertThat(sql).contains("'lastleg:pda:operate'");
assertPdaPickerGrant(sql);
assertThat(catalog).hasSize(53).noneMatch(code -> code.endsWith(":view"));
assertThat(catalog).hasSize(56).noneMatch(code -> code.endsWith(":view"));
assertThat(sql).contains("permission_code LIKE 'platform:lastleg:%'", "status='INACTIVE'", "deleted=1");
assertThat(sql).doesNotContain(":carton:", ":product:", ":outbound:", ":delivery:");
assertThat(sql).doesNotContain(":carton:", ":product:");
assertThat(catalog).contains("platform:overseas-warehouse:delivery:list");
assertCustomAdminGrant(sql);
assertCustomAdminNavigationGrant(sql);
}
@Test
@@ -58,6 +60,7 @@ class OverseasPhase4PermissionSqlContractTest {
assertThat(sql).contains("baiyue_iam.iam_permission", "baiyue_iam.iam_role_permission", "'lastleg:pda:operate'");
assertPdaPickerGrant(sql);
assertCustomAdminGrant(sql);
assertCustomAdminNavigationGrant(sql);
}
@Test
@@ -138,6 +141,17 @@ class OverseasPhase4PermissionSqlContractTest {
assertThat(sql.substring(grantStart, grantEnd)).contains("lastleg:pda:operate");
}
private static void assertCustomAdminNavigationGrant(String sql) {
int permissionIndex = sql.indexOf("'platform:overseas-warehouse:delivery:list'", sql.indexOf("PLATFORM_CUSTOM_ADMIN needs access"));
assertThat(permissionIndex).isGreaterThanOrEqualTo(0);
int grantStart = sql.lastIndexOf("INSERT IGNORE INTO", permissionIndex);
int grantEnd = sql.indexOf(';', permissionIndex);
assertThat(sql.substring(grantStart, grantEnd)).contains(
"UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN'",
"platform:overseas-warehouse:outbound:list",
"platform:overseas-warehouse:delivery:list");
}
private static String matrixBlock(String sql) {
int start = sql.indexOf("INSERT INTO tmp_phase4_warehouse_role_permission");
int end = sql.indexOf("DELETE grant_row FROM", start);
+11 -7
View File
@@ -24,17 +24,22 @@
| 入仓计划分页、转换与详情 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java` |
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,旧 planNo/allocationId Controller 已移除 |
| PDA 卸柜照片文件上传与归属校验 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO` |
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除 |
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` |
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
| 尾程暂扣状态回传 | `service/InboundOrderDetainedOutboxService.java`;暂扣与解除暂扣在订单更新事务内按 `sourceInboundId` 写入海外到国内 Outbox,国内按来源入仓单绑定更新 |
| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 |
| 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,不含出库和派送 |
| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java`、`pda/warehouse/service/PdaWarehouseService.java`;解析库存、库位和托盘码并查询操作历史,固定要求 `lastleg:pda:operate` 与 `ROLE_WAREHOUSE_PICKER`,仓库范围只能取自 PDA Token |
| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 |
| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,范围开关与禁用写操作见“四期开发范围路由” |
| 排车编辑、PDA 出库、派送与交接 | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`MerchantDeliveryController.java`、`OutboundPdaController.java` |
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` |
| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` |
| 一件代发接收、SKU/箱唛库存 | `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` |
| 一件代发接收、SKU/箱唛库存 | 内部接收入口 `dropship/controller/DropshipOrderController.java` 的 `/internal/v1/lastleg/dropship/receive`,必须经 `InternalServiceTokenGuard`;处理 `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` |
| 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` |
| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`;平台维护拜访、附件上传、派送完成/状态 |
| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`;异常订单建档、关联解析、派送退回候选与标记 |
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO |
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
@@ -46,9 +51,8 @@
## 四期开发范围路由
- 四期在本服务内只进入尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、海外采购和本土卡车;本土散板新增实现也归本服务的独立稳定子域。
- `dispatch/`、`delivery/`、出库单、PDA 出库拣货、派送和其他库存离仓能力虽然已存在于服务代码或表结构中,但不属于当前四期开发与验收范围,不得因代码已存在而自动纳入。
- 历史排车编辑/取消/出库、PDA 出库和 `delivery/` Controller 默认不注册;只有后续阶段明确启用 `lastleg.future-outbound-api.enabled=true` 才可恢复。平台排车列表与详情由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。预排只能创建待出库排车并锁定托盘,不得借此开放编辑、取消、depart、PDA 出库或派送。四期平台库存 Controller 不映射 allocate、unallocate、pick、ship。
- 尾程服务覆盖尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、海外采购、本土卡车、本土散板、排车、出库、派送、客户拜访及异常退回等完整业务域。
- 排车编辑/取消/出库、PDA 出库和 `delivery/` Controller 与其他尾程业务接口一同注册,不受功能开关控制。平台排车列表与详情仍由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建仍由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。
- 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway-overseas/`。
## 数据库表索引与注释约束
@@ -93,7 +97,7 @@
- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。
- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。
- 货物废弃只使用可核对 SKU 和箱唛的一件代发库存。创建申请只能将箱从 `AVAILABLE` 锁定为 `DISCARD_PENDING`,不得扣减 SKU 箱数;实际扣库存节点固定为具备 `lastleg:cargo-discard:approve` 权限的仓库/PDA 审核人确认废弃时,并且必须在审核事务内调用专用 discard adjustment。该 adjustment 必须将箱改为 `DISCARDED`、写逐箱业务流水及唯一幂等键,并校验 `扣减前箱数 - 废弃箱数 = 扣减后箱数` 且逐箱数等于申请总箱数;任一环节失败须整体回滚。拒绝申请只释放锁箱,不得扣库存。
- 海外采购客户下单固定进入 `PENDING_REVIEW`;四期采购只维护采购订单、审核、入库数量、候选快照和操作流水,不提供采购出库端点、出库记录或出库数量。采购单据不得写入或冒充 `available_packages`;只有海外仓 PDA 完成实收后,`InboundStockReceiptService` 才能以 `PDA_STOCK_IN:{taskId}:{allocationId}` 幂等键调用库存 `stockIn` 转入可用库存。禁止通过通用平台接口绕过 PDA 入可用库存。
- 海外采购客户下单固定进入 `PENDING_REVIEW`;采购编辑可维护入库数量和采购出库明细,出库量为明细数量汇总,审核通过且有入库量后按 `PENDING_OUTBOUND`、`PARTIALLY_OUTBOUND`、`OUTBOUNDED` 派生展示状态。采购出库只记录采购单据流转,不得写入或冒充 `available_packages`;只有海外仓 PDA 完成实收后,`InboundStockReceiptService` 才能以 `PDA_STOCK_IN:{taskId}:{allocationId}` 幂等键调用库存 `stockIn` 转入可用库存。禁止通过通用平台接口绕过 PDA 入可用库存。
- 派车状态只允许 PDA 拣货、完成和出库动作推进,普通派车编辑接口不得修改状态。PDA 写操作要求 `lastleg:pda:operate`;后台库存表单操作要求 `lastleg:inventory:manage`,盘点调整另要求 `lastleg:inventory:adjust`,转仓库存操作要求 `lastleg:warehouse-transfer:manage`。
- `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。
@@ -93,6 +93,43 @@ public class LastlegOrderController {
return ApiResponse.success(orderService.createFee(platformScope(user, customerId), id, request, operatorName(user)));
}
@PutMapping("/api/platform/v1/last-leg/orders/{id}/detained")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
@Operation(summary = "Set tail-leg order hold status", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
public ApiResponse<Void> setDetained(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable long id,
@Valid @RequestBody LastlegOrderRequests.SetDetained request) {
orderService.setDetained(platformScope(user, customerId), id, request,
String.valueOf(user.userId()), operatorName(user));
return ApiResponse.success(null);
}
@PutMapping("/api/platform/v1/last-leg/orders/{id}/pallet-merge-required")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
@Operation(summary = "Set the PDA pallet-merge reminder for one tail-leg order",
description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
public ApiResponse<Void> setPalletMergeRequired(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable long id,
@Valid @RequestBody LastlegOrderRequests.SetPalletMergeRequired request) {
orderService.setPalletMergeRequired(platformScope(user, customerId), id, request, operatorName(user));
return ApiResponse.success(null);
}
@PutMapping("/api/platform/v1/last-leg/orders/pallet-merge-required/batch")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
@Operation(summary = "Mark a selected tail-leg order group for PDA pallet merge")
public ApiResponse<Void> setPalletMergeBatch(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid @RequestBody LastlegOrderRequests.SetPalletMergeBatch request) {
orderService.setPalletMergeBatch(platformScope(user, customerId), request, operatorName(user));
return ApiResponse.success(null);
}
@GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EXPORT + "')")
@Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EXPORT)
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.customervisit;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.baiyue.lastleg.customervisit.mapper")
public class CustomerVisitMapperConfiguration {
}
@@ -7,7 +7,6 @@ import com.baiyue.lastleg.customervisit.dto.CustomerVisitResponses;
import com.baiyue.lastleg.customervisit.service.CustomerVisitService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -25,7 +24,6 @@ import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
@RestController
@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true")
@RequestMapping("/api/platform/v1/last-leg")
public class CustomerVisitController {
@Resource private CustomerVisitService service;
@@ -6,18 +6,18 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
import com.baiyue.lastleg.delivery.service.DeliveryTransferService;
import com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true")
@RequestMapping("/api/platform/v1/last-leg")
@Tag(name = "Outbound and delivery")
public class DeliveryTransferController {
@@ -65,6 +65,7 @@ public class DeliveryTransferController {
}
@GetMapping("/delivery-orders")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_LIST + "')")
public ApiResponse<PageResult<DeliveryResponses.DeliveryRow>> deliveryPage(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@Valid DeliveryRequests.Query query) {
@@ -72,10 +73,11 @@ public class DeliveryTransferController {
}
@GetMapping("/delivery-orders/{deliveryNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_LIST + "')")
public ApiResponse<DeliveryResponses.DeliveryDetail> deliveryDetail(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@PathVariable String deliveryNo, @Valid DeliveryRequests.PalletQuery query) {
return ApiResponse.success(service.deliveryDetail(scope(user, customerId), deliveryNo, query));
@PathVariable String deliveryNo) {
return ApiResponse.success(service.deliveryDetail(scope(user, customerId), deliveryNo));
}
private String scope(AuthenticatedUser user, String requestedCustomer) {
@@ -0,0 +1,46 @@
package com.baiyue.lastleg.delivery.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
import com.baiyue.lastleg.delivery.service.DeliveryTransferService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/merchant/v1/last-leg/delivery-orders")
public class MerchantDeliveryController {
@Resource private DeliveryTransferService service;
@GetMapping
public ApiResponse<PageResult<DeliveryResponses.DeliveryRow>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid DeliveryRequests.Query query) {
return ApiResponse.success(service.deliveryPage(customer(user), query));
}
@PostMapping
public ApiResponse<DeliveryResponses.TransferResult> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String dispatchNo,
@Valid @RequestBody DeliveryRequests.Transfer request) {
return ApiResponse.success(service.transfer(customer(user), dispatchNo, operator(user), request));
}
@GetMapping("/{deliveryNo}")
public ApiResponse<DeliveryResponses.DeliveryDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String deliveryNo) {
return ApiResponse.success(service.deliveryDetail(customer(user), deliveryNo));
}
private String customer(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required");
return String.valueOf(user.tenantId());
}
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); }
}
@@ -7,14 +7,12 @@ import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
import com.baiyue.lastleg.delivery.service.DeliveryTransferService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@ConditionalOnProperty(name="lastleg.future-outbound-api.enabled", havingValue="true")
@RequestMapping("/api/pda/v1/last-leg/outbound-tasks")
public class OutboundPdaController {
@Resource private DeliveryTransferService service;
@@ -41,9 +41,6 @@ public final class DeliveryRequests {
String driverName, String deliveryMethod,
LocalDateTime plannedFrom, LocalDateTime plannedTo) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
public Query(long page, long size, String documentNo, String warehouseId, String status) {
this(page, size, documentNo, warehouseId, status, null, null, null, null, null, null, null);
}
}
public record PalletQuery(@Min(1) long page, @Min(1) @Max(200) long size,
@@ -53,6 +53,7 @@ import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import java.math.BigDecimal;
import java.util.*;
@@ -77,6 +78,7 @@ public class DeliveryTransferService {
@Resource private LastlegOrderMapper tailOrders;
@Resource private LastlegOrderStatusHistoryMapper histories;
@Resource private LastlegOrderDetailMapper tailOrderDetails;
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Transactional
public DeliveryResponses.TransferResult transfer(String customerId, String dispatchNo, String operatorId,
@@ -105,7 +107,7 @@ public class DeliveryTransferService {
LocalDateTime now = LocalDateTime.now();
OutboundOrder outbound = new OutboundOrder();
outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId);
outbound.setOutboundNo("OUT" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + outbound.getId());
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", dispatch.getOverseasWarehouseId(), outbound.getId()));
outbound.setDispatchOrderId(dispatch.getId()); outbound.setDispatchNo(dispatch.getDispatchNo());
outbound.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); outbound.setStatus("WAIT_OUTBOUND"); outbound.setOutboundType("DISPATCH_DELIVERY");
outbound.setPackageCount(items.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
@@ -141,7 +143,7 @@ public class DeliveryTransferService {
DeliveryOrder delivery = new DeliveryOrder();
delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId);
delivery.setDeliveryNo("DEL" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + delivery.getId());
delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", dispatch.getOverseasWarehouseId(), delivery.getId()));
delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo());
delivery.setDispatchOrderId(dispatch.getId()); delivery.setDispatchNo(dispatch.getDispatchNo());
delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setOverseasWarehouseName(dispatch.getOverseasWarehouseName());
@@ -197,14 +199,16 @@ public class DeliveryTransferService {
OutboundOrder existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
if (existing != null) return taskResult(existing, pdaTasks.lockByOutbound(customerId, existing.getId()));
LocalDateTime now = LocalDateTime.now();
InventoryPosition firstPosition = inventoryPositions.selectById(request.items().get(0).inventoryPositionId());
if (firstPosition == null || !customerId.equals(firstPosition.getCustomerId())) throw notFound("Inventory position not found");
String selectedWarehouseId = firstPosition.getOverseasWarehouseId();
OutboundOrder outbound = new OutboundOrder();
outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId);
outbound.setOutboundNo("FBOO" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + outbound.getId());
outbound.setOutboundNo(orderNoGenerator.next("ONE_CLICK_DROPSHIP", selectedWarehouseId, outbound.getId()));
outbound.setOutboundType("ONE_CLICK_DROPSHIP"); outbound.setStatus("WAIT_PICK"); outbound.setRecipientInfo(request.recipient().trim());
outbound.setPackageCount(request.items().stream().mapToInt(i -> i.packages().intValue()).sum()); outbound.setPalletCount(request.items().size());
outbound.setIdempotencyKey(request.idempotencyKey().trim()); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now);
outboundOrders.insert(outbound);
String selectedWarehouseId = null;
for (DeliveryRequests.OneClickItem input : request.items()) {
InventoryPosition position = inventoryPositions.selectById(input.inventoryPositionId());
if (position == null || !customerId.equals(position.getCustomerId())) throw notFound("Inventory position not found");
@@ -290,7 +294,7 @@ public class DeliveryTransferService {
if (delivery != null) {
for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>().eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getOutboundOrderNo, outboundNo))) {
String from = order.getCurrentStatus(); order.setCurrentStatus("RECEIVED"); order.setSubOrderStatus(null); order.setOutboundOrderNo(null); order.setDeliveryOrderNo(null); order.setUpdatedAt(now); tailOrders.updateById(order);
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setOrderId(order.getId()); history.setFromStatus(from); history.setToStatus("RECEIVED"); history.setEventId("CANCEL_OUTBOUND:" + outboundNo); history.setRemark(request.reason().trim()); history.setCreatedAt(now); histories.insert(history);
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from); history.setToStatus("RECEIVED"); history.setEventId("CANCEL_OUTBOUND:" + outboundNo); history.setRemark(request.reason().trim()); history.setCreatedAt(now); histories.insert(history);
}
}
}
@@ -337,8 +341,7 @@ public class DeliveryTransferService {
return page(page, page.getRecords().stream().map(this::deliveryRow).toList());
}
public DeliveryResponses.DeliveryDetail deliveryDetail(String customerId, String deliveryNo,
DeliveryRequests.PalletQuery query) {
public DeliveryResponses.DeliveryDetail deliveryDetail(String customerId, String deliveryNo) {
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("Delivery order not found");
List<DispatchItem> items = dispatchItems.findByDispatchOrder(customerId, delivery.getDispatchOrderId());
@@ -375,7 +378,7 @@ public class DeliveryTransferService {
order.setDeliveryOrderNo(deliveryNo); order.setCurrentStatus("WAIT_OUTBOUND"); order.setUpdatedAt(now);
order.setSubOrderStatus("OUTBOUND:WAIT_OUTBOUND;DELIVERY:WAIT_DELIVERY");
tailOrders.updateById(order);
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setOrderId(order.getId());
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId());
history.setFromStatus(from); history.setToStatus("WAIT_OUTBOUND");
history.setEventId("TRANSFER_DELIVERY:" + deliveryNo);
history.setRemark(StringUtils.hasText(remark) ? remark.trim() : "Transferred to outbound and delivery documents");
@@ -394,7 +397,7 @@ public class DeliveryTransferService {
}
private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getCreatedAt()); }
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt()); }
private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); }
private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); }
private DeliveryResponses.DeliveryFeeRow feeRow(DeliveryFee f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getFeeDirection(), f.getFeeType(), f.getCurrency(), f.getAmount(), f.getRemark()); }
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); }
@@ -44,6 +44,7 @@ import java.util.Set;
@Service
public class CargoDiscardService {
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
public static final String APPROVAL_PERMISSION = "lastleg:cargo-discard:approve";
public static final String PENDING_REVIEW = "PENDING_REVIEW";
public static final String APPROVED = "APPROVED";
@@ -105,7 +106,7 @@ public class CargoDiscardService {
LocalDateTime now = LocalDateTime.now();
CargoDiscardApplication application = new CargoDiscardApplication();
application.setId(IdWorker.getId()); application.setCustomerId(customerId);
application.setDiscardNo("FH" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + application.getId());
application.setDiscardNo(orderNoGenerator.next("DISCARD", warehouseId, application.getId()));
application.setOverseasWarehouseId(warehouseId); application.setOverseasWarehouseName(warehouseName);
application.setInboundCodes(orderCache.values().stream().map(LastlegOrder::getInboundCode)
.distinct().sorted().collect(java.util.stream.Collectors.joining(",")));
@@ -13,7 +13,6 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
@@ -27,7 +26,6 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true")
@RequestMapping("/api/platform/v1/last-leg/dispatches")
@Tag(name = "Tail-leg vehicle dispatch")
public class DispatchController {
@@ -7,14 +7,12 @@ import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true")
@RequestMapping("/api/pda/v1/last-leg/dispatches/{dispatchNo}")
public class DispatchPdaController {
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
@@ -7,7 +7,8 @@ public final class DispatchPdaResponses {
public record Task(Long id, String taskNo, String dispatchNo, String vehiclePlate, String warehouseId, String status,
List<Item> items) {}
public record Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode, int scheduledPackages,
String destinationWarehouseName, String fbaWarehouseCode, String warehouseRouteLabel,
int scheduledPackages,
long pickedPackages, List<Position> positions) {}
public record Position(Long id, Long balanceId, long availablePackages, long allocatedPackages,
long pickedPackages) {}
@@ -119,6 +119,11 @@ public class DispatchPdaWorkflowService {
inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(),
request.packages(), order.getDispatchNo(), order.getDispatchNo(), null,
keyBase + ":PICK", request.remark()));
// A successful PDA scan is the inventory deduction point. Departure only
// changes outbound/dispatch facts and must not deduct the same quantity again.
inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(),
request.packages(), order.getDispatchNo(), order.getDispatchNo(), null,
keyBase + ":SHIP", request.remark()));
DispatchPdaPick pick = new DispatchPdaPick();
pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId());
pick.setDispatchItemId(itemId); pick.setInventoryPositionId(request.positionId()); pick.setPackages(request.packages());
@@ -149,6 +154,14 @@ public class DispatchPdaWorkflowService {
if (replay != null) return read(replay, DispatchPdaResponses.PickingCompleted.class).withReplay();
requireVehicle(order);
DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId());
if (task != null && "COMPLETED".equals(task.getStatus())) {
List<DispatchItem> completedItems = items.findByDispatchOrder(customerId, order.getId());
long planned = completedItems.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull)
.mapToLong(Integer::longValue).sum();
long picked = picks.listTask(customerId, task.getId()).stream().mapToLong(p -> n(p.getPackages())).sum();
return new DispatchPdaResponses.PickingCompleted(order.getDispatchNo(), order.getStatus(), planned,
picked, Math.max(0, planned - picked), picked < planned, true);
}
if (task == null || !("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) {
throw conflict("Dispatch PDA task is not available for completion");
}
@@ -197,14 +210,7 @@ public class DispatchPdaWorkflowService {
}
List<DispatchPdaPick> dispatchPicks = picks.listTask(customerId, task.getId());
if (dispatchPicks.isEmpty()) throw conflict("Dispatch has no PDA picks to ship");
long shipped = 0;
for (DispatchPdaPick pick : dispatchPicks) {
String key = "DISPATCH:" + order.getDispatchNo() + ":PICK:" + pick.getId() + ":SHIP";
inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(
pick.getInventoryPositionId(), n(pick.getPackages()),
outbound.getOutboundNo(), outbound.getOutboundNo(), null, key, request.remark()));
shipped += n(pick.getPackages());
}
long shipped = dispatchPicks.stream().mapToLong(p -> n(p.getPackages())).sum();
LocalDateTime now = LocalDateTime.now();
pickAttribution.depart(customerId, outbound.getOutboundNo(), order.getDispatchNo(), dispatchPicks, now);
outbound.setStatus("OUTBOUNDED");
@@ -227,7 +233,8 @@ public class DispatchPdaWorkflowService {
List<InventoryPosition> palletPositions = item.getWarehousePalletId() == null ? List.of()
: positions.listPallet(customerId, item.getWarehousePalletId());
values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(),
item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), item.getPackageCount(),
item.getDestinationWarehouseName(), item.getFbaWarehouseCode(),
warehouseRouteLabel(item.getFbaWarehouseCode(), item.getDestinationWarehouseCode()), item.getPackageCount(),
picks.totalItem(customerId, task.getId(), item.getId()), palletPositions.stream().map(p ->
new DispatchPdaResponses.Position(p.getId(), p.getBalanceId(), n(p.getAvailablePackages()),
n(p.getAllocatedPackages()), n(p.getPickedPackages()))).toList()));
@@ -270,6 +277,12 @@ public class DispatchPdaWorkflowService {
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw new IllegalStateException(e); } }
private static long n(Long value) { return value == null ? 0 : value; }
private static long n(Integer value) { return value == null ? 0 : value; }
static String warehouseRouteLabel(String originalFbaWarehouseCode, String destinationWarehouseCode) {
String original = trim(originalFbaWarehouseCode);
String destination = trim(destinationWarehouseCode);
return original != null && destination != null && !original.equalsIgnoreCase(destination)
? original + " -> " + destination : null;
}
private static String trim(String value) { return value == null || value.trim().isEmpty() ? null : value.trim(); }
private static String required(String value, String message) { String result = trim(value); if (result == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); return result; }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
@@ -84,6 +84,7 @@ public class DispatchPickOrderAttributionService {
long shipped = value(before.getShippedPackageCount()) + entry.getValue();
String to = shipped == value(before.getPackageCount()) ? "COMPLETED" : "PARTIALLY_SHIPPED";
LastlegOrderStatusHistory row = new LastlegOrderStatusHistory();
row.setCustomerId(customerId);
row.setOrderId(entry.getKey());
row.setFromStatus(before.getCurrentStatus());
row.setToStatus(to);
@@ -31,6 +31,7 @@ import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
@@ -48,6 +49,7 @@ public class DispatchService {
@Resource private DispatchDraftMapper drafts;
@Resource private DispatchDraftContainerMapper draftContainers;
@Resource private DispatchCandidateMapper candidates;
@Resource private LastlegOrderNoGenerator orderNoGenerator;
public String resolveCustomerScopeForPlans(List<Long> planIds) {
return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")),
@@ -239,7 +241,7 @@ public class DispatchService {
DispatchOrder dispatch = new DispatchOrder();
dispatch.setId(IdWorker.getId());
dispatch.setCustomerId(customerId);
dispatch.setDispatchNo("PC" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + dispatch.getId());
dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", selected.get(0).overseasWarehouseId(), dispatch.getId()));
dispatch.setOverseasWarehouseId(selected.get(0).overseasWarehouseId());
dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName());
dispatch.setRoute(StringUtils.hasText(request.route()) ? request.route().trim() : route(selected));
@@ -18,8 +18,8 @@ public final class LastlegOrderRequests {
}
public record Query(
@Min(1) long page,
@Min(1) @Max(200) long size,
@Min(1) Long page,
@Min(1) @Max(200) Long size,
String orderNo,
String orderType,
String inboundCode,
@@ -35,8 +35,8 @@ public final class LastlegOrderRequests {
LocalDateTime createdTo
) {
public Query {
if (page == 0) page = 1;
if (size == 0) size = 20;
if (page == null || page == 0) page = 1L;
if (size == null || size == 0) size = 20L;
}
}
@@ -68,4 +68,21 @@ public final class LastlegOrderRequests {
@Size(max = 500) String supplierDescription
) {
}
public record SetDetained(
@NotNull Boolean detained,
@NotBlank @Size(max = 500) String remark
) {
}
public record SetPalletMergeRequired(
@NotNull Boolean palletMergeRequired
) {
}
public record SetPalletMergeBatch(
@NotEmpty @Size(max = 200) List<Long> orderIds,
@NotNull Boolean palletMergeRequired
) {
}
}
@@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "按入仓单编码粒度展示的尾程订单列表行")
public record LastlegOrderSummary(
@@ -40,6 +41,9 @@ public record LastlegOrderSummary(
String relabelOrderNo,
String relabelStatus,
String palletMergeFlag,
String palletMergeBatchId,
@Schema(description = "同一合托组内其他来源入仓单的入仓码")
List<String> palletMergeInboundNos,
String currentStatus,
String subOrderStatus,
String privateRemark,
@@ -52,6 +52,7 @@ public class LastlegOrder {
private String relabelOrderNo;
private String relabelStatus;
private String palletMergeFlag;
private String palletMergeBatchId;
private String currentStatus;
private String subOrderStatus;
private String privateRemark;
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.exceptionorder;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.baiyue.lastleg.exceptionorder.mapper")
public class ExceptionOrderMapperConfiguration {
}
@@ -8,7 +8,6 @@ import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderResponses;
import com.baiyue.lastleg.exceptionorder.service.ExceptionOrderService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
@@ -17,7 +16,6 @@ import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true")
@RequestMapping("/api/platform/v1/last-leg")
public class ExceptionOrderController {
@Resource private ExceptionOrderService service;
@@ -31,6 +31,7 @@ import java.util.Objects;
@Service
public class ExceptionOrderService {
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
@Resource private ExceptionOrderMapper exceptionOrders;
@Resource private LastlegOrderMapper lastMileOrders;
@Resource private LastlegInboundOrderMapper inboundOrders;
@@ -158,7 +159,7 @@ public class ExceptionOrderService {
LocalDateTime now = LocalDateTime.now();
ExceptionOrder value = new ExceptionOrder();
value.setId(IdWorker.getId()); value.setCustomerId(relation.customerId());
value.setExceptionNo("EXC" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + value.getId());
value.setExceptionNo(orderNoGenerator.next("EXCEPTION", relation.overseasWarehouseId(), value.getId()));
value.setExceptionType(type); value.setStatus("PENDING"); value.setReason(trim(reason));
value.setLastMileOrderId(relation.lastMileOrderId()); value.setLastMileOrderNo(relation.lastMileOrderNo());
value.setInboundOrderId(relation.inboundOrderId()); value.setInboundCode(relation.inboundCode());
@@ -11,6 +11,7 @@ import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest;
import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest;
import com.baiyue.lastleg.inboundplan.dto.UpdateInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
import com.baiyue.lastleg.security.OverseasPermissions;
@@ -62,6 +63,15 @@ public class InboundPlanController {
return ApiResponse.success(service.detailByPlanNo(scope(user, customerId), planNo));
}
@PutMapping("/{planNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')")
@Operation(summary = "编辑入仓计划")
public ApiResponse<InboundPlanResponse> update(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@Valid @RequestBody UpdateInboundPlanRequest request) {
return ApiResponse.success(service.update(scope(user, request.customerId()), planNo, request));
}
@PostMapping("/{planNo}/pallets/supplement")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')")
/** 为计划目标仓新增一个可打印的补充托盘。 */
@@ -26,7 +26,8 @@ public record InboundPlanListResponse(
LocalDateTime appointmentAt,
LocalDateTime expectedLoadingAt,
String dispatchStatus,
LocalDateTime createdAt
LocalDateTime createdAt,
Integer version
) {
public InboundPlanListResponse(Long lineId, Long planId, String planNo, String customerId,
Long orderId, String inboundCode, String orderType,
@@ -39,6 +40,6 @@ public record InboundPlanListResponse(
this(lineId, planId, planNo, customerId, orderId, inboundCode, orderType, warehouseId,
warehouseCode, warehouseName, fbaWarehouseCode, customerName, packageCount, 0,
volumeCbm, palletCount, status, appointmentNo, appointmentStatus, appointmentAt,
expectedLoadingAt, dispatchStatus, createdAt);
expectedLoadingAt, dispatchStatus, createdAt, 0);
}
}
@@ -18,15 +18,24 @@ public record InboundPlanResponse(
int pickedPackageCount,
List<Line> lines,
List<Pallet> pallets,
List<Allocation> allocations
List<Allocation> allocations,
int version
) {
public InboundPlanResponse(Long id, String planNo, String customerId, Long sourceOrderId,
String containerNo, String status, int lineCount, int palletCount,
BigDecimal totalVolumeCbm, List<Line> lines, List<Pallet> pallets,
List<Allocation> allocations) {
this(id, planNo, customerId, sourceOrderId, containerNo, status, lineCount, palletCount,
totalVolumeCbm, lines, pallets, allocations, 0);
}
public InboundPlanResponse(Long id, String planNo, String customerId, Long sourceOrderId,
String containerNo, String status, int lineCount, int palletCount,
BigDecimal totalVolumeCbm, List<Line> lines, List<Pallet> pallets,
List<Allocation> allocations, int version) {
this(id, planNo, customerId, sourceOrderId, containerNo, status, lineCount, palletCount,
totalVolumeCbm, lines.stream().mapToInt(Line::requiredPackageCount).sum(),
allocations.stream().mapToInt(Allocation::pickedPackageCount).sum(), lines, pallets, allocations);
allocations.stream().mapToInt(Allocation::pickedPackageCount).sum(), lines, pallets, allocations, version);
}
public record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
@@ -0,0 +1,12 @@
package com.baiyue.lastleg.inboundplan.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record UpdateInboundPlanRequest(
@NotBlank String customerId,
@NotNull Long overseasWarehouseId,
String warehouseCode,
String warehouseName,
@NotNull Integer version
) {}
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.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.Data;
import java.math.BigDecimal;
@@ -24,4 +25,6 @@ public class InboundPlan {
private BigDecimal totalVolumeCbm;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@Version
private Integer version;
}
@@ -23,12 +23,9 @@ public interface InboundPlanOrderMapper {
"COALESCE(NULLIF(o.transfer_warehouse_id,''),MIN(NULLIF(d.destination_warehouse_id,''))) AS destinationWarehouseId," +
"COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,''))) AS destinationWarehouseCode," +
"COALESCE(NULLIF(o.transfer_warehouse_name,''),MIN(NULLIF(d.destination_warehouse_name,''))) AS destinationWarehouseName," +
"COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) AS destinationCount," +
"SUM(CASE WHEN d.id IS NOT NULL " +
"AND COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL " +
"AND COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#') " +
"<> COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) THEN 1 ELSE 0 END) " +
"AS destinationMismatchCount " +
"CASE WHEN COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL THEN 1 " +
"ELSE COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) END AS destinationCount," +
"0 AS destinationMismatchCount " +
"FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id " +
"WHERE o.customer_id=#{customerId} AND o.id=#{orderId} " +
"GROUP BY o.id,o.customer_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.pallet_count,o.fba_warehouse_code," +
@@ -45,8 +45,9 @@ public interface InboundPlanQueryMapper {
"l.planned_pallet_count palletCount,p.status status,a.appointment_no appointmentNo," +
"a.appointment_status appointmentStatus,a.appointment_at appointmentAt," +
"a.expected_loading_at expectedLoadingAt," +
"CASE WHEN EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.customer_id=#{customerId} AND di.order_id=l.order_id)" +
" THEN 'SCHEDULED' ELSE 'UNSCHEDULED' END dispatchStatus,p.created_at createdAt" +
"CASE WHEN EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.order_id=l.order_id" +
" <if test='customerId != null and customerId != \"\"'>AND di.customer_id=#{customerId}</if>)" +
" THEN 'SCHEDULED' ELSE 'UNSCHEDULED' END dispatchStatus,p.created_at createdAt,p.version version" +
FROM_AND_FILTER + " ORDER BY p.created_at DESC,l.id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<InboundPlanListResponse> rows(@Param("customerId") String customerId,
@Param("q") InboundPlanRequests.Query query,
@@ -11,6 +11,10 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -24,6 +28,7 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.util.List;
@RestController
@@ -42,6 +47,14 @@ public class ReceivingContainerController {
return ApiResponse.success(service.candidates(identity.operatorKey(), identity.overseasWarehouseId()));
}
@GetMapping("/warehouse-profile")
@Operation(summary = "Get the authenticated warehouse picker's current Chinese warehouse master name")
public ApiResponse<ReceivingContainerResponses.WarehouseProfile> warehouseProfile(
@AuthenticationPrincipal AuthenticatedUser user) {
PdaWarehouseIdentity identity = identity(user);
return ApiResponse.success(service.warehouseProfile(identity.overseasWarehouseId()));
}
@GetMapping("/{containerNo}")
@Operation(summary = "Get one receiving container without exposing plan or allocation IDs")
public ApiResponse<ReceivingContainerResponses.Session> detail(@AuthenticationPrincipal AuthenticatedUser user,
@@ -66,6 +79,18 @@ public class ReceivingContainerController {
return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), user.userId(), file));
}
@GetMapping("/current/unload-photos/files/{fileId}/download")
public ResponseEntity<byte[]> downloadCurrentPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String fileId) {
PdaWarehouseIdentity identity = currentIdentity(user);
var download = service.downloadCurrentPhoto(identity.operatorKey(), fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.inline().filename(download.fileName(), StandardCharsets.UTF_8).build().toString())
.contentType(org.springframework.http.MediaType.parseMediaType(download.contentType()))
.contentLength(download.content().length).body(download.content());
}
@PostMapping("/current/sign")
public ApiResponse<ReceivingContainerResponses.Session> sign(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.Sign request) {
@@ -87,14 +112,6 @@ public class ReceivingContainerController {
return ApiResponse.success(service.resolveCarton(identity.operatorKey(), request));
}
@PostMapping("/current/skus/resolve")
public ApiResponse<ReceivingContainerResponses.SkuResolved> resolveSku(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.SkuResolve request) {
PdaWarehouseIdentity identity = currentIdentity(user);
return ApiResponse.success(service.resolveSku(identity.operatorKey(), request));
}
@PostMapping("/current/pallets/resolve")
public ApiResponse<ReceivingContainerResponses.PalletResolved> resolvePallet(
@AuthenticationPrincipal AuthenticatedUser user,
@@ -18,10 +18,8 @@ public final class ReceivingContainerRequests {
public record SignedPhoto(@NotNull ReceivingPhotoRole role,
@NotBlank @Pattern(regexp = "^[1-9][0-9]{0,18}$") String fileId) { }
public record CartonResolve(@NotBlank String cartonMark) { }
public record SkuResolve(@NotBlank @Size(max = 128) String skuCode) { }
public record PalletResolve(@NotBlank String palletCode, @NotBlank String inboundCode) { }
public record BulkReceive(@NotBlank String inboundCode, @NotBlank @Size(max = 128) String skuCode,
@NotBlank String palletCode,
public record BulkReceive(@NotBlank String inboundCode, @NotBlank String palletCode,
@Positive int packageCount, @NotBlank @Size(max = 255) String idempotencyKey) { }
public record CartonReceive(@NotBlank String cartonMark, @NotBlank String palletCode,
@NotBlank @Size(max = 255) String idempotencyKey) { }
@@ -10,6 +10,8 @@ public final class ReceivingContainerResponses {
List<String> destinationCodes, int plannedPalletCount,
int totalPackages, int receivedPackages, boolean currentAccount) { }
public record WarehouseProfile(String warehouseId, String warehouseCode, String warehouseName) { }
public record Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName,
String status, String operatorName, int version,
List<SignedPhoto> photos, LocalDateTime preparedAt, LocalDateTime signedAt,
@@ -17,28 +19,35 @@ public final class ReceivingContainerResponses {
int totalPackages, int receivedPackages, int pendingPackages,
List<Inbound> inbounds) { }
public record SignedPhoto(ReceivingPhotoRole role, String fileId) { }
public record SignedPhoto(ReceivingPhotoRole role, String fileId, String downloadPath) { }
public record Inbound(String inboundCode, String destinationType,
String overseasWarehouseId, String overseasWarehouseName,
String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
int receivedPackages, int pendingPackages) { }
int receivedPackages, int pendingPackages,
boolean palletMergeRequired, String palletMergeHint) { }
public record CartonResolved(String cartonMark, String skuCode, String inboundCode,
public record CartonResolved(String cartonMark, String inboundCode,
String destinationType, String overseasWarehouseId,
String overseasWarehouseName, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
int receivedPackages, int pendingPackages) { }
public record SkuResolved(String skuCode, String productName, String inboundCode,
String destinationType, String overseasWarehouseId,
String overseasWarehouseName, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
int receivedPackages, int pendingPackages) { }
int receivedPackages, int pendingPackages,
boolean palletMergeRequired, String palletMergeHint) {
public CartonResolved(String cartonMark, String inboundCode, String destinationType,
String overseasWarehouseId, String overseasWarehouseName,
String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode,
int plannedPalletCount, int totalPackages, int receivedPackages,
int pendingPackages) {
this(cartonMark, inboundCode, destinationType, overseasWarehouseId, overseasWarehouseName,
destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
fbaWarehouseCode, plannedPalletCount, totalPackages, receivedPackages,
pendingPackages, false, null);
}
}
public record PalletResolved(String palletCode, long warehouseId, String status,
String fbaWarehouseCode, String inboundCode,
@@ -46,7 +55,15 @@ public final class ReceivingContainerResponses {
public record Receipt(String result, String containerNo, String inboundCode,
String destinationType, String palletCode, int acceptedPackages,
int receivedPackages, int pendingPackages, String sessionStatus) { }
int receivedPackages, int pendingPackages, String sessionStatus,
boolean palletMergeRequired, String palletMergeHint) {
public Receipt(String result, String containerNo, String inboundCode,
String destinationType, String palletCode, int acceptedPackages,
int receivedPackages, int pendingPackages, String sessionStatus) {
this(result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages,
receivedPackages, pendingPackages, sessionStatus, false, null);
}
}
public record Photo(String fileId, String fileName, String contentType, long sizeBytes) { }
}
@@ -8,6 +8,9 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.stereotype.Component;
@@ -85,6 +88,39 @@ public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway {
}
}
@Override
public DownloadedFile download(String customerId, long taskId, long fileId) {
long tenantId = tenantId(customerId);
ResponseEntity<byte[]> response = client.get()
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content",
tenantId, BUSINESS_TYPE, taskId, fileId)
.header("X-Internal-Service-Token", internalToken)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (request, result) -> {
HttpStatusCode status = result.getStatusCode();
if (status.value() == HttpStatus.NOT_FOUND.value()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Unload photo was not found");
}
if (status.value() == HttpStatus.FORBIDDEN.value()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"Unload photo is not in current account scope");
}
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unload photo download failed");
})
.onStatus(HttpStatusCode::is5xxServerError, (request, result) -> {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Unload photo download failed");
})
.toEntity(byte[].class);
byte[] content = response.getBody() == null ? new byte[0] : response.getBody();
String fileName = contentDispositionFilename(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION));
if (fileName == null || fileName.isBlank()) {
fileName = String.valueOf(fileId) + ".bin";
}
String contentType = response.getHeaders().getContentType() == null
? MediaType.APPLICATION_OCTET_STREAM_VALUE : response.getHeaders().getContentType().toString();
return new DownloadedFile(content, fileName, contentType);
}
private long tenantId(String customerId) {
try { return Long.parseLong(customerId); }
catch (NumberFormatException exception) { throw new ResponseStatusException(
@@ -104,6 +140,15 @@ public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway {
response.message() == null || response.message().isBlank() ? fallback : response.message());
}
private static String contentDispositionFilename(String rawValue) {
if (rawValue == null || rawValue.isBlank()) return null;
try {
return ContentDisposition.parse(rawValue).getFilename();
} catch (IllegalArgumentException ignored) {
return rawValue.replace("attachment; filename=\"", "").replace("\"", "").trim();
}
}
private record MetadataEnvelope(String code, String message, Metadata data) {}
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String contentType, Long sizeBytes) {}
@@ -5,6 +5,8 @@ import org.springframework.web.multipart.MultipartFile;
public interface UnloadPhotoFileGateway {
UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file);
void validate(String customerId, long taskId, long fileId);
DownloadedFile download(String customerId, long taskId, long fileId);
record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) {}
record DownloadedFile(byte[] content, String fileName, String contentType) {}
}
@@ -224,15 +224,17 @@ public class InboundStockReceiptService {
String operationKey = "PDA_STOCK_IN:" + task.getId() + ":" + allocation.getId() + ":" + sku.skuCode();
String overseasWarehouseId = required(order.getOverseasWarehouseId(),
"Actual overseas warehouse is required for stock-in");
InventoryCommandResponse result = inventory.stockIn(customerId, task.getOperatorId(),
new InventoryCommandRequests.StockIn(null, order.getCustomerName(), overseasWarehouseId,
InventoryCommandRequests.StockIn stockIn = new InventoryCommandRequests.StockIn(null, order.getCustomerName(), overseasWarehouseId,
null, order.getOverseasWarehouseName(), line.getDestinationWarehouseId(), destinationCode,
line.getDestinationWarehouseName(), order.getId(), plan.getPlanNo(), line.getInboundCode(),
sku.skuCode(), sku.productNameZh(), sku.productNameEn(),
pallet.getWarehousePalletId(), pallet.getPalletNo(), plan.getPlanNo(),
pallet.getWarehouseLocationId(), null, packages, order.getOrderNo(),
order.getSourceEventId(), operationKey,
"PDA inbound completion for " + line.getInboundCode()));
"PDA inbound completion for " + line.getInboundCode());
InventoryCommandResponse result = Boolean.TRUE.equals(order.getDetained())
? inventory.stockInHeld(customerId, task.getOperatorId(), stockIn)
: inventory.stockIn(customerId, task.getOperatorId(), stockIn);
InboundStockReceipt receipt = new InboundStockReceipt();
receipt.setId(IdWorker.getId());
@@ -15,7 +15,7 @@ import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.UUID;
/** 至少一次投递海外卸柜完成事件;国内事件桥按 eventId 幂等接收。 */
/** 至少一次投递海外到国内业务事件;国内事件桥按 eventId 幂等接收。 */
@Component
@Slf4j
public class InboundUnloadCompletedOutboxRelay {
@@ -44,7 +44,7 @@ public class InboundUnloadCompletedOutboxRelay {
} catch (Exception exception) {
repository.retry(row.getId(), workerId, LocalDateTime.now().plusSeconds(Math.max(retrySeconds, 1)),
exception.getMessage());
log.warn("海外卸柜完成事件投递失败: eventKey={}, reason={}", row.getEventKey(), exception.getMessage());
log.warn("海外到国内事件投递失败: eventKey={}, reason={}", row.getEventKey(), exception.getMessage());
}
}
}
@@ -53,7 +53,7 @@ public class InboundUnloadCompletedOutboxRelay {
com.baiyue.common.core.api.ApiResponse<EventBridgeClient.Acknowledgement> response) {
if (response == null) throw new IllegalStateException("国内事件桥返回空响应");
if (!response.successful()) {
throw new IllegalStateException("国内事件桥拒绝海外卸柜完成事件: code=" + response.code()
throw new IllegalStateException("国内事件桥拒绝海外事件: code=" + response.code()
+ ", message=" + response.message() + ", requestId=" + response.requestId());
}
if (response.data() == null) throw new IllegalStateException("国内事件桥成功响应缺少确认数据");
@@ -17,6 +17,7 @@ import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingPhotoRole;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt;
@@ -58,6 +59,10 @@ import java.util.stream.Collectors;
@Service
public class ReceivingContainerService {
private static final String PHOTO_DOWNLOAD_PATH =
"/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/{fileId}/download";
private static final String PALLET_MERGE_HINT = "该入仓单已标记合托,请按合托要求拣货";
@Resource private InboundPlanMapper plans;
@Resource private InboundPlanLineMapper lines;
@Resource private InboundPlanPalletMapper pallets;
@@ -83,6 +88,12 @@ public class ReceivingContainerService {
.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
}
public ReceivingContainerResponses.WarehouseProfile warehouseProfile(long overseasWarehouseId) {
WarehouseResourceGateway.WarehouseReference value = warehouse.requireActiveSharedWarehouse(overseasWarehouseId);
return new ReceivingContainerResponses.WarehouseProfile(
Long.toString(value.warehouseId()), value.warehouseCode(), value.warehouseName());
}
public List<ReceivingContainerResponses.Candidate> candidates(String operatorKey, long overseasWarehouseId) {
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
requireSessionWarehouse(current, overseasWarehouseId);
@@ -198,6 +209,16 @@ public class ReceivingContainerService {
return snapshot(session, requirePlans(session.getCustomerId(), session.getContainerNo()));
}
public UnloadPhotoFileGateway.DownloadedFile downloadCurrentPhoto(String operatorKey, String fileId) {
ReceivingContainerSession session = requireCurrent(operatorKey, false);
long parsedFileId = positiveFileId(fileId);
if (signedPhotos(session).stream().noneMatch(photo -> fileId.equals(photo.fileId()))) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"Unload photo is not part of the current receiving session");
}
return unloadPhotos.download(session.getCustomerId(), session.getId(), parsedFileId);
}
public void requireCurrentWarehouse(String operatorKey, long overseasWarehouseId) {
requireSessionWarehouse(sessions.findActiveByOperator(activeOperatorKey(operatorKey)), overseasWarehouseId);
}
@@ -206,40 +227,27 @@ public class ReceivingContainerService {
ReceivingContainerRequests.CartonResolve request) {
ReceivingContainerSession session = requireSigned(operatorKey);
String customerId = session.getCustomerId();
List<InboundRef> currentRefs = refs(customerId, session.getContainerNo());
if (currentRefs.stream().noneMatch(value ->
"OVERSEAS".equals(destinationType(value.line(), value.order())))) {
throw conflict("FBA destination must use bulk count");
}
InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper<InboundPlanCarton>()
.eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark()));
if (carton == null) throw notFound("Carton mark was not found");
InboundRef ref = currentRefs.stream()
.filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).findFirst()
.orElseThrow(() -> conflict("Carton mark belongs to another container"));
if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) {
throw conflict("FBA inbound must use bulk count instead of carton scan");
String inboundCode = cartonInboundCode(request.cartonMark());
List<InboundRef> matches = refs(customerId, session.getContainerNo()).stream()
.filter(value -> inboundCode.equals(value.line().getInboundCode())).toList();
CartonRef carton;
if (matches.size() > 1) {
throw conflict("Carton mark inbound code is ambiguous in the current container");
} else if (matches.size() == 1 && "FBA".equals(destinationType(matches.get(0).line(), matches.get(0).order()))) {
carton = new CartonRef(matches.get(0));
} else if (matches.size() == 1) {
carton = requireCartonRef(customerId, session.getContainerNo(), request.cartonMark());
} else {
carton = requireUnmatchedFbaCartonPrefix(customerId, inboundCode);
}
InboundRef ref = carton.ref();
ReceivingContainerResponses.Inbound inbound = inbound(ref);
return new ReceivingContainerResponses.CartonResolved(carton.getCartonMark(), carton.getSkuCode(),
return new ReceivingContainerResponses.CartonResolved(request.cartonMark().trim(),
inbound.inboundCode(), inbound.destinationType(), inbound.overseasWarehouseId(),
inbound.overseasWarehouseName(), inbound.destinationWarehouseId(),
inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(),
inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages());
}
public ReceivingContainerResponses.SkuResolved resolveSku(String operatorKey,
ReceivingContainerRequests.SkuResolve request) {
ReceivingContainerSession session = requireSigned(operatorKey);
String customerId = session.getCustomerId();
List<SkuRef> matches = refs(customerId, session.getContainerNo()).stream()
.filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order())))
.map(ref -> skuRef(ref, request.skuCode()))
.filter(Objects::nonNull)
.toList();
if (matches.isEmpty()) throw notFound("SKU was not found in the current FBA container");
if (matches.size() != 1) throw conflict("SKU belongs to multiple inbound codes in the current container");
return skuResponse(matches.get(0));
inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages(),
inbound.palletMergeRequired(), inbound.palletMergeHint());
}
public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey,
@@ -264,22 +272,28 @@ public class ReceivingContainerService {
if (!"FBA".equals(destinationType(ref.line(), ref.order()))) {
throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden");
}
SkuRef sku = requireSku(ref, request.skuCode());
validateSkuPending(customerId, sku, request.packageCount());
workflow.validateBulkResolved(customerId, ref.plan(), ref.line(), request.packageCount());
List<InboundRef> fbaPlanRefs = requireFbaPlanRefs(customerId, session.getContainerNo(), ref);
List<FbaSkuAllocation> skuAllocations = allocateFbaSkuReceipts(customerId, fbaPlanRefs, request.packageCount());
skuAllocations.forEach(allocation -> workflow.validateBulkResolved(customerId, allocation.ref().plan(),
allocation.ref().line(), allocation.packages()));
WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode());
validatePallet(ref, resource);
InboundPlanPallet pallet = choosePallet(ref, resource);
Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource);
try {
int accepted = workflow.receiveBulkResolved(customerId, ref.plan(), ref.line(), pallet,
request.packageCount(), operatorKey, operatorName);
recordSkuReceipt(session, sku, pallet, accepted);
int accepted = 0;
for (FbaSkuAllocation allocation : skuAllocations) {
accepted += workflow.receiveBulkResolved(customerId, allocation.ref().plan(), allocation.ref().line(), pallet,
allocation.packages(), operatorKey, operatorName);
recordSkuReceipt(session, allocation, pallet);
}
markReceiving(session);
ReceivingContainerResponses.Inbound inbound = inbound(ref);
int received = fbaPlanRefs.stream().mapToInt(this::receivedLinePackages).sum();
int total = fbaPlanRefs.stream().mapToInt(refValue -> value(refValue.line().getRequiredPackageCount())).sum();
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), accepted,
inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus());
session.getContainerNo(), ref.line().getInboundCode(), "FBA", resource.palletNo(), accepted,
received, Math.max(0, total - received), session.getStatus(), palletMergeRequired(ref.order()),
palletMergeHint(ref.order()));
save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result);
return result;
} catch (RuntimeException failure) {
@@ -300,12 +314,8 @@ public class ReceivingContainerService {
"OVERSEAS".equals(destinationType(value.line(), value.order())))) {
throw conflict("FBA destination must use bulk count");
}
InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper<InboundPlanCarton>()
.eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark()));
if (carton == null) throw notFound("Carton mark was not found");
InboundRef ref = currentRefs.stream()
.filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).findFirst()
.orElseThrow(() -> conflict("Carton mark belongs to another container"));
CartonRef carton = requireCartonRef(customerId, session.getContainerNo(), request.cartonMark());
InboundRef ref = carton.ref();
if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) {
throw conflict("FBA destination must use bulk count");
}
@@ -321,7 +331,8 @@ public class ReceivingContainerService {
ReceivingContainerResponses.Inbound inbound = inbound(ref);
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), 1,
inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus());
inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus(),
inbound.palletMergeRequired(), inbound.palletMergeHint());
save(session, "CARTON_RECEIVE", request.idempotencyKey(), request, result);
return result;
} catch (RuntimeException failure) {
@@ -406,7 +417,8 @@ public class ReceivingContainerService {
List<InboundRef> result = new ArrayList<>();
for (InboundPlan plan : plansForContainer(customerId, containerNo)) {
for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()))) {
.eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId())
.orderByAsc(InboundPlanLine::getId))) {
LastlegOrder order = orders.selectById(line.getOrderId());
if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Inbound line order is missing");
result.add(new InboundRef(plan, line, order));
@@ -419,8 +431,53 @@ public class ReceivingContainerService {
List<InboundRef> matches = refs(customerId, containerNo).stream()
.filter(value -> inboundCode.equals(value.line().getInboundCode())).toList();
if (matches.isEmpty()) throw notFound("Inbound code is not part of the current container");
if (matches.size() != 1) throw conflict("Inbound code is duplicated inside the current container");
return matches.get(0);
Set<Long> planIds = matches.stream().map(value -> value.plan().getId()).collect(Collectors.toSet());
if (planIds.size() != 1) throw conflict("Inbound code is duplicated inside the current container");
return matches.stream().min(Comparator.comparing(value -> value.line().getId())).orElseThrow();
}
private CartonRef requireCartonRef(String customerId, String containerNo, String cartonMark) {
InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper<InboundPlanCarton>()
.eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, cartonMark));
if (carton == null) throw notFound("Carton mark was not found");
List<InboundRef> matches = refs(customerId, containerNo).stream()
.filter(value -> carton.getLineId() == null || Objects.equals(value.line().getId(), carton.getLineId()))
.filter(value -> carton.getPlanId() == null || Objects.equals(value.plan().getId(), carton.getPlanId()))
.filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).toList();
if (matches.size() != 1) throw conflict("Carton mark belongs to another container");
return new CartonRef(matches.get(0));
}
private String cartonInboundCode(String cartonMark) {
int delimiter = cartonMark == null ? -1 : cartonMark.indexOf('#');
if (delimiter < 1) throw conflict("Carton mark must contain a non-empty inbound code prefix");
String inboundCode = cartonMark.substring(0, delimiter).trim();
if (inboundCode.isEmpty()) throw conflict("Carton mark must contain a non-empty inbound code prefix");
return inboundCode;
}
private CartonRef requireUnmatchedFbaCartonPrefix(String customerId, String inboundCode) {
boolean exists = lines.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getCustomerId, customerId)
.eq(InboundPlanLine::getInboundCode, inboundCode)).stream().findAny().isPresent();
if (exists) throw conflict("Carton mark belongs to another container");
throw notFound("Carton mark was not found");
}
private List<InboundRef> requireFbaPlanRefs(String customerId, String containerNo, InboundRef selected) {
List<InboundRef> values = refs(customerId, containerNo).stream()
.filter(value -> Objects.equals(value.plan().getId(), selected.plan().getId()))
.sorted(Comparator.comparing(value -> value.line().getId())).toList();
if (values.isEmpty() || values.stream().anyMatch(value ->
!"FBA".equals(destinationType(value.line(), value.order())))) {
throw conflict("FBA bulk receiving requires every line in the selected inbound plan to be FBA");
}
Set<String> destinations = values.stream().map(value -> trim(value.line().getFbaWarehouseCode()))
.collect(Collectors.toSet());
if (destinations.size() != 1 || destinations.contains(null)) {
throw conflict("FBA bulk receiving requires one destination warehouse per inbound plan");
}
return values;
}
private InboundPlanPallet choosePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference resource) {
@@ -486,7 +543,17 @@ public class ReceivingContainerService {
: String.valueOf(ref.plan().getOverseasWarehouseId()), ref.order().getOverseasWarehouseName(),
ref.line().getDestinationWarehouseId(), ref.line().getDestinationWarehouseCode(),
ref.line().getDestinationWarehouseName(), ref.line().getFbaWarehouseCode(),
value(ref.line().getPlannedPalletCount()), total, received, Math.max(0, total - received));
value(ref.line().getPlannedPalletCount()), total, received, Math.max(0, total - received),
palletMergeRequired(ref.order()), palletMergeHint(ref.order()));
}
private boolean palletMergeRequired(LastlegOrder order) {
return order != null && ("REQUIRED".equals(order.getPalletMergeFlag())
|| "MERGED".equals(order.getPalletMergeFlag()));
}
private String palletMergeHint(LastlegOrder order) {
return palletMergeRequired(order) ? PALLET_MERGE_HINT : null;
}
private Totals totals(List<InboundPlan> containerPlans) {
@@ -510,7 +577,7 @@ public class ReceivingContainerService {
throw conflict("Exactly three typed unload photos are required");
}
if (request.photos().stream().anyMatch(Objects::isNull)) throw conflict("Unload photo cannot be null");
Set<com.baiyue.lastleg.inboundplan.pda.dto.ReceivingPhotoRole> roles = request.photos().stream()
Set<ReceivingPhotoRole> roles = request.photos().stream()
.map(ReceivingContainerRequests.SignedPhoto::role).collect(Collectors.toSet());
if (roles.size() != 3 || roles.contains(null)) throw conflict("Each unload photo role is required exactly once");
Set<Long> fileIds = request.photos().stream().map(value -> positiveFileId(value.fileId()))
@@ -520,7 +587,7 @@ public class ReceivingContainerService {
}
return request.photos().stream()
.sorted(Comparator.comparing(value -> value.role().ordinal()))
.map(value -> new ReceivingContainerResponses.SignedPhoto(value.role(), value.fileId())).toList();
.map(value -> new ReceivingContainerResponses.SignedPhoto(value.role(), value.fileId(), null)).toList();
}
private long positiveFileId(String value) {
@@ -533,44 +600,54 @@ public class ReceivingContainerService {
throw conflict("Unload photo file ID must be a positive decimal string");
}
private SkuRef requireSku(InboundRef ref, String skuCode) {
SkuRef result = skuRef(ref, skuCode);
if (result == null) throw notFound("SKU is not part of the selected inbound code");
private List<FbaSkuAllocation> allocateFbaSkuReceipts(String customerId, List<InboundRef> refs, int packageCount) {
int remaining = packageCount;
List<FbaSkuAllocation> result = new ArrayList<>();
for (InboundRef ref : refs) {
int linePending = value(ref.line().getRequiredPackageCount()) - receivedLinePackages(ref);
List<SkuExpectation> expectations = skuExpectations(ref);
int skuPending = expectations.stream().mapToInt(expectation -> expectation.expectedPackages()
- receivedSkuPackages(customerId, ref.line().getId(), expectation.skuCode())).sum();
if (linePending < 0 || skuPending != linePending) {
throw conflict("FBA SKU receipt progress must exactly match the inbound line progress");
}
for (SkuExpectation expectation : expectations) {
if (remaining == 0) break;
int skuPendingPackages = expectation.expectedPackages()
- receivedSkuPackages(customerId, ref.line().getId(), expectation.skuCode());
int accepted = Math.min(remaining, skuPendingPackages);
if (accepted > 0) {
result.add(new FbaSkuAllocation(ref, expectation.skuCode(), expectation.productName(), accepted));
remaining -= accepted;
}
}
if (remaining == 0) break;
}
if (remaining != 0) throw conflict("Received package count exceeds the FBA inbound pending total");
return result;
}
private SkuRef skuRef(InboundRef ref, String skuCode) {
String requested = trim(skuCode);
if (requested == null) return null;
private List<SkuExpectation> skuExpectations(InboundRef ref) {
List<LastlegOrderDetail> details = orderDetails.selectList(new LambdaQueryWrapper<LastlegOrderDetail>()
.eq(LastlegOrderDetail::getCustomerId, ref.order().getCustomerId())
.eq(LastlegOrderDetail::getOrderId, ref.order().getId()).orderByAsc(LastlegOrderDetail::getId));
List<LastlegOrderDetail> matched = details.stream()
.filter(detail -> requested.equalsIgnoreCase(trim(detail.getSkuCode()))).toList();
int expected = matched.stream().mapToInt(detail -> value(detail.getPackageCount())).sum();
if (expected <= 0) return null;
String canonicalSku = matched.stream().map(LastlegOrderDetail::getSkuCode).map(ReceivingContainerService::trim)
.filter(Objects::nonNull).findFirst().orElse(requested);
String productName = matched.stream().map(detail -> first(detail.getProductNameZh(), detail.getProductNameEn()))
.filter(Objects::nonNull).findFirst().orElse(null);
return new SkuRef(ref, canonicalSku, productName, expected);
}
private ReceivingContainerResponses.SkuResolved skuResponse(SkuRef sku) {
ReceivingContainerResponses.Inbound inbound = inbound(sku.ref());
int received = receivedSkuPackages(sku.ref().order().getCustomerId(), sku.ref().line().getId(), sku.skuCode());
return new ReceivingContainerResponses.SkuResolved(sku.skuCode(), sku.productName(), inbound.inboundCode(),
inbound.destinationType(), inbound.overseasWarehouseId(), inbound.overseasWarehouseName(),
inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(),
inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), inbound.plannedPalletCount(),
sku.expectedPackages(), received, Math.max(0, sku.expectedPackages() - received));
}
private void validateSkuPending(String customerId, SkuRef sku, int packageCount) {
int received = receivedSkuPackages(customerId, sku.ref().line().getId(), sku.skuCode());
if (packageCount <= 0 || packageCount > sku.expectedPackages() - received) {
throw conflict("Received package count exceeds the SKU pending total");
Map<String, SkuExpectation> grouped = new LinkedHashMap<>();
for (LastlegOrderDetail detail : details) {
String skuCode = trim(detail.getSkuCode());
int expected = value(detail.getPackageCount());
if (skuCode == null || expected <= 0) continue;
String key = skuCode.toUpperCase(java.util.Locale.ROOT);
SkuExpectation existing = grouped.get(key);
grouped.put(key, existing == null ? new SkuExpectation(skuCode,
first(detail.getProductNameZh(), detail.getProductNameEn()), expected)
: new SkuExpectation(existing.skuCode(), existing.productName(), existing.expectedPackages() + expected));
}
List<SkuExpectation> result = List.copyOf(grouped.values());
int expected = result.stream().mapToInt(SkuExpectation::expectedPackages).sum();
if (result.isEmpty() || expected != value(ref.line().getRequiredPackageCount())) {
throw conflict("Frozen FBA SKU package snapshots must exactly match the inbound line total");
}
return result;
}
private int receivedSkuPackages(String customerId, Long lineId, String skuCode) {
@@ -578,7 +655,15 @@ public class ReceivingContainerService {
.mapToInt(value -> value(value.getReceivedPackages())).sum();
}
private void recordSkuReceipt(ReceivingContainerSession session, SkuRef sku, InboundPlanPallet pallet, int packages) {
private int receivedLinePackages(InboundRef ref) {
return allocations.selectList(new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getCustomerId, ref.order().getCustomerId())
.eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId())
.eq(InboundPlanPalletAllocation::getLineId, ref.line().getId())).stream()
.mapToInt(value -> value(value.getPickedPackageCount())).sum();
}
private void recordSkuReceipt(ReceivingContainerSession session, FbaSkuAllocation sku, InboundPlanPallet pallet) {
List<ReceivingSkuReceipt> existing = skuReceipts.lockLineSku(session.getCustomerId(),
sku.ref().line().getId(), sku.skuCode());
ReceivingSkuReceipt receipt = existing.stream()
@@ -590,11 +675,11 @@ public class ReceivingContainerService {
receipt.setPlanId(sku.ref().plan().getId()); receipt.setLineId(sku.ref().line().getId());
receipt.setPalletId(pallet.getId()); receipt.setOrderId(sku.ref().order().getId());
receipt.setInboundCode(sku.ref().line().getInboundCode()); receipt.setSkuCode(sku.skuCode());
receipt.setProductName(sku.productName()); receipt.setReceivedPackages(packages);
receipt.setProductName(sku.productName()); receipt.setReceivedPackages(sku.packages());
receipt.setCreatedAt(now); receipt.setUpdatedAt(now); receipt.setVersion(0);
skuReceipts.insert(receipt);
} else {
receipt.setReceivedPackages(value(receipt.getReceivedPackages()) + packages);
receipt.setReceivedPackages(value(receipt.getReceivedPackages()) + sku.packages());
receipt.setUpdatedAt(now);
if (skuReceipts.updateById(receipt) != 1) throw conflict("SKU receiving progress changed concurrently");
}
@@ -603,21 +688,14 @@ public class ReceivingContainerService {
private void validateFbaSkuBalance(String customerId, String containerNo) {
for (InboundRef ref : refs(customerId, containerNo)) {
if (!"FBA".equals(destinationType(ref.line(), ref.order()))) continue;
List<LastlegOrderDetail> details = orderDetails.selectList(new LambdaQueryWrapper<LastlegOrderDetail>()
.eq(LastlegOrderDetail::getCustomerId, customerId).eq(LastlegOrderDetail::getOrderId, ref.order().getId()));
Set<String> skuCodes = details.stream().map(LastlegOrderDetail::getSkuCode)
.map(ReceivingContainerService::trim).filter(Objects::nonNull)
.map(value -> value.toUpperCase(java.util.Locale.ROOT))
.collect(Collectors.toCollection(java.util.LinkedHashSet::new));
int expected = 0;
List<SkuExpectation> skuCodes = skuExpectations(ref);
int received = 0;
for (String skuCode : skuCodes) {
SkuRef sku = requireSku(ref, skuCode);
for (SkuExpectation sku : skuCodes) {
int skuReceived = receivedSkuPackages(customerId, ref.line().getId(), sku.skuCode());
if (skuReceived != sku.expectedPackages()) throw conflict("Every FBA SKU must be fully received before completing the container");
expected += sku.expectedPackages(); received += skuReceived;
received += skuReceived;
}
if (skuCodes.isEmpty() || expected != value(ref.line().getRequiredPackageCount()) || received != expected) {
if (received != value(ref.line().getRequiredPackageCount())) {
throw conflict("FBA SKU package snapshots must exactly match the inbound code total");
}
}
@@ -726,6 +804,13 @@ public class ReceivingContainerService {
}
private List<ReceivingContainerResponses.SignedPhoto> signPhotos(ReceivingContainerSession session) {
return signedPhotos(session).stream()
.map(photo -> new ReceivingContainerResponses.SignedPhoto(photo.role(), photo.fileId(),
PHOTO_DOWNLOAD_PATH.replace("{fileId}", photo.fileId())))
.toList();
}
private List<ReceivingContainerResponses.SignedPhoto> signedPhotos(ReceivingContainerSession session) {
if (session.getSignPhotos() == null || session.getSignPhotos().isBlank()) return List.of();
try { return objectMapper.readValue(session.getSignPhotos(),
new TypeReference<List<ReceivingContainerResponses.SignedPhoto>>() { }); }
@@ -763,7 +848,9 @@ public class ReceivingContainerService {
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
private record InboundRef(InboundPlan plan, InboundPlanLine line, LastlegOrder order) { }
private record SkuRef(InboundRef ref, String skuCode, String productName, int expectedPackages) { }
private record CartonRef(InboundRef ref) { }
private record SkuExpectation(String skuCode, String productName, int expectedPackages) { }
private record FbaSkuAllocation(InboundRef ref, String skuCode, String productName, int packages) { }
private record Totals(int inboundCount, List<String> destinationCodes, int plannedPalletCount,
int total, int received) { }
private record PrepareCommand(String containerNo, String idempotencyKey) { }

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